186 lines
11 KiB
JSON
186 lines
11 KiB
JSON
{
|
||
"topic": "cache-read-write-strategy",
|
||
"type": "code_reading",
|
||
"schema_version": "1.0.0",
|
||
"generated": "09-2026-09-05T00:00:00+08:00",
|
||
"questions": [
|
||
{
|
||
"id": "cr-001",
|
||
"type": "code_reading",
|
||
"difficulty": 3,
|
||
"tags": [
|
||
"Cache-Aside",
|
||
"读策略",
|
||
"回填"
|
||
],
|
||
"question": "阅读以下 Cache-Aside 读路径的 Go 伪代码,回答子问题。",
|
||
"code": "func GetUser(ctx, id) *User {\n // 1. 查缓存\n if v, ok := cache.Get(id); ok {\n return v // 缓存命中\n }\n // 2. 查数据库\n user := db.QueryUser(id)\n if user == nil {\n return nil\n }\n // 3. 回填缓存\n cache.Set(id, user, expireTime)\n return user\n}",
|
||
"language": "go",
|
||
"sub_questions": [
|
||
{
|
||
"index": 1,
|
||
"type": "short_answer",
|
||
"question": "这段代码实现的是哪种缓存读写策略?读路径分几步?",
|
||
"answer": "实现的是 Cache-Aside(旁路缓存)策略的读路径,分三步:先查缓存,缓存 miss 后查数据库,最后把结果回填缓存并返回。",
|
||
"explanation": "应用层管理缓存命中与回填,正是 Cache-Aside 的典型写法。三步恰好对应「查缓存→查库→回填」。"
|
||
},
|
||
{
|
||
"index": 2,
|
||
"type": "single_choice",
|
||
"question": "如果多个并发请求同时对同一冷 key 执行这段代码,会产生什么现象?",
|
||
"options": {
|
||
"A": "多个请求同时击穿缓存打到数据库(缓存击穿)",
|
||
"B": "不会有任何额外数据库访问",
|
||
"C": "缓存会被删除",
|
||
"D": "数据库写入异常"
|
||
},
|
||
"answer": "A",
|
||
"explanation": "代码没有加缓存空值回填或分布式锁,多个并发 miss 请求会同时查询数据库并在各自回填,形成缓存击穿。"
|
||
}
|
||
],
|
||
"explanation": "Cache-Aside 读路径的标准写法是「查缓存→miss 查库→回填」。但该版本未做并发防护,多个并发 miss 会同时穿透到数据库,实战中可配合互斥锁、空值缓存或 refreshAfterWrite 缓解击穿。",
|
||
"source": null,
|
||
"related": []
|
||
},
|
||
{
|
||
"id": "cr-002",
|
||
"type": "code_reading",
|
||
"difficulty": 4,
|
||
"tags": [
|
||
"Cache-Aside",
|
||
"写策略",
|
||
"竞态"
|
||
],
|
||
"question": "阅读以下 Cache-Aside 写路径的 Go 伪代码,判断其写库与删缓存的顺序是否合理。",
|
||
"code": "func UpdateUser(id, name string) {\n db.UpdateUser(id, name) // 1. 先写数据库\n cache.Del(id) // 2. 删除缓存让其失效\n}",
|
||
"language": "go",
|
||
"sub_questions": [
|
||
{
|
||
"index": 1,
|
||
"type": "short_answer",
|
||
"question": "这段写路径符合 Cache-Aside 的什么原则?为什么先写库再删缓存?",
|
||
"answer": "符合 Cache-Aside「先写数据库,再删除缓存」的原则。写库保证数据库(真相源)永远是最新的,删除缓存使旧值立即失效,避免缓存与库长期不一致;下次读 miss 后回填的就是新值。",
|
||
"explanation": "若先删缓存再写库,在写库完成前缓存为空,期间读请求会把旧值回填,反而更容易制造不一致。先写库再删缓存原则更优。"
|
||
},
|
||
{
|
||
"index": 2,
|
||
"type": "short_answer",
|
||
"question": "该写路径存在什么样的并发竞态窗口?简述一个严重场景。",
|
||
"answer": "存在「读线程回填旧值」的竞态:读请求在删缓存前已在数据库读到旧值,然后其在删缓存之后的时刻才回填缓存,导致旧值被重新写回缓存并长期残留,delete 无法治愈。",
|
||
"explanation": "这正是竞态窗口的来源2。删缓存只能清掉此刻存在的旧值,却不能阻止删缓存之后读线程把库里的旧值再回填进来。可配合双删延时或版本号回填根治。"
|
||
}
|
||
],
|
||
"explanation": "Cache-Aside 写策略以「先写库、再失效缓存」为原则,比「先删缓存后写库」更不容易留不一致。但它仍未根治「删缓存后旧值被回填」的竞态,需配合双删+延时或版本号回填。",
|
||
"source": null,
|
||
"related": []
|
||
},
|
||
{
|
||
"id": "cr-003",
|
||
"type": "code_reading",
|
||
"difficulty": 4,
|
||
"tags": [
|
||
"双删",
|
||
"延时",
|
||
"竞态"
|
||
],
|
||
"question": "阅读以下「双删+延时」写路径的 Go 伪代码,回答子问题。",
|
||
"code": "func SafeUpdate(key, val) {\n db.Update(key, val) // 1. 写库\n cache.Del(key) // 2. 第一次删除缓存\n time.Sleep(50*time.Millisecond) // 3. 延时约50ms\n cache.Del(key) // 4. 第二次删除缓存\n}",
|
||
"language": "go",
|
||
"sub_questions": [
|
||
{
|
||
"index": 1,
|
||
"type": "short_answer",
|
||
"question": "这段代码叫什么方案?延时与第二次删除的目的是什么?",
|
||
"answer": "这是「双删 + 延时」方案。借助 50ms 延时等待第一阶段中可能在删缓存后把库存旧值回填缓存读线程完成回填操作,然后在第二步再用缓存删除其刚回填的旧值,使它不会长期残留。",
|
||
"explanation": "第一次删缓存清掉当前旧值;延时让并发读回填完成;第二次删掉刚回填的旧值。延时通常取数十毫秒以覆盖回填窗口。"
|
||
},
|
||
{
|
||
"index": 2,
|
||
"type": "short_answer",
|
||
"question": "如果第二次删除因某种原因失败且没有补偿,会产生什么影响?如何改进?",
|
||
"answer": "若第二次删除失败,第二次删除时刚回填的旧值会长期残留,直到随机过时。可把第二次删除改为 MQ 延时消息,失败后重试兜底(延迟双删+MQ),或改用版本号回填。",
|
||
"explanation": "二次删除失败会使旧值残留;MQ 延时消息可在失败时重试,或使用版本号从根源阻止旧值回填,从而提升可靠性。"
|
||
}
|
||
],
|
||
"explanation": "双删+延时通过两次删除覆盖并发回填窗口,降低旧值残留概率。但仍是概率性方案,第二次删除受网络/进程影响可能失败,需要 MQ 兜底或升级为版本号回填才能根治。",
|
||
"source": null,
|
||
"related": []
|
||
},
|
||
{
|
||
"id": "cr-004",
|
||
"type": "code_reading",
|
||
"difficulty": 5,
|
||
"tags": [
|
||
"版本号回填",
|
||
"竞态",
|
||
"回填"
|
||
],
|
||
"question": "阅读以下带版本号的 Cache-Aside 读回填 Go 伪代码,判断它能否防止旧值覆盖新值,并回答问题。",
|
||
"code": "type CacheItem struct {\n Version int64\n Data *User\n}\n\nfunc GetUser(id string) *User {\n // 1. 读缓存\n item := cache.Get(id) // * CacheItem\n if item != nil {\n return item.Data // 缓存命中\n }\n // 2. 查库,带出新版本号\n ver := db.GetVersion(id)\n u := db.QueryUser(id)\n // 3. 回填:若缓存里已有更大的版本则丢弃\n if cur, ok := cache.Get(id); ok && cur.Version > ver {\n return u // 本线程拿到的是旧版本,不回填\n }\n cache.Set(id, &CacheItem{Version: ver, Data: u})\n return u\n}",
|
||
"language": "go",
|
||
"sub_questions": [
|
||
{
|
||
"index": 1,
|
||
"type": "short_answer",
|
||
"question": "这段代码为什么能防止并发「旧值回填」把内存中的新值覆盖掉?",
|
||
"answer": "回填前会先读缓存中当前的版本号,只有当前版本号不大于待回填版本时才写入。若并发写线程已把新版本回填,读线程缓存版本号偏小对比失败,就不会回填,从而杜绝旧值覆盖新值。",
|
||
"explanation": "核心是「回填前比较版本号」:缓存里已有更高版本,旧读线程直接丢弃本次回填。这从根上解决 Cache- 旁路回填旧值的问题。"
|
||
},
|
||
{
|
||
"index": 2,
|
||
"type": "single_choice",
|
||
"question": "若并发读线程在缓存版本比较通过后即将写入,与此同时写库线程把版本从 5 升到 6,可能发生什么?",
|
||
"options": {
|
||
"A": "必然覆盖新版本,导致旧值残留",
|
||
"B": "一定安全,版本号彻底根治所有窗口",
|
||
"C": "需配合原子比较-设置(CAS)才能保证比较与写入的原子性,否则仍存在极小窗口",
|
||
"D": "缓存会自动回滚"
|
||
},
|
||
"answer": "C",
|
||
"explanation": "代码里的「读版本→比较→写入」三步不是原子的。若想严格保证,应对写入使用原子版号(CAS,如 Lua 或版本号伪装),才能彻底避免中间窗口。"
|
||
}
|
||
],
|
||
"explanation": "版本号回填通过「回填前比较、旧版本丢弃」杜绝了旧值覆盖,是对 Cache-Aside 竞态窗口来源2最彻底的根治方案;若要做到极致严格,比较与写入这段需配合 CAS 等原子操作消除极小间隙。",
|
||
"source": null,
|
||
"related": []
|
||
},
|
||
{
|
||
"id": "cr-005",
|
||
"type": "code_reading",
|
||
"difficulty": 3,
|
||
"tags": [
|
||
"Write-Through",
|
||
"Write-Behind"
|
||
],
|
||
"question": "阅读以下两种缓存写路径的 Go 伪代码,判断分别属于哪种写策略,并回答。",
|
||
"code": "// 写法 A\nfunc WriteThrough(key, val) {\n cache.Set(key, val) // 1. 先写缓存\n db.Update(key, val) // 2. 同步写数据库\n}\n\n// 写法 B\nfunc WriteBehind(key, val) {\n cache.Set(key, val) // 1. 只先写缓存\n go batchFlushToDB(key, val) // 2. 异步/批量刷库,直接返回\n}",
|
||
"language": "go",
|
||
"sub_questions": [
|
||
{
|
||
"index": 1,
|
||
"type": "short_answer",
|
||
"question": "区分写 A 与写 B 分别是哪种缓存写策略?",
|
||
"answer": "写 A 是 Write-Through(同步写穿透):缓存与数据库同步写入;写 B 是 Write-Behind(异步回写),先写缓存立即返回,数据库由后台异步批量刷写。",
|
||
"explanation": "是否同步写库是区分关键:A 同步写库为 Write-Through,B 异步写库为 Write-Back/Write-Behind。"
|
||
},
|
||
{
|
||
"index": 2,
|
||
"type": "single_choice",
|
||
"question": "对于记录用户登录次数的计数场景且外部能接受偶尔丢失,应选用哪种?",
|
||
"options": {
|
||
"A": "写 B(Write-Behind),吞吐高、可容忍最终一致与偶发丢失",
|
||
"B": "写 A(Write-Through),强一致但慢",
|
||
"C": "两者都完全等价可用",
|
||
"D": "都不适合缓存"
|
||
},
|
||
"answer": "A",
|
||
"explanation": "登录计数属最终一致+可容忍丢失的高频写数据,Write-Behind 吞吐高。若为余额、库存这类不可丢强一致数据则必须立刻同步写库(或走事务,不适宜写入缓存主链路)。"
|
||
}
|
||
],
|
||
"explanation": "Write-Through 与 Write-Behind 的核心区别在于是否同步写库。Write-Behind 以可能丢写、乱序、外部读旧为代价换取吞吐,适用于计数/会话等最终一致数据。",
|
||
"source": null,
|
||
"related": []
|
||
}
|
||
]
|
||
}
|