feat: add cache-system topic with 105 questions (穿透/击穿/雪崩, 读写策略, 架构选型)
Deploy Examination / deploy (push) Successful in 5s

This commit is contained in:
2026-09-05 12:03:21 +08:00
parent 2785c25dc9
commit 0015449dcf
16 changed files with 2834 additions and 0 deletions
@@ -0,0 +1,185 @@
{
"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": []
}
]
}
@@ -0,0 +1,191 @@
{
"topic": "cache-read-write-strategy",
"type": "fill_blank",
"schema_version": "1.0.0",
"generated": "09-2026-09-05T00:00:00+08:00",
"questions": [
{
"id": "fb-001",
"type": "fill_blank",
"difficulty": 1,
"tags": [
"Cache-Aside",
"写策略"
],
"question": "Cache-Aside 策略的写流程是:先写______,再______缓存。",
"answer": [
"数据库",
"删除(失效)"
],
"answer_rule": "ordered",
"explanation": "Cache-Aside 的标准写流程:先把数据库当作真相源写入,再把缓存中的该 key 删除使其失效,等下次读请求 miss 后再回填,从而避免缓存与数据库长时间不一致。",
"source": null,
"related": []
},
{
"id": "fb-002",
"type": "fill_blank",
"difficulty": 1,
"tags": [
"Cache-Aside",
"Read-Through",
"CacheLoader"
],
"question": "Cache-Aside 的读流程是:先查缓存,若 miss 则查询______,并把结果______到缓存后返回。",
"answer": [
"数据库",
"回填(写入)"
],
"answer_rule": "ordered",
"explanation": "读请求先查缓存,miss 时由应用查数据库拿到最新值并回填缓存,再返回给调用方。这样后续相同 key 的读可以直接命中缓存,减少数据库压力。",
"source": null,
"related": []
},
{
"id": "fb-003",
"type": "fill_blank",
"difficulty": 3,
"tags": [
"Write-Through",
"Write-Behind"
],
"question": "Write-Through 策略写操作是______地把数据写入缓存和数据库;而 Write-Behind 是先写入缓存、数据库由后台______批量刷写。",
"answer": [
"同步",
"异步"
],
"answer_rule": "ordered",
"explanation": "Write-Through 写缓存即同步写数据库,强一致但慢;Write-Behind 写入缓存后立即返回成功,数据库由后台攒批/定时异步刷写,吞吐高但有丢失与乱序风险。",
"source": null,
"related": []
},
{
"id": "fb-004",
"type": "fill_blank",
"difficulty": 3,
"tags": [
"Write-Behind",
"Write-Back",
"数据安全"
],
"question": "Write-Behind 策略在返回成功到数据落库之间若缓存进程宕机,会带来______风险;因此它只适合最终一致且可容忍______的数据,不适合交易、余额、库存。",
"answer": [
"丢失",
"丢失"
],
"answer_rule": "any",
"explanation": "Write-Behind 先缓存后异步落库,若缓存宕机且尚未刷库则数据永久丢失。因此只适用于「最终一致 + 可容忍丢失」的数据,例如计数、会话、中间态;交易、余额等绝不可用。",
"source": null,
"related": []
},
{
"id": "fb-005",
"type": "fill_blank",
"difficulty": 3,
"tags": [
"多级缓存",
"L1",
"L2"
],
"question": "经典多级缓存结构是 L1 ______ 缓存 → L2 ______ 缓存 → DB。",
"answer": [
"本地(进程内)",
"Redis(共享)"
],
"answer_rule": "ordered",
"explanation": "经典多级缓存为「L1 本地进程内缓存(如 Caffeine)→ L2 Redis(共享)→ DB」。本地命中最快零网络,Redis 次之毫秒级,DB 兜底保证最终一致。",
"source": null,
"related": []
},
{
"id": "fb-006",
"type": "fill_blank",
"difficulty": 4,
"tags": [
"多级缓存",
"一致性"
],
"question": "多级缓存架构最大的代价是其______:同一数据在多个缓存层、多个节点上有副本,更新后若任一副本残留旧值就会不一致。写路径需要在先写数据库后,逐层________。",
"answer": [
"一致性",
"失效(删除/清理)"
],
"answer_rule": "ordered",
"explanation": "多级缓存中同一数据在 L1、L2 及多节点均有副本,跨层跨节点难以保证实时一致,所以一致性是最大代价。写路径以数据库为真相源,写后须逐层失效:删除 Redis 并广播清理各节点本地缓存。",
"source": null,
"related": []
},
{
"id": "fb-007",
"type": "fill_blank",
"difficulty": 4,
"tags": [
"双删",
"延时",
"竞态"
],
"question": "双删方案是:更新数据库后先删一次缓存,延时约______毫秒后再删一次,用来清掉第一次删除之后刚被回填的旧值。",
"answer": [
"50"
],
"answer_rule": "any",
"explanation": "标准双删的延时通常在几十毫秒(教材常取约 50ms),目的是让可能已读取旧值并发起回填的并发读请求完成回填,然后用第二次删除把它清掉。延时过短则回填可能还没发生,过长则会放大读 miss 窗口。",
"source": null,
"related": []
},
{
"id": "fb-008",
"type": "fill_blank",
"difficulty": 4,
"tags": [
"版本号回填",
"竞态"
],
"question": "在解决「旧值被回填」竞态问题的方案里,最彻底的是______回填:缓存数据带版本号,回填前比较,旧版本直接丢弃。",
"answer": [
"版本号"
],
"answer_rule": "any",
"explanation": "版本号回填在缓存里同时存业务值和版本号,回填前比较新旧版本,旧版本直接丢弃,杜绝读线程把库里的旧值重新写回缓存。相比双删的「概率性清理」更彻底,是根治竞态窗口来源2的方案。",
"source": null,
"related": []
},
{
"id": "fb-009",
"type": "fill_blank",
"difficulty": 3,
"tags": [
"CacheLoader",
"refreshAfterWrite",
"Read-Through"
],
"question": "Caffeine 用 ________ 把 Cache-Aside 封装成 Read-Through,缓存 miss 时自动加载;用 ________ 在写后到点异步刷新,防止热点 key 被击穿。",
"answer": [
"CacheLoader",
"refreshAfterWrite"
],
"answer_rule": "ordered",
"explanation": "CacheLoader 让 Caffeine 在 miss 时自动回调加载数据(Read-Through 的本地版);refreshAfterWrite 在写入指定时长后异步刷新,避免过期瞬间大量请求同时穿透数据库造成击穿。",
"source": null,
"related": []
},
{
"id": "fb-010",
"type": "fill_blank",
"difficulty": 3,
"tags": [
"Caffeine",
"TinyLFU"
],
"question": "Caffeine 的淘汰算法是 ________,它通过记录 key 的访问________来判定淘汰,比 LRU 更能保留高频 key。",
"answer": [
"TinyLFU",
"频率"
],
"answer_rule": "ordered",
"explanation": "Caffeine 使用 TinyLFU 淘汰算法,维护计数器记录 key 的访问频率并带时间衰减与最近访问,可避免 LRU 被批量顺序扫描冲垮,更适合热点集中的真实负载。",
"source": null,
"related": []
}
]
}
@@ -0,0 +1,53 @@
{
"slug": "cache-read-write-strategy",
"name": "多级缓存与读写策略",
"description": "Cache-Aside、读写穿透、多级缓存路径、双删与版本号竞态优化、Caffeine 本地缓存",
"tags": [
"Cache-Aside",
"CacheLoader",
"Caffeine",
"L1",
"L2",
"Read-Through",
"TinyLFU",
"Write-Back",
"Write-Behind",
"Write-Through",
"refreshAfterWrite",
"一致性",
"丢失",
"乱序",
"写策略",
"双删",
"回填",
"多级缓存",
"延时",
"数据安全",
"淘汰算法",
"版本号回填",
"竞态",
"读策略",
"选择"
],
"difficulty_range": [
1,
5
],
"schema_version": "1.0.0",
"updated": "2026-09-05",
"question_files": [
"single_choice",
"fill_blank",
"short_answer",
"code_reading"
],
"stats": {
"total": 35,
"by_type": {
"single_choice": 10,
"fill_blank": 10,
"short_answer": 10,
"code_reading": 5
}
}
}
@@ -0,0 +1,244 @@
{
"topic": "cache-read-write-strategy",
"type": "short_answer",
"schema_version": "1.0.0",
"generated": "09-2026-09-05T00:00:00+08:00",
"questions": [
{
"id": "sa-001",
"type": "short_answer",
"difficulty": 2,
"tags": [
"Cache-Aside",
"Read-Through",
"Write-Through",
"Write-Behind"
],
"question": "分别简述 Cache-Aside、Read-Through、Write-Through、Write-Behind 四种缓存读写策略在「读」和「写」上的核心差别。",
"answer": "Cache-Aside:读时 miss 后应用自行查库并回填缓存;写时先写数据库再删缓存。Read-Through:把 miss→查库→回填封装进缓存层,应用只读缓存。Write-Through:写缓存的同时同步写数据库,强一致但慢。Write-Behind/Write-Back:写请求先写缓存即返回成功,DB 由后台异步批量刷写,吞吐高但有丢失与乱序风险。",
"keywords": [
"Cache-Aside",
"Read-Through",
"Write-Through",
"Write-Behind",
"写库",
"删缓存",
"异步"
],
"scoring_rubric": "正确描述 Cache-Aside 读写流程得 30 分;Read-Through 封装 miss 回填得 15 分;Write-Through 同步写库得 15 分;Write-Behind 异步刷库及其丢失风险得 40 分。",
"explanation": "四种策略可从「谁负责回填」「何时写库」两个维度区分:Cache-Aside 由应用回填、先写库再删缓存;Read-Through 由缓存层回填;Write-Through 同步写库;Write-Back 异步写库,牺牲一致性换取吞吐。",
"source": null,
"related": []
},
{
"id": "sa-002",
"type": "short_answer",
"difficulty": 3,
"tags": [
"Write-Behind",
"Write-Back",
"数据安全"
],
"question": "Write-Behind 策略采用「先写缓存、后台异步落库」的方式,带来了哪些风险?为什么交易、余额、库存等数据不能用它?",
"answer": "风险有三:①丢失——缓存进程在落库前宕机则数据丢失;②乱序——异步并发落库可能导致写库顺序与业务发生顺序不一致;③外部读旧值——直接读 DB 的一方可能读到旧值。由于存在这些风险只能保证最终一致,余额、库存、交易等要求强一致、绝不丢数据,所以不可用。",
"keywords": [
"丢失",
"乱序",
"读旧值",
"最终一致",
"强一致"
],
"scoring_rubric": "答出丢失风险得 30 分;乱序得 20 分;外部读旧值得 20 分;说明交易/余额不可用及原因得 30 分。",
"explanation": "Write-Behind 的性价比来自「写进缓存即返回」,代价是数据可能丢失、乱序、外部读到旧值,只适合可容忍最终一致与丢失的数据(计数、会话、中间态)。",
"source": null,
"related": []
},
{
"id": "sa-003",
"type": "short_answer",
"difficulty": 3,
"tags": [
"多级缓存",
"L2",
"Caffeine"
],
"question": "给出经典多级缓存的结构(L1/L2/DB),并说明各自的职责与读路径。",
"answer": "经典结构为 L1 本地进程内缓存(Caffeine)→ L2 Redis(共享)→ DB。L1 负责当前进程热点 key 的低延迟读取,零网络开销;L2 负责跨节点共享数据;DB 为真相源兜底。读路径逐层 miss 逐层回填:本地命中最快,Redis 命中次之(毫秒级),DB 兜底。",
"keywords": [
"L1",
"Caffeine",
"L2",
"Redis",
"DB",
"逐层回填",
"miss"
],
"scoring_rubric": "结构写对 L1/L2/DB 得 40 分;职责描述正确得 30 分;读路径逐层回填得 30 分。",
"explanation": "多级缓存的目的在于利用本地缓存的极低延迟承接热点读,Redis 承接跨节点共享,DB 兜底。读路径逐层 miss 逐层回填,命中最快的本地缓存优先。",
"source": null,
"related": []
},
{
"id": "sa-004",
"type": "short_answer",
"difficulty": 4,
"tags": [
"多级缓存",
"一致性"
],
"question": "为什么多级缓存是实现时最大的痛点是「一致性」?多级缓存的写路径应当如何处理?",
"answer": "同一数据在 L1 本地、L2 Redis、多个节点上同时存在副本,更新时若任一副本残留旧值就会读到不一致数据,且跨层跨节点难以实时同步。因此写路径应以数据库为真相源:先写 DB,再逐层失效,即删除 Redis 并广播清理所有节点的本地缓存;必要时配合短 TTL / 消息兜底。",
"keywords": [
"一致性",
"副本",
"先写DB",
"逐层失效",
"广播",
"TTL"
],
"scoring_rubric": "解释一致性的成因为何(多副本)得 30 分;写路径先写 DB 得 20 分;逐层失效(删 Redis、广播本地)得 40 分;补充短 TTL 兜底得 10 分。",
"explanation": "多级缓存把一致性数据复制到多个位置换来读性能,代价是跨层一致性。写路径必须统一以数据库为真相源,写后逐层失效并通过广播清理各节点本地副本,必要时加短 TTL 兜底。",
"source": null,
"related": []
},
{
"id": "sa-005",
"type": "short_answer",
"difficulty": 4,
"tags": [
"Cache-Aside",
"双删",
"延时"
],
"question": "说明 Cache-Aside「先写库再删缓存」的竞态窗口中有哪两个旧值来源,并简述「双删+延时」优化的做法与原理。",
"answer": "两个来源:来源1为读请求在删缓存前命中缓存旧值(窗口很短通常无需处理);来源2(严重)为读线程在库中读到旧值后回填缓存,且可能发生在删缓存之后,导致旧值永久残留。双删做法:写库后先删缓存,延时约 50ms 后再删一次,目的是清掉第一次删除后刚回填的旧值;延时需覆盖并发读回填的窗口。",
"keywords": [
"删缓存前命中旧值",
"回填旧值",
"双删",
"延时",
"50ms"
],
"scoring_rubric": "正确指出两种来源各得 25 分;说明双删流程得 25 分;说明延时目的与原理得 25 分。",
"explanation": "竞态的核心在于第二次删除发生在回填之前,双删+延时让第二次删除覆盖回填窗口,清理刚被写回的旧值。它只能把残留概率降得很低,版本号回填才能根治。",
"source": null,
"related": []
},
{
"id": "sa-006",
"type": "short_answer",
"difficulty": 4,
"tags": [
"版本号回填",
"双删",
"竞态"
],
"question": "为什么说「版本号回填」能根治 Cache-Aside 竞态窗口中的「旧值回填」问题?它与双删相比有何优劣?",
"answer": "版本号回填在缓存里同时存业务值和版本号,写库时携带新版本号,读线程回填前先把缓存当前版本号与将回填数据的版本号比较,旧版本直接丢弃。这样读线程永远不会把旧版本覆盖进去,从根上杜绝旧值残留。双删只能概率性把第一次删后回填的旧值二次清掉,无法保证绝对干净且会放大读 miss 窗口;版本号代价是需要维护版本并原子比较替换。",
"keywords": [
"版本号",
"回填前比较",
"丢弃旧版本",
"根治"
],
"scoring_rubric": "说清缓存带版本号并比较得 40 分;说明旧版本丢弃原理(根治)得 30 分;对比双删的优劣得 30 分。",
"explanation": "版本号方案把一致性判定下沉到回填动作本身,因此能从根上避免旧值被写回;双删只能概率性清理,且延时等待放大读 miss 窗口。两者可组合使用。",
"source": null,
"related": []
},
{
"id": "sa-007",
"type": "short_answer",
"difficulty": 3,
"tags": [
"CacheLoader",
"refreshAfterWrite",
"Caffeine",
"Read-Through"
],
"question": "Caffeine 的 CacheLoader 与 refreshAfterWrite 各自的作用是什么?如何配合防止缓存击穿?",
"answer": "CacheLoader 在缓存 miss 时自动回调加载数据并回填,把 Cache- 封装成 Read-Through 的本地版;refreshAfterWrite 在条目标写入指定时长后异步刷新,避免缓存到期瞬间大量请求同时穿透到数据库。两者配合可在单条失效前提前异步刷新热点数据,显著降低击穿风险。",
"keywords": [
"CacheLoader",
"回填",
"refreshAfterWrite",
"异步刷新",
"击穿"
],
"scoring_rubric": "CacheLoader 的 miss 自动加载得 30 分;refreshAfterWrite 的异步刷新得 40 分;结合避免击穿得 30 分。",
"explanation": "CacheLoader 负责把旁路加载封装成缓存内自动加载;refreshAfterWrite 通过在到期前异步刷新,把原本集中在某一时刻的回填错开,从而缓解或避免击穿。",
"source": null,
"related": []
},
{
"id": "sa-008",
"type": "short_answer",
"difficulty": 2,
"tags": [
"Caffeine",
"TinyLFU",
"多级缓存"
],
"question": "说明为什么多级缓存的 L1 层通常首选 Caffeine 而非 Redis,以及 Caffeine 相比 Redis 的局限。",
"answer": "Caffeine 是 Java 进程内存宿主缓存,毫纳秒级读取、读无锁,作为 L1 能提供最低延迟扛住超高 QPS,其 TinyLFU 淘汰算法对热点负载友好;Redis 是分布式共享缓存,需网络往返。局限在于 Caffeine 只是单机副本:不跨节点共享、重启即丢失、无法持久化/主从,多节点之间与本节点缓存一致性需额外机制维护。",
"keywords": [
"本地进程内",
"低延迟",
"不共享",
"重启丢失",
"TinyLFU"
],
"scoring_rubric": "Caffeine 的本地低延迟/高吞吐优点得 30 分;TinyLFU 与共享共识得 20 分;单机副本、不共享、无法持久的局限得 50 分。",
"explanation": "L1 追求的是极低延迟,Caffeine 本地化天然满足;Redis 的价值在于共享与持久,二者定位不同、互补组成 L1+L2。",
"source": null,
"related": []
},
{
"id": "sa-009",
"type": "short_answer",
"difficulty": 3,
"tags": [
"多级缓存",
"选择"
],
"question": "结合读多写少、容忍秒级延迟的标准,说明什么时候应该使用多级缓存,什么时候不应该。",
"answer": "应使用:读频率高、数据更新少或可容忍秒级落后的热点数据,如商品详情、榜单、静态字典、可容忍稍慢的配置。应避免:强一致实时数据如库存、余额、订单、实时价格,以及写密集或请求量本身不大的场景,因为多级缓存的主要收益是读速、代价是一致性,写密集/强一致场景得不偿失。",
"keywords": [
"读多写少",
"可容忍秒级落后",
"热点",
"强一致",
"库存余额"
],
"scoring_rubric": "写应用场景(读多写少/热点/容忍稍慢)得 50 分;写不应用场景(强一致/写密集化/价格真实时)得 50 分。",
"explanation": "多级缓存的价值前提是读远多于写、热点集中、能容忍少量秒级落后;对一致性敏感或写密集的数据,多副本不一致的代价超过收益。",
"source": null,
"related": []
},
{
"id": "sa-010",
"type": "short_answer",
"difficulty": 3,
"tags": [
"多级缓存",
"Cache-Aside",
"Read-Through",
"Write-Behind"
],
"question": "客户只知道缓存读写,想让缓存层自动加载与异步落库时,会选择怎样的组合?这种主链路模式的代价有哪些?",
"answer": "会采用 Read-Through + Write-Behind 组合:客户端只读写缓存,缓存 miss 时由缓存层自动查库回填(Read-Through),写则由后台异步批量刷库(Write-Behind)。代价:写操作可能丢失、可能乱序、外部直接读库的一方会读到旧值;而且交易、余额这类强一致数据绝不合适进入这类缓存主链路。",
"keywords": [
"Read-Through",
"Write-Behind",
"异步",
"丢失",
"乱序",
"读旧值"
],
"scoring_rubric": "正确组合(Read-Through + Write-Behind)得 40 分;说明客户端只需读写缓存得 15 分;答出三种代价之一得 15 分(共 45 分)。",
"explanation": "Read-Through + Write-Behind 让应用只读写缓存、把命中回填与落库都交给缓存层,代价是丢失、乱序、外部读旧值,属于「最终一致且容忍丢写」的组合。",
"source": null,
"related": []
}
]
}
@@ -0,0 +1,215 @@
{
"topic": "cache-read-write-strategy",
"type": "single_choice",
"schema_version": "1.0.0",
"generated": "09-2026-09-05T00:00:00+08:00",
"questions": [
{
"id": "sc-001",
"type": "single_choice",
"difficulty": 1,
"tags": [
"Cache-Aside",
"写策略"
],
"question": "Cache-Aside(旁路缓存)策略中,写操作的标准流程是?",
"options": {
"A": "先删除缓存,再写数据库",
"B": "先写数据库,再失效(删除)缓存",
"C": "只写缓存,不同步数据库",
"D": "先写缓存,再同步写数据库"
},
"answer": "B",
"explanation": "Cache-Aside 的主流写法是「先写 DB(真相源),再删除缓存」,下次读请求 miss 后重新查库回填。选项 A 是容易引入不一致的反模式,C 会丢失数据,D 是 Write-Through 的做法。",
"source": null,
"related": []
},
{
"id": "sc-002",
"type": "single_choice",
"difficulty": 1,
"tags": [
"Cache-Aside",
"CacheLoader",
"Read-Through"
],
"question": "Cache-Aside 策略中,读请求发生缓存 miss 时的正确处理是?",
"options": {
"A": "直接返回空,不访问数据库",
"B": "由应用自行查询数据库,并把结果回填(写入)缓存",
"C": "只把数据返回给调用方,不回填缓存",
"D": "删除该 key 对应的缓存后再返回"
},
"answer": "B",
"explanation": "Cache-Aside 的读路径:先查缓存,miss 后由应用查数据库,拿到结果后回填缓存并返回。这样后续相同 key 的读请求都能命中缓存,避免每次打库。C 不回填会导致缓存失去意义。",
"source": null,
"related": []
},
{
"id": "sc-003",
"type": "single_choice",
"difficulty": 3,
"tags": [
"Write-Through",
"Write-Behind",
"一致性"
],
"question": "关于 Write-Through(写穿透)与 Write-Behind(异步回写)的区别,以下说法正确的是?",
"options": {
"A": "Write-Through 先写缓存后异步批量刷库,吞吐高但可能丢数据",
"B": "Write-Behind 写缓存即同步写数据库,强一致但慢",
"C": "Write-Through 是「先写缓存再同步写数据库」,一致性较强但写性能较低",
"D": "两种策略在写路径上完全等价"
},
"answer": "C",
"explanation": "Write-Through 写缓存即同步写 DB,一致性较强但慢;Write-Behind 写进缓存就先返回,DB 由后台异步批量刷写,吞吐高但有丢失/乱序风险。A、B 把两者对调混淆了,D 错误。",
"source": null,
"related": []
},
{
"id": "sc-004",
"type": "single_choice",
"difficulty": 3,
"tags": [
"Write-Behind",
"Write-Back",
"数据安全"
],
"question": "对于交易、余额、库存这类强一致+不可丢失的数据,下列哪种缓存策略是绝不可取的?",
"options": {
"A": "Cache-Aside(先写库再删缓存)",
"B": "Write-Through(写库即同步写库)",
"C": "Write-Behind(异步批量落库)",
"D": "Read-Through(读缓存 miss 自动查库回填)"
},
"answer": "C",
"explanation": "Write-Behind/Write-Back 采用异步批量刷库,存在缓存宕机导致写入丢失、乱序、外部读到旧值等风险,只适合最终一致+可容忍丢失的数据(计数、会话、中间态),绝不可用于交易/余额/库存这类数据。",
"source": null,
"related": []
},
{
"id": "sc-005",
"type": "single_choice",
"difficulty": 3,
"tags": [
"多级缓存",
"L1",
"L2"
],
"question": "经典的多级缓存结构中,本地进程内缓存(如 Caffeine)相比共享 Redis 缓存的优势是?",
"options": {
"A": "可以跨节点共享数据,重启后不丢失",
"B": "零网络开销,读取延迟最低,能扛住超高 QPS",
"C": "支持持久化和主从集群",
"D": "数据全自动与数据库保持一致"
},
"answer": "B",
"explanation": "L1 本地进程内缓存(Caffeine)读不走网络,延迟最低,适合热点 key 的超高 QPS;代价是单机副本、重启即失、跨节点不一致。A、C 是 Redis(L2 共享)的优势,D 在所有缓存策略下都不成立。",
"source": null,
"related": []
},
{
"id": "sc-006",
"type": "single_choice",
"difficulty": 4,
"tags": [
"多级缓存",
"一致性"
],
"question": "多级缓存架构中最需要付出代价、也是最难解决的是?",
"options": {
"A": "缓存容量不足",
"B": "各级缓存的命中率",
"C": "同一数据在多个缓存层/多个节点间的一致性",
"D": "本地缓存占用内存过大"
},
"answer": "C",
"explanation": "多级缓存里同一数据会同时存在于 L1 本地、L2 Redis、多个节点上,更新时任一副本残留旧值都会造成不一致。所以一致性是多级缓存最大的代价,需要通过广播失效、短 TTL、回调等机制缓解。",
"source": null,
"related": []
},
{
"id": "sc-007",
"type": "single_choice",
"difficulty": 4,
"tags": [
"Caffeine",
"TinyLFU",
"淘汰算法"
],
"question": "Caffeine 本地缓存的默认淘汰/近似最合适算法是 TinyLFU,它与 LRU 相比的主要改进是?",
"options": {
"A": "淘汰顺序完全随机,降低计算开销",
"B": "只按访问时间决定淘汰,实现更简单",
"C": "基于访问频率(并辅以最近访问时间/衰减),能保留短期突发但长期高频的 key,避免 LRU 被顺序扫描冲垮",
"D": "淘汰后立即删除数据库副本,保证一致性"
},
"answer": "C",
"explanation": "TinyLFU 通过维护计数器记录 key 的访问频率,并结合时间窗口衰减与最近访问,避免 LRU 被「批量顺序扫描」一次性清空高频 key,淘汰决策更聪明。A、B 描述错误,D 属其他机制。",
"source": null,
"related": []
},
{
"id": "sc-008",
"type": "single_choice",
"difficulty": 4,
"tags": [
"版本号回填",
"竞态"
],
"question": "Cache-Aside「先写库再删缓存」的竞态窗口,最恶劣的后果是?",
"options": {
"A": "某个读请求多一次数据库查询",
"B": "删缓存操作偶尔失败",
"C": "读线程在删缓存之后把库里的旧值回填进缓存,导致旧值长期残留缓存",
"D": "写请求直接丢失数据"
},
"answer": "C",
"explanation": "最严重的是「来源2」:读线程在写线程删缓存的时刻库仍是旧值,读操作回填,且可能发生在删除之后,于是旧值被写进缓存且无法用「删缓存」治愈。它会导致后续所有读请求都命中旧值,直到 TTL 到期或再次写。",
"source": null,
"related": []
},
{
"id": "sc-009",
"type": "single_choice",
"difficulty": 4,
"tags": [
"双删",
"版本号回填",
"竞态"
],
"question": "下列哪种方案可以从「在缓存中残留旧值」这一类竞态问题,适合强一致且不易辩的 key?",
"options": {
"A": "删除缓存前先 sleep 100ms",
"B": "写成功后连删两次缓存(双删)",
"C": "缓存中带版本号,回填前比较,旧版本直接丢弃",
"D": "写入缓存后就放弃数据库"
},
"answer": "C",
"explanation": "版本号回填会在回填前比较缓存中的版本号与要写入的一致性,旧版本直接丢弃,因此从根源上避免「旧值被回填」——这也是“版本号是最彻底的方案”的原因。双删只能提高第二次删除覆盖旧可能,不能保证绝对干净。",
"source": null,
"related": []
},
{
"id": "sc-010",
"type": "single_choice",
"difficulty": 4,
"tags": [
"Write-Behind",
"乱序",
"丢失"
],
"question": "Write-Behind(异步回写)存在多种风险,下列哪一项是 Write-Behind 独有、Cache-Aside 也会出现但策略不同?",
"options": {
"A": "缓存 miss 后需要回填",
"B": "异步落库可能存在「缓存宕机且落库前数据丢失」",
"C": "删缓存后读线程仍读库",
"D": "本地缓存无法节点间共享"
},
"answer": "B",
"explanation": "「先写缓存即返回成功、DB 后台异步刷」是 Write-Behind 的核心,因此若缓存进程在落库前宕机,这些更新就丢了。这是 Write-Behind 独有的风险,也再次印证它只适合可丢失的最终一致数据。",
"source": null,
"related": []
}
]
}