From 0015449dcfc0bb38347a1f7d7dfb66293cb426dc Mon Sep 17 00:00:00 2001 From: wonder Date: Sat, 5 Sep 2026 12:03:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20cache-system=20topic=20with=20105?= =?UTF-8?q?=20questions=20(=E7=A9=BF=E9=80=8F/=E5=87=BB=E7=A9=BF/=E9=9B=AA?= =?UTF-8?q?=E5=B4=A9,=20=E8=AF=BB=E5=86=99=E7=AD=96=E7=95=A5,=20=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E9=80=89=E5=9E=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache-architecture/code_reading.json | 275 ++++++++++++++++++ .../cache-architecture/fill_blank.json | 180 ++++++++++++ .../cache-system/cache-architecture/meta.json | 56 ++++ .../cache-architecture/short_answer.json | 235 +++++++++++++++ .../cache-architecture/single_choice.json | 204 +++++++++++++ .../code_reading.json | 185 ++++++++++++ .../cache-read-write-strategy/fill_blank.json | 191 ++++++++++++ .../cache-read-write-strategy/meta.json | 53 ++++ .../short_answer.json | 244 ++++++++++++++++ .../single_choice.json | 215 ++++++++++++++ .../cache-three-problems/code_reading.json | 260 +++++++++++++++++ .../cache-three-problems/fill_blank.json | 203 +++++++++++++ .../cache-three-problems/meta.json | 41 +++ .../cache-three-problems/short_answer.json | 238 +++++++++++++++ .../cache-three-problems/single_choice.json | 209 +++++++++++++ topics/index.json | 45 +++ 16 files changed, 2834 insertions(+) create mode 100644 topics/architecture/cache-system/cache-architecture/code_reading.json create mode 100644 topics/architecture/cache-system/cache-architecture/fill_blank.json create mode 100644 topics/architecture/cache-system/cache-architecture/meta.json create mode 100644 topics/architecture/cache-system/cache-architecture/short_answer.json create mode 100644 topics/architecture/cache-system/cache-architecture/single_choice.json create mode 100644 topics/architecture/cache-system/cache-read-write-strategy/code_reading.json create mode 100644 topics/architecture/cache-system/cache-read-write-strategy/fill_blank.json create mode 100644 topics/architecture/cache-system/cache-read-write-strategy/meta.json create mode 100644 topics/architecture/cache-system/cache-read-write-strategy/short_answer.json create mode 100644 topics/architecture/cache-system/cache-read-write-strategy/single_choice.json create mode 100644 topics/architecture/cache-system/cache-three-problems/code_reading.json create mode 100644 topics/architecture/cache-system/cache-three-problems/fill_blank.json create mode 100644 topics/architecture/cache-system/cache-three-problems/meta.json create mode 100644 topics/architecture/cache-system/cache-three-problems/short_answer.json create mode 100644 topics/architecture/cache-system/cache-three-problems/single_choice.json diff --git a/topics/architecture/cache-system/cache-architecture/code_reading.json b/topics/architecture/cache-system/cache-architecture/code_reading.json new file mode 100644 index 0000000..ea34f63 --- /dev/null +++ b/topics/architecture/cache-system/cache-architecture/code_reading.json @@ -0,0 +1,275 @@ +{ + "topic": "cache-architecture", + "type": "code_reading", + "schema_version": "1.0.0", + "generated": "2026-09-05T00:00:00+08:00", + "questions": [ + { + "id": "cr-001", + "type": "code_reading", + "difficulty": 2, + "tags": [ + "单机缓存", + "多进程共享", + "Redis取舍", + "Caffeine", + "组件选型" + ], + "question": "阅读以下函数,它根据运行环境信息判断该场景是否需要引入独立的 Redis 作为缓存层,回答子问题。", + "code": "func decide(topology Topology) string {\n if !topology.multiProcess {\n return \"L1(LocalMap/Caffeine)\" // 单进程缓存\n }\n if topology.needShared &&\n !topology.needExternalStore &&\n topology.capacityOK {\n return \"L1(LocalMap/Caffeine)\"\n }\n return \"Redis\"\n}\n\n// ①: single-process checker, 缓存可重启重查, 无 AOF/INCR 需求\nfmt.Println(decide(topology{})) // ①\n// ②: 一台机器跑 3 个进程, 各持本地副本会不一致\nfmt.Println(decide(topology{multiProcess: true})) // ②\n// ③: 单进程, 但业务依赖 INCR 原子计数与 AOF 持久化\nfmt.Println(decide(topology{multiProcess: false})) // ③", + "language": "go", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "调用①传入的空结构体 topology 中 multiProcess 默认 false,输出是什么?", + "options": { + "A": "Redis", + "B": "L1(LocalMap/Caffeine)", + "C": "DB", + "D": "编译错误,结构体不能直接传给函数" + }, + "answer": "B", + "explanation": "multiProcess 默认 false,直接命中第一个分支返回 L1(LocalMap/Caffeine)。单进程、缓存可重启重查、无特殊需求时不需要 Redis。" + }, + { + "index": 2, + "type": "single_choice", + "question": "调用② 传入 multiProcess=true 但 needSharedMemory=false,代码却已指向 Redis,其根本原因是?", + "options": { + "A": "缓存需要跨进程共享,各进程持本地副本必然不一致", + "B": "因为数据量大本地内存扛不住", + "C": "因为业务需要 AOF 持久化", + "D": "因为将来必然扩展为多机集群" + }, + "answer": "A", + "explanation": "一台机器跑多个进程,各进程持有本地缓存的各自副本,读到同一 key 的不同版本,必然不一致。此时本地 Caffeine/Map 不适用,需要共享缓存层(如 Redis)。核心误判是『把单机当成单进程』。" + }, + { + "index": 3, + "type": "short_answer", + "question": "调用③ 传入 multiProcess=false,但业务依赖 Redis 的 INCR 原子计数与 AOF 持久化,decide 会返回什么?说明理由。", + "answer": "Redis(命中最终兜底分支)。", + "explanation": "multiProcess=false 不进第一个分支;needSharedMemory 默认 false 使第二个分支的条件不成立,最终返回 Redis。虽是单进程,但需要 Redis 的原子计数/持久化能力,本地缓存提供不了,故仍需 Redis。印证『判断三问』:取决于是否被共享、是否依赖 Redis 特性、数据量是否本地扛得住,而非单机还是多机。" + } + ], + "explanation": "『单机』≠『单进程』。是否需要 Redis 取决于三问:①缓存是否需被多进程/多服务共享;②是否依赖 Redis 能力(AOF、分布式锁、INCR、ZSET、PubSub);③数据量本地内存是否扛不住。三者皆否则由本地 Caffeine/Map 充当足够;任一为是则需独立 Redis 作为共享/持久化层。", + "source": null, + "related": [] + }, + { + "id": "cr-002", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "Write-Behind", + "回写式缓存", + "AOF", + "幂等", + "异步批量落库", + "Redis持久化" + ], + "question": "以下伪代码实现 Write-Behind(回写式/懒更新)缓存:客户端统一只写缓存,由缓存层异步批量落库。回答子问题。", + "code": "type BatchWriter struct {\n db DB\n buf map[string]*Entry // key -> 待写条目\n backup *BackupLog // 兜底日志(确保崩溃不丢写)\n}\n\nfunc (w *BatchWriter) Write(key, value string, now int64) {\n e := &Entry{Key: key, Value: value, Version: now}\n w.pending[key] = e\n w.backup.Append(e) // 先记兜底日志\n}\n\nfunc (w *BatchWriter) flush() {\n batch := w.drainPending()\n for _, e := range batch {\n // 幂等: 已落库的行比该条目新则跳过, 防乱序覆盖\n if e.Version < GetRowVersion(e.Key) {\n continue\n }\n db.Upsert(e.Key, e.Value, e.Version)\n w.backup.Delete(e.Key) // 落库成功即清兜底\n }\n}\n\nfunc (w *BatchWriter) recover() {\n for _, e := range w.backup.DrainAll() { // 崩溃后调备份重放\n db.Upsert(e.Key, e.Value, e.Version)\n }\n}", + "language": "go", + "sub_questions": [ + { + "index": 1, + "type": "short_answer", + "question": "Write-Behind 中为何要引入 BackupLog 并在 flush 里对 version 做幂等判断?", + "answer": "BackupLog 保证缓存崩溃/重启前的未落库写不丢失;version 幂等判断防止乱序/旧写覆盖新写。", + "explanation": "Write-Behind 三大边界之一『不丢写』与『不乱序』:缓存没有 AOF+兜底日志时崩溃即丢写,所以兜底日志是必要成分;异步批量落库时序不确定,必须带时间戳/版本号,旧版本直接跳过,防止旧写覆盖新写。" + }, + { + "index": 2, + "type": "single_choice", + "question": "下列哪类业务因为最终一致与滞后可见性,绝不应该放进该 Write-Behind 链路?", + "options": { + "A": "高频可丢的用户浏览历史", + "B": "余额、库存、订单金额等强一致交易类数据", + "C": "可容忍几秒延迟的统计报表", + "D": "热点读多写少的商品详情" + }, + "answer": "B", + "explanation": "Write-Behind 是最终一致:落库前直查 DB 的外部系统读到滞后数据,一旦写丢失后果严重,交易/余额/库存绝不进此链路,应回 DB 同步写。" + }, + { + "index": 3, + "type": "single_choice", + "question": "若缓存层既未开 AOF 也没有 BackupLog,flush 前 power 断点会怎样?", + "options": { + "A": "不会丢,Redis 有默认持久化", + "B": "未落库写入随进程重启丢失,DB 丢失这笔写", + "C": "自动用 RDB 恢复到最新", + "D": "客户端感知延迟但数据仍在" + }, + "answer": "B", + "explanation": "未开 AOF 又无兜底日志时,进程重启无法恢复缓存中未落库的 key,这批写给 DB 就永久丢失了。这就是『把缓存当唯一真源会丢写』的经典反面教训。" + } + ], + "explanation": "Write-Behind(回写式缓存):客户端不碰 DB 统一走缓存,由缓存层异步批量落库。工程代价三条铁律:①不丢写——缓存必须 AOF + 兜底日志,绝不把缓存当唯一真源;②不乱序——带版本号/时间戳防旧写覆盖;③外部读旧值——只适配内部可容忍一致性的读,交易/存量/库存绝不进入。", + "source": null, + "related": [] + }, + { + "id": "cr-003", + "type": "code_reading", + "difficulty": 3, + "tags": [ + "多级缓存", + "写失效广播", + "本地缓存", + "TTL", + "版本号" + ], + "question": "以下 Go 代码是多级缓存(L1 本地 Caffeine + L2 Redis)下写入时通过失效广播维护一致性的示意。回答子问题。", + "code": "func Write(key string, val []byte, ver int64) {\n redis.Set(key, val, ver) // ① L2 更新并记录版本\n l1.InvalidateLocal(key) // ② 本进程 L1 失效\n mq.Publish(\"cache.invalid\", key) // ③ 广播给所有进程\n}\n\nfunc OnInvalid(key string) {\n l1.InvalidateLocal(key) // 收到广播的进程清本地副本\n}\n\nfunc Read(key string) []byte {\n if v, ok := l1.Get(key); ok {\n // 本地版本足够新则直接返回\n if v.ver >= Version() { return v.val }\n return l1.Put(key, redis.Get(key)) // 落后则回落 L2\n }\n return redis.Get(key)\n}", + "language": "go", + "sub_questions": [ + { + "index": 1, + "type": "short_answer", + "question": "Write() 中①②③三步的目的分别是什么?顺序能否随意调换?", + "answer": "①写 L2 让共享真值源更新;②失效本进程 L1 不读旧;③广播通知其它进程失效各自 L1 副本。顺序不可随意调换,广播必须等 L2 收完后发出,否则存在让其它进程读到旧值的窗口。", + "explanation": "多级缓存副本不一致是核心难点。①②③保证本进程读到最新、其他进程也会被通知清除旧本地值。若先广播后写 L2,会有窗口其他进程读到 L2 旧疑似。顺序有意义。" + }, + { + "index": 2, + "type": "single_choice", + "question": "Read() 中 L1 命中后比对版本号目的是什么?", + "options": { + "A": "让 L1 永远返回本地值", + "B": "通过版本号判定本地副本是否过期,避免读到旧值", + "C": "让 L2 不再需要", + "D": "只是多花一次 Redis 请求" + }, + "answer": "B", + "explanation": "即使有失效广播,仍有广播丢失/竞态可能留下 L1 旧副本,读时再用版本比对:本地版本满足最新则直接用,落后则回落 L2,兜底判定过期。" + }, + { + "index": 3, + "type": "single_choice", + "question": "广播消息偶发丢失时,哪种本地策略能额外兜底防止长期读到旧值?", + "options": { + "A": "增本地缓存短 TTL 并配合逻辑过期", + "B": "把 TTL 设成无限大", + "C": "不做任何处理", + "D": "只用版本号即可完全防丢失" + }, + "answer": "A", + "explanation": "主动广播不是绝对可靠(MQ 可能丢消息),所以我还要本地缓存设短 TTL + 逻辑过期兜底:广播丢失时因 TTL 到期强制回源,避免长期读到旧值。二者叠加使用双保险。" + } + ], + "explanation": "多级缓存一致性三道防线:①写时主动失效+广播;②本地短 TTL+逻辑过期到期强制回源;③版本号/时间戳判定过期兜底读取。三者配合可把 L1 与 L2 不一致窗口压到最低。", + "source": null, + "related": [] + }, + { + "id": "cr-004", + "type": "code_reading", + "difficulty": 2, + "tags": [ + "RDB", + "AOF", + "Redis持久化", + "配置选择", + "高可用" + ], + "question": "以下是 Redis 持久化配置片段,结合读多写少、可接受重启丢几秒写的场景,回答子问题。", + "code": "# redis.conf 关键配置\n# ---- RDB 快照条件 ----\nsave 900 1 # 900 秒内 >=1 次写则快照\nsave 300 10\nsave 60 10000\n\n# ---- AOF 只改动日志 ----\nappendonly yes\nappendfsync everysec\n\naof-use-rdb-preamble no", + "language": "redis", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "读多写少、可接受重启丢最近几秒写入的场景,推荐哪组持久化?", + "options": { + "A": "只开 RDB 并把 appendonly 设为 no", + "B": "关闭持久化", + "C": "开 AOF 且 appendfsync everysec 再辅以周期 RDB", + "D": "开 AOF 且 appendfsync always" + }, + "answer": "C", + "explanation": "读多写少、可容忍秒级丢失、希望尽量可恢复,推荐『AOF(everysec)+周期 RDB』:每个周期最多丢约 1 秒写,RDB 加速冷启动。appendalways 接近不丢但性能损耗大,强语义场景才用。" + }, + { + "index": 2, + "type": "short_answer", + "question": "本配置开启 appendfsync everysec 且保留 save 300 10,此刻断电最坏丢多少写?为什么?", + "answer": "最多约 1 秒内的写入。", + "explanation": "appendfsync everysec 表示每秒刷盘一次,断电时最多丢失最后一秒 buffer 内的写。RDB 的 save 规则只影响快照频率与恢复速度,不决定断电丢多少。" + }, + { + "index": 3, + "type": "single_choice", + "question": "同时开启 AOF 与 RDB 时,Redis 加载启动为何优先用 AOF 而非 RDB?", + "options": { + "A": "AOF 文件读取速度更快", + "B": "AOF 逐条记录写命令,能恢复 RDB 快照点之后的写,数据最全", + "C": "RDB 是二进制不可读", + "D": "开 appendonly 会自动禁用 RDB" + }, + "answer": "B", + "explanation": "RDB 只是某时间点的快照,快照之后到 AOF 之间的写不会进入;AOF 逐条记录写命令,只要开启就是恢复全量,所以优先用 AOF。混合头可加速载入,正文仍以 AOF 为准。" + } + ], + "explanation": "持久化选型:写不多、可容忍秒级丢失→AOF(everysec)+周期 RDB;几乎不可丢→allways 换性能;极便宜最快恢复→只 RDB。重启以 AOF 优先重放最新命令。", + "source": null, + "related": [] + }, + { + "id": "cr-005", + "type": "code_reading", + "difficulty": 5, + "tags": [ + "多级缓存", + "降级", + "熔断", + "缓存雪崩", + "过期随机抖动", + "高可用" + ], + "question": "以下 Go 伪代码实现了一个带降级与熔断的多级缓存读取函数,阅读代码找出其核心缺陷并回答子问题。", + "code": "func Get(key string) ([]byte, error) {\n if breaker.IsOpen(key) {\n return nil, errCircuitOpen // 熔断直接返回\n }\n if v, ok := l1.Get(key); ok { return v }\n if v, ok := l2.Get(key); ok { l1.Put(key, v); return v }\n v, err := db.Load(key) // L1/L2 都未命中回源 DB\n if err != nil { return nil, err }\n ttl := 3600 // 固定 TTL, 无抖动\n l2.Set(key, v, ttl)\n l1.Set(key, v, ttl)\n return v, nil\n}", + "language": "go", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "代码用固定 ttl=3600 且无互斥/逻辑过期,当热点 key 恰好同时过期且大量请求涌入会发生什么?", + "options": { + "A": "所有请求一起穿透到 DB(缓存雪崩击穿),DB 可能被打垮", + "B": "没有影响,L1 已把流量挡住", + "C": "Redis 会自动去重请求", + "D": "熔断会立刻返回降级结果" + }, + "answer": "A", + "explanation": "固定 TTL 使所有 key 在同一时刻统一过期,热点 key 过期瞬间大量并发一并穿透到 DB,这就是典型雪崩/击穿。正确做法是热点 key 加互斥(singleflight)或逻辑过期,且 TTL 加随机抖动分散过期。" + }, + { + "index": 2, + "type": "single_choice", + "question": "关于熔断分支 return nil, errCircuitOpen,下列哪项判断正确?", + "options": { + "A": "熔断后不返回任何降级数据,只返回错误,属于不够优雅的降级", + "B": "熔断逻辑完全没有用", + "C": "熔断永远不会发生因为 DB 不失败", + "D": "已经是完整可靠的降级方案" + }, + "answer": "A", + "explanation": "合理降级应返回可用的兜底(如 stale 旧缓存值或本地默认值)并告警,而不是直接抛错让上层蒙 撞。此处熔断返回 nil+err,是降级不彻底的表现。" + }, + { + "index": 3, + "type": "short_answer", + "question": "针对固定 TTL 雪崩、热点key击穿、熔断无降级数据三个问题,分别给出改进要点。", + "answer": "① TTL 加随机抖动(如 3600+rand(0,300)) 防固定时间集体过期;② 热点 key 加互斥锁/singleflight 或逻辑过期,避免并发重复回源;③ 熔断时返回本地旧缓存 stale 值或默认值并告警 + 逐级恢复,保障降级可用。", + "explanation": "本题三处反例对应三套治理:固定 TTL→TTL 随机抖动/逻辑过期;无互斥→击穿加单飞;裸熔断→降级兜底值+告警。综合保护 DB 与缓存,避免整体雪崩。" + } + ], + "explanation": "多级缓存可靠性设计:防雪崩(随机 TTL 抖动)、防击穿(互斥锁/singleflight/逻辑过期)、降级(本地兜底值+熔断计数+告警)。本题集中在三个反例对应的修复方案。", + "source": null, + "related": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-architecture/fill_blank.json b/topics/architecture/cache-system/cache-architecture/fill_blank.json new file mode 100644 index 0000000..a00e337 --- /dev/null +++ b/topics/architecture/cache-system/cache-architecture/fill_blank.json @@ -0,0 +1,180 @@ +{ + "topic": "cache-architecture", + "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": [ + "单机缓存" + ], + "question": "判断单机应用是否需要引入 Redis 的核心依据不是'是否单机',而是缓存是否需要被______共享。", + "answer": [ + "多进程/多个服务" + ], + "answer_rule": "any", + "explanation": "决定要不要 Redis 的核心判断是'缓存是否需要被多进程/多能力共享'。单进程应用可用本地缓存;一旦多进程共享缓存,就必须引入 Redis 以保证一致性。常见误判是把'单机'当'单进程'。", + "source": null, + "related": [] + }, + { + "id": "fb-002", + "type": "fill_blank", + "difficulty": 2, + "tags": [ + "多进程共享" + ], + "question": "一台机器上多个进程需要共享缓存时,若各进程持本地副本会彼此不一致,此时应引入______作为独立的共享缓存层。", + "answer": [ + "Redis" + ], + "answer_rule": "any", + "explanation": "多个业务进程同时访问缓存时,进程内本地缓存各持副本必然不一致,应引入 Redis 这类独立进程的缓存服务作为共享层,保证所有进程看到同一份数据。", + "source": null, + "related": [] + }, + { + "id": "fb-003", + "type": "fill_blank", + "difficulty": 2, + "tags": [ + "Redis持久化", + "AOF" + ], + "question": "在进程重启后缓存不能丢失,且依赖原子自增(INCR)能力的场景下,应选择的缓存组件是______,并结合______持久化保证重启不丢。", + "answer": [ + "Redis", + "AOF" + ], + "answer_rule": "ordered", + "explanation": "需要跨进程共享、进程重启不丢且依赖原子计数时,应选 Redis。Redis 通过 AOF(追加日志)或 RDB(快照)持久化;其中 AOF 基于操作日志、重放后数据恢复更完整,符合'重启不丢'的诉求。", + "source": null, + "related": [] + }, + { + "id": "fb-004", + "type": "fill_blank", + "difficulty": 3, + "tags": [ + "Write-Behind", + "回写式缓存" + ], + "question": "客户端不直接访问 DB,统一读写缓存层,由缓存层异步批量写回 DB,这种架构被称为 Read-Through + ______(回写式缓存 / 懒更新缓存)。", + "answer": [ + "Write-Behind" + ], + "answer_rule": "any", + "explanation": "Write-Behind 也叫回写式缓存或懒更新缓存:写操作只更新缓存,由缓存层异步批量同步到 DB。它带来了异步落库、可批量合并写的优势,但也有丢写风险和外部读到旧值的代价。", + "source": null, + "related": [] + }, + { + "id": "fb-005", + "type": "fill_blank", + "difficulty": 3, + "tags": [ + "Write-Behind" + ], + "question": "Write-Behind 架构下为保证不丢写,缓存必须开启______持久化,并配套兜底日志/队列;为保证批量落库不乱序,需为数据附带______或时间戳。", + "answer": [ + "AOF", + "版本号" + ], + "answer_rule": "ordered", + "explanation": "Write-Behind 丢写风险高,缓存必须持久化(AOF)并有兜底日志/队列;批量落库时要带版本号或时间戳,防止旧数据覆盖新数据造成乱序。绝不能把缓存当作唯一真相源。", + "source": null, + "related": [] + }, + { + "id": "fb-006", + "type": "fill_blank", + "difficulty": 2, + "tags": [ + "多级缓存" + ], + "question": "多级缓存(L1 本地 + L2 Redis)副本不一致的常见解法之一是:本地缓存设短 TTL 结合______过期做兜底,或写时通过 MQ ______通知各节点清本地缓存。", + "answer": [ + "逻辑", + "广播" + ], + "answer_rule": "ordered", + "explanation": "L1 本地与 L2 Redis 副本不一致是核心难点。缓解手段包括:本地短 TTL + 逻辑过期兜底;主动失效 + MQ 广播通知所有节点清本地缓存;版本号/时间戳判定过期。", + "source": null, + "related": [] + }, + { + "id": "fb-007", + "type": "fill_blank", + "difficulty": 2, + "tags": [ + "Caffeine", + "TinyLFU" + ], + "question": "Caffeine 是进程内 JVM 内存缓存,采用______淘汰策略,单次访问纳秒级、无网络开销,适合做 L1 ______热点缓存。", + "answer": [ + "TinyLFU", + "极热/高" + ], + "answer_rule": "ordered", + "explanation": "Caffeine 使用 TinyLFU 淘汰策略,能更精确地识别高频热数据;作为进程内内存虽然纳秒级访问,但重启即失、无法跨进程共享,因此适合做 L1 极热/高访问层,与 Redis(L2 共享)搭配成多级缓存。", + "source": null, + "related": [] + }, + { + "id": "fb-008", + "type": "fill_blank", + "difficulty": 3, + "tags": [ + "限流", + "熔断" + ], + "question": "在缓存降级设计中,当 Redis 抖动不可用时,为防止流量全部回源打爆 DB,应引入本地缓存兜底、配合______、______等保护手段。", + "answer": [ + "限流", + "熔断" + ], + "answer_rule": "ordered", + "explanation": "Redis 不可用时应降级而不是无限回源。常用手段包括:本地缓存兜底、限流(限制回源速率)、熔断(快速失败保护后端)、以及返回降级数据。共同目标都是保护 DB 和后端服务免于过载。", + "source": null, + "related": [] + }, + { + "id": "fb-009", + "type": "fill_blank", + "difficulty": 4, + "tags": [ + "哨兵", + "Redis Cluster" + ], + "question": "Redis 高可用的两种组成方式:由______负责主从复制的故障检测与自动切换,由______负责数据分片以实现横向扩容。", + "answer": [ + "哨兵/Sentinel", + "Redis Cluster" + ], + "answer_rule": "ordered", + "explanation": "哨兵(Sentinel)部署在主从复制之上,监控、故障检测并自动提升换来主从切换,保障高可用;Redis Cluster 按 slot 分片,把数据分发到不同分片实现水平扩展。两者职责互补。", + "source": null, + "related": [] + }, + { + "id": "fb-010", + "type": "fill_blank", + "difficulty": 4, + "tags": [ + "RDB" + ], + "question": "Redis 提供两种主流持久化方式:______以周期性快照落盘,______则记录每一次写命令,二者可以同时开启。", + "answer": [ + "RDB", + "AOF" + ], + "answer_rule": "ordered", + "explanation": "RDB(快照)按周期把内存数据整份序列化写入磁盘,恢复快但可能丢失最后一次快照后的数据;AOF 记录每次写命令的追加日志,可重放到最近状态,数据更安全。两者不相斥,生产上可同时开启。", + "source": null, + "related": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-architecture/meta.json b/topics/architecture/cache-system/cache-architecture/meta.json new file mode 100644 index 0000000..b4f2407 --- /dev/null +++ b/topics/architecture/cache-system/cache-architecture/meta.json @@ -0,0 +1,56 @@ +{ + "slug": "cache-architecture", + "name": "缓存架构与组件选型", + "description": "单机是否需 Redis、客户端只读写缓存(Write-Behind)、多级缓存一致性、组件选型与降级高可用", + "tags": [ + "AOF", + "Caffeine", + "RDB", + "Redis Cluster", + "Redis取舍", + "Redis持久化", + "TTL", + "TinyLFU", + "Write-Behind", + "写失效广播", + "单机缓存", + "哨兵", + "回写式缓存", + "多级缓存", + "多进程共享", + "幂等", + "异步批量落库", + "本地缓存", + "熔断", + "版本号", + "组件选型", + "缓存降级", + "缓存雪崩", + "过期随机抖动", + "配置选择", + "降级", + "限流", + "高可用" + ], + "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 + } + } +} diff --git a/topics/architecture/cache-system/cache-architecture/short_answer.json b/topics/architecture/cache-system/cache-architecture/short_answer.json new file mode 100644 index 0000000..2f33569 --- /dev/null +++ b/topics/architecture/cache-system/cache-architecture/short_answer.json @@ -0,0 +1,235 @@ +{ + "topic": "cache-architecture", + "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": [ + "单机缓存" + ], + "question": "简述判断单机环境是否需要引入 Redis 的'判断三问',并说明每问回答'是'意味着什么。", + "answer": "判断三问:①缓存是否需要被多进程/多服务共享?②是否依赖 Redis 某项能力(AOF 持久化、分布式锁、原子计数 INCR、榜单 ZSET、PubSub 等)?③数据量是否大得本地进程内内存扛不住、或将来必然扩展为多机集群?只要任一回答'是',就应当引入 Redis;只有当三问全部为'否'时,进程内本地缓存(如 Caffeine)才足够。", + "keywords": [ + "多进程共享", + "Redis能力", + "数据量", + "本地缓存", + "Caffeine" + ], + "scoring_rubric": "答出三问各得 20 分(共 60 分);正确说明三问全否才选本地缓存得 20 分;能指出'单机≠单进程'这一易错点得 20 分。", + "explanation": "决定是否用 Redis 的核心是缓存是否需要被多进程/多能力共享,而不是机器是否单机。三问中任一命中就说明需要 Redis 的跨进程共享或特性能力;全否则本地缓存足够,加 Redis 反而引入网络与一致性麻烦。", + "source": null, + "related": [] + }, + { + "id": "sa-002", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "Write-Behind", + "回写式缓存" + ], + "question": "请说明 Write-Behind(回写式/懒更新缓存)架构的运作方式,以及使用它需要注意哪几类代价或边界。", + "answer": "运作方式:客户端完全不直连 DB,统一读写缓存层;写操作只更新缓存并返回,由缓存层异步批量把写入同步回 DB(Read-Through + Write-Behind)。注意事项:①不丢写——缓存必须开启持久化(AOF)并配套兜底日志/队列,绝不可把缓存当唯一真源;②不乱序——批量落库需带时间戳/版本号防止旧覆盖新;③外部读到旧值——直查 DB 的外部/报表系统会看到滞后数据,因此只适合内部读、可容忍一致的数据,而交易/余额/库存等强一致数据绝不可进入该链路。", + "keywords": [ + "异步落库", + "持久化", + "AOF", + "版本号", + "旧值", + "强一致" + ], + "scoring_rubric": "正确描述异步落库机制得 30 分;说出缓存需持久化兜底不丢写得 25 分;说出版本号/时间戳防乱序得 20 分;指出外部读旧值风险并排除强一致数据得 25 分。", + "explanation": "Write-Behind 的优势是写异步化、可批量合并、降低 DB 写压力;代价是丢写风险、乱序风险和读滞后。本题考察对整个架构边界(可靠性、一致性、适用数据)的把握,是必考点。", + "source": null, + "related": [] + }, + { + "id": "sa-003", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "多级缓存" + ], + "question": "多级缓存(L1 本地 + L2 Redis)副本不一致是核心难点,请给出至少三种缓解不一致的治理手段。", + "answer": "①本地缓存设置短 TTL + 逻辑过期兜底——即使没收到失效通知,本地数据也会在较短时间内自然过期回源,缩小不一致窗口;②主动失效 + 广播——写入时通过 MQ 发送失效事件,通知所有节点清除本地缓存副本;③版本号/时间戳判定过期——缓存项携带版本号或写入时间戳,读时对比 L2 判定本地副本是否已过期。", + "keywords": [ + "短TTL", + "逻辑过期", + "广播", + "MQ", + "版本号", + "失效" + ], + "scoring_rubric": "每种治理手段计约 33 分(共约 100 分)。答出短 TTL/逻辑过期得 33 分;答出 MQ 广播主动失效得 33 分;答出版本号/时间戳判过期得 34 分。", + "explanation": "多级缓存的根本矛盾是同一数据多个副本。思路分两条线:缩短过期窗口(短 TTL + 逻辑过期),和主动同步失效(广播 + 版本判定)。三者属于缓存一致性的经典治理手段。", + "source": null, + "related": [] + }, + { + "id": "sa-004", + "type": "short_answer", + "difficulty": 2, + "tags": [ + "Caffeine", + "TinyLFU", + "多级缓存" + ], + "question": "对比 Caffeine 与 Redis 两种缓存组件,说明各自适用场景,在多级缓存中各自适合承担哪一层。", + "answer": "Caffeine 是进程内 JVM 内存、TinyLFU 淘汰、纳秒级无网络单机访问,适合做 L1 极热/高访问层;但重启即失、无法跨进程共享。Redis 是独立进程、可跨机器,毫秒网络 I/O、支持持久化、分布式锁/原子计数/榜单/PubSub,适合做 L2 共享与兜底层。多级缓存中 Caffeine 放 L1 承接极端热点、Redis 放 L2 跨进程共享,两者组合兼顾极热性能与一致性。", + "keywords": [ + "Caffeine", + "TinyLFU", + "L1", + "Redis", + "L2", + "多级缓存" + ], + "scoring_rubric": "Caffeine 特点与 L1 定位各得 20 分;Redis 特点与 L2 定位各得 20 分;正确说明二者组合成多级缓存得 20 分。", + "explanation": "两者的本质差异是'进程内内存' vs '独立共享服务'。多级缓存正是发挥二者长处:L1 用 Caffeine 承接极热访问减网络开销,L2 用 Redis 保证跨进程一致与可靠性。", + "source": null, + "related": [] + }, + { + "id": "sa-005", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "缓存降级", + "限流", + "熔断" + ], + "question": "在缓存架构的可靠性设计中,请说明限流、熔断、降级三种手段各自的职责,以及它们如何共同保护 DB 和后端服务。", + "answer": "限流:限制单位时间回源/请求的数量,防止突发流量瞬间打满 DB 连接或线程池;熔断:当后端(DB/缓存)连续失败达到阈值时快速失败而非继续请求,让其有喘息恢复时间;降级:当 Redis 等组件不可用时,返回兜底数据(如本地缓存副本、默认值、静态页)而不是无限回源。三者配合:Redis 抖动时先本地兜底降级 + 限流控制回源 + 熔断保护 DB,避免全量打爆后端。", + "keywords": [ + "限流", + "熔断", + "降级", + "兜底", + "保护DB" + ], + "scoring_rubric": "正确说明限流职责得 25 分;熔断职责得 25 分;降级兜底职责得 30 分;能整合说明三者协作保护 DB 得 20 分。", + "explanation": "可靠性设计不止是缓存本身高可用,更重要的是对下游的保护。限流控制流量、熔断快速失败、降级提供兜底,三者共同避免缓存故障演变为 DB 过载故障。", + "source": null, + "related": [] + }, + { + "id": "sa-006", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "Redis持久化", + "RDB", + "AOF" + ], + "question": "请对比 Redis 的 RDB 快照与 AOF 日志两种持久化方式,说明各自在数据安全性与恢复方式上的特点,并给出选型建议。", + "answer": "RDB 按周期把内存数据整份快照序列化落盘,文件紧凑、恢复快,但两次快照之间的写入可能丢失,安全性较弱。AOF 记录每次写命令的追加日志,可重放到最近状态,数据更安全,但文件较大、在重写(bgrewriteaof)影响恢复速度/性能。选型:若对安全性要求高(如缓存作为准真源、写后异步落库)选 AOF 或两者同时开启、仅接受少量丢失选 RDB;一般高要求生产推荐同时开启 AOF 以保数据。", + "keywords": [ + "RDB", + "AOF", + "快照", + "重放", + "持久化", + "选型" + ], + "scoring_rubric": "正确说明 RDB 快照特点与丢失窗口得 30 分;正确说明 AOF 追加日志与重放得 30 分;给出符合场景的选型建议得 40 分。", + "explanation": "RDB 是周期性快照(紧凑、恢复快、可能丢最后窗口数据),AOF 是命令日志(数据更安全、可重放)。持久化选型属于缓存组件选型的核心点,尤其结合 Write-Behind 的落库需求更强调 AOF。", + "source": null, + "related": [] + }, + { + "id": "sa-007", + "type": "short_answer", + "difficulty": 2, + "tags": [ + "单机缓存", + "多进程共享" + ], + "question": "为什么说'单机'不等于'单进程',并基于此说明一台单机部署在手机多进程时为何仍需 Redis。", + "answer": "'单机'描述的是部署物理维度(一台机器),而'单进程'描述的是运行进程维度(应用进程个数)。决定是否需要 Redis 的是缓存是否被多进程/多能力共享:即使在同一台机器上运行多个业务进程,每个进程持本地副本会各自维护一份缓存而产生数据不一致;此时仍需要引入 Redis 作为跨进程的共享缓存层,保证所有进程读到同一份数据。因此'单机用本地缓存即可'的常见误判,本质是把'单机'误当成了'单进程'。", + "keywords": [ + "单机", + "单进程", + "多进程", + "共享", + "不一致" + ], + "scoring_rubric": "正确解释单机与单进程的维度区别得 40 分;指出多进程各自持副本不一致得 30 分;正确结论是仍需 Redis 做共享层得 30 分。", + "explanation": "该题是缓存选型中最常见的误判点。判断依据是'缓存是否需要被共享',与机器台数无关。只要多进程共享就需 Redis。", + "source": null, + "related": [] + }, + { + "id": "sa-008", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "哨兵", + "Redis Cluster" + ], + "question": "区分哨兵(Sentinel)与 Redis Cluster 在体系结构中的职责,并说明二者是否可以同时使用及其各自解决的问题。", + "answer": "哨兵负责高可用:部署在主从复制之上,监控主从状态,在主库故障时自动发起投票并提升上手从库为主库,实现自动接管,不负责数据分片;Redis Cluster 负责横向扩展:按 hash slot(16384 个槽)把键分布到多个分片节点,支持大容量与更高吞吐,具有分片能力。两者职责不同可同时使用——在 Cluster 之上叠加基于哨兵的监控/运维切换,或对不同的独立 Redis 实例分别用哨兵保障。哨兵解决'主库挂了怎么办',Cluster 解决'单实例容量/吞吐不够怎么扩展'。", + "keywords": [ + "哨兵", + "Sentinel", + "集群", + "Cluster", + "分片", + "自动切换" + ], + "scoring_rubric": "正确说明哨兵负责故障检测与自动切换得 35 分;正确说明 Cluster 负责分片/横向扩容得 35 分;说明两个职责互补、可同时使用得 30 分。", + "explanation": "这是选型中易混淆的一对:哨兵是'高可用'组件(故障切换),Cluster 是'分片扩展'组件(横向扩容)。职责不同,可搭配使用。", + "source": null, + "related": [] + }, + { + "id": "sa-009", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "Write-Behind", + "回写式缓存" + ], + "question": "Write-Behind 架构中,为什么'绝不能把缓存当作唯一真源'?请从一两个典型故障说明后果与应采取的兜底措施。", + "answer": "因为 Write-Behind 下数据先写缓存、异步落库,若缓存作为唯一真源而该缓存未持久化或崩溃,则尚未同步到 DB 的写入会直接丢失;同时如果只有一套副本,一旦重启清空则无法恢复任何待落库数据。典型故障:缓存进程崩溃且未开 AOF,队列中积压的上万笔写入全部丢失;或落库逻辑持续失败导致缓存与 DB 长期分歧。兜底措施:缓存开启持久化(AOF)+ 独立的可靠的落库日志/消息队列兜底,保证每比写入都被可靠记录并可重放;配合版本号防乱序、System对账重试。", + "keywords": [ + "丢写", + "崩溃", + "持久化", + "AOF", + "消息队列", + "兜底" + ], + "scoring_rubric": "指出缓存作为唯一真源会有崩溃丢写风险得 30 分;给出具体的故障场景(崩溃/清空/重放)得 30 分;提出持久化 + 队列兜底/对账措施得 40 分。", + "explanation": "Write-Behind 的可靠性核心是不把缓存当唯一真相源:必须要有持久化 + 兜底队列,把写入先可靠记下来再异步同步,避免缓存崩溃丢真源。", + "source": null, + "related": [] + }, + { + "id": "sa-010", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "多级缓存", + "Caffeine" + ], + "question": "设计一个多级缓存的一致性读写如何做具体过程:热点数据写入时,如何保证 L1 本地、L2 Redis 与 DB 三方的新旧一致?", + "answer": "可采用的完整流程:写时先更新 DB,写成功后向 L2 Redis 写新值并发出失效事件/版本号;通过 MQ 把版本号/失效通知直接发给所有节点的 L1 本地缓存,各节点收到后删除对应本地 key(主动失效);本地 key 同时设置短 TTL + 逻辑过期作为兜底,即使广播丢失也会在短 TTL / 逻辑过期时触发校验回源,读时携带版本号判断过期;对外暴露时返回已确认一致的值。排列组合:更新 DB → 更新或失效 Redis → 广播失效本地 → 本地短 TTL 逻辑过期兜底 + 版本号判定,最终保证 DB 为真源、L2 与 L1 为可作废副本,缩短不一致窗口。", + "keywords": [ + "RIO广播", + "失效", + "短TTL", + "版本号", + "DB真源" + ], + "scoring_rubric": "答出 DB 为真源、写库先行得 20 分;答出 L2 更新/失效再加版本号得 20 分;答出 MQ 广播失效各节点 L1 得 25 分;答出短 TTL + 逻辑过期兜底得 15 分;答出版本号校验过期得 20 分。", + "explanation": "多级缓存一致性的核心是“DB 真源 + 主动失效 + 过期兜底”。题目考察对读写路径和一致性窗口最小化的全套设计理解,属于本子主题较难的综合题。", + "source": null, + "related": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-architecture/single_choice.json b/topics/architecture/cache-system/cache-architecture/single_choice.json new file mode 100644 index 0000000..8118e75 --- /dev/null +++ b/topics/architecture/cache-system/cache-architecture/single_choice.json @@ -0,0 +1,204 @@ +{ + "topic": "cache-architecture", + "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": [ + "单机缓存" + ], + "question": "一台机器上运行着多个独立的业务进程,各进程需要共享同一份热点数据缓存。此时关于是否需要引入 Redis,下列说法正确的是?", + "options": { + "A": "因为部署在一台机器上,用进程内 Caffeine 即可,无需 Redis", + "B": "只要数据能被重启后重新加载,就绝不该用 Redis", + "C": "缓存需要跨进程共享,进程内本地缓存会各自持有副本导致不一致,应引入 Redis", + "D": "单机场景下 Redis 会因为网络开销绝对劣于本地缓存" + }, + "answer": "C", + "explanation": "决定是否用 Redis 的关键不是'单机'而是'缓存是否需要被多进程/多能力共享'。多进程部署时每个进程各持一份本地缓存的副本会彼此不一致,因此需要引入 Redis 作为共享缓存层。单机不等于单进程,A 把'单机'误当'单进程'。B 忽略了跨进程共享的需求。D 中 Redis 在网络开销上高于本地缓存,但在跨进程共享场景下是必要的,不能说'绝对劣于'。", + "source": null, + "related": [] + }, + { + "id": "sc-002", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "单机缓存" + ], + "question": "一个单进程应用,缓存数据可以随时重启后从 DB 重新加载,也不依赖任何 Redis 特性。下列最合理的做法是?", + "options": { + "A": "接入 Redis,保证缓存能被其他进程共享", + "B": "直接使用进程内本地缓存(如 Caffeine),不加 Redis", + "C": "必须同时使用本地缓存和 Redis 两层缓存", + "D": "放弃缓存,所有请求直连 DB" + }, + "answer": "B", + "explanation": "判断三问:①缓存是否被多进程共享?否;②依赖 Redis 某项能力(可持久化、分布式锁、原子计数等)?否;③数据量本地扛不住?否。三条都'否',说明本地缓存足够,加 Redis 反而引入一层网络与一致性麻烦,属于多余。因此直接用进程内本地缓存即可。", + "source": null, + "related": [] + }, + { + "id": "sc-003", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "多进程共享", + "Redis持久化" + ], + "question": "某系统需要:缓存跨多个进程共享、进程重启后缓存不丢失、支持分布式锁与原子自增。针对该需求,下列说法正确的是?", + "options": { + "A": "用进程内 Caffeine 即可满足全部需求", + "B": "必须引入 Redis,因为它支持共享、AOF 持久化、分布式锁和原子计数", + "C": "只要本地缓存设置足够大的容量就能解决", + "D": "跨进程共享和持久化互斥,无法同时满足" + }, + "answer": "B", + "explanation": "该需求命中判断三问中的①②:需要跨进程共享、且依赖 Redis 特性。Redis 独立于进程、支持 AOF/RDB 持久化,原生支持分布式锁(SETNX/Redlock)、原子计数(INCR)、榜单(ZSET)、PubSub,因此必须引入 Redis。Caffeine 是进程内内存,无法跨进程共享、无法在进程崩溃后保留数据,故 A、C 错误。共享与持久化并不互斥,D 错误。", + "source": null, + "related": [] + }, + { + "id": "sc-004", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "多级缓存" + ], + "question": "在多级缓存架构中,客户端完全不直接访问 DB,统一通过缓存层读写,并由缓存层异步地把写入批量同步回 DB。这种架构被称为?", + "options": { + "A": "Cache-Aside(旁路缓存)", + "B": "Read-Through + Write-Behind(回写式缓存)", + "C": "Cache-Aside + 同步写库", + "D": "直写缓存(Write-Through)" + }, + "answer": "B", + "explanation": "Read-Through 指程序只读缓存,缓存缺失时由缓存层负责回源加载;Write-Behind(回写式/懒更新缓存)指写操作只写入缓存,由缓存层异步批量写回 DB。两者结合即为'客户端只读写缓存+异步落库'的架构。Write-Through 是同步写入库,与题目'异步批量'不符。", + "source": null, + "related": [] + }, + { + "id": "sc-005", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "Write-Behind", + "回写式缓存" + ], + "question": "采用 Write-Behind 回写式缓存架构时,最终哪类数据绝不应进入该链路?", + "options": { + "A": "可容忍短暂滞后的报表统计数据", + "B": "仅供内部读、允许弱一致的数据", + "C": "交易、余额、库存等强一致且不可丢失的业务数据", + "D": "热点读多写少的页面配置数据" + }, + "answer": "C", + "explanation": "Write-Behind 存在两个天然代价:外部直查 DB 的系统会读到滞后旧值,以及若缓存崩溃且未持久化可能丢写。因此该架构只适合内部读、可容忍一致性的数据;而交易、余额、库存等强一致、不可丢失的核心业务数据绝不能进入此链路,必须走强一致方案。", + "source": null, + "related": [] + }, + { + "id": "sc-006", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "多级缓存" + ], + "question": "针对多级缓存(L1 本地 + L2 Redis)副本不一致的问题,下列哪项方案不能从根本上缓解不一致?", + "options": { + "A": "本地缓存设置较短 TTL 并配合逻辑过期兜底", + "B": "写入时通过 MQ 广播失效事件,通知所有节点清除本地缓存", + "C": "为缓存条目附带版本号/时间戳,读时判定过期", + "D": "把本地缓存 TTL 无限拉长以减少回源次数" + }, + "answer": "D", + "explanation": "加剧不一致的做法是把本地 TTL 无限拉长——本地副本长时间不过期,L2 已更新时本地仍返回旧值,反而放大不一致。正确治理是:短 TTL+逻辑过期兜底、主动失效并广播、版本号/时间戳判定过期。A、B、C 都是缓解一致性的正确手段。", + "source": null, + "related": [] + }, + { + "id": "sc-007", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "Caffeine", + "TinyLFU" + ], + "question": "关于 Caffeine 与 Redis 的对比,下列说法正确的是?", + "options": { + "A": "Caffeine 是独立进程可跨机器共享,访问无需网络", + "B": "Redis 是进程内 JVM 内存,重启后即失", + "C": "Caffeine 采用 TinyLFU 淘汰策略,纳秒级访问、适合做 L1 极热点缓存,但重启即失", + "D": "Redis 只能做本地内存,不支持持久化和分布式锁" + }, + "answer": "C", + "explanation": "Caffeine 是进程内 JVM 内存,采用 TinyLFU 淘汰,单元访问纳秒级、无网络开销,适合做 L1 极热点层,但重启即失、无法跨进程共享。Redis 是独立进程、可跨机器,毫秒级网络 I/O,支持持久化、分布式锁/原子计数/榜单/PubSub,是 L2 共享层。A、B、D 的描述正好反了。", + "source": null, + "related": [] + }, + { + "id": "sc-008", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "多级缓存" + ], + "question": "若业务场景为'读多写少、数据不强一致、希望提升极端热点访问性能',最合适的缓存方案是?", + "options": { + "A": "放弃缓存,全部走 DB", + "B": "引入多级缓存:进程内 Caffeine 作 L1 极热层,Redis 作 L2 共享层", + "C": "只用 DB 并加大连接池压力", + "D": "直接把 DB 当作缓存使用" + }, + "answer": "B", + "explanation": "读多写少、不强一致、极端热点适合多级缓存:本地 Caffeine 承担 L1 极热高频访问(纳秒级,无网络),Redis 作为 L2 跨进程共享与兜底层。该组合在热点场景显著降低回源。该场景不需要强一致,无需回 DB。", + "source": null, + "related": [] + }, + { + "id": "sc-009", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "缓存降级", + "限流", + "熔断" + ], + "question": "关于缓存架构的可靠性设计,下列说法错误的是?", + "options": { + "A": "Redis 可通过主从复制 + 哨兵表决实现高可用", + "B": "限流、熔断、降级主要用于保护 DB 和后端服务免于过载", + "C": "必要的 TTL 加上随机抖动可以有效防止缓存雪崩", + "D": "当 Redis 抖动或不可用时,应把流量无限回源 DB,不做任何降级" + }, + "answer": "D", + "explanation": "可靠性设计的另一面恰恰是降级与兜底:当 Redis 不可用时,直接全量回源 DB 可能瞬间打爆 DB,应通过本地缓存兜底、限流/熔断、返回降级数据等方式保护后端。A、B、C 均是正确描述,D 忽略了降级策略,是错误的说法。", + "source": null, + "related": [] + }, + { + "id": "sc-010", + "type": "single_choice", + "difficulty": 5, + "tags": [ + "Redis Cluster", + "哨兵" + ], + "question": "关于 Redis 组成的选型,下列说法正确的是?", + "options": { + "A": "哨兵(Sentinel)负责数据分片扩容,用于水平扩展写容量", + "B": "Redis Cluster 负责故障检测与主从自动切换,不具备分片能力", + "C": "哨兵用于主从复制的高可用表决与故障切换,Cluster 用于多分片横向扩展", + "D": "RDB 与 AOF 只能二选一,不能同时开启" + }, + "answer": "C", + "explanation": "哨兵(Sentinel)用于对主从复制架构做监控、故障检测、自动提升与切换(顾向选举),保障高可用;Redis Cluster 提供的是数据分片(slot),用于横向扩展容量与吞吐。二者职责不同。A、B 将两者职责对调了。RDB 与 AOF 可以同时开启,故 D 错误。", + "source": null, + "related": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-read-write-strategy/code_reading.json b/topics/architecture/cache-system/cache-read-write-strategy/code_reading.json new file mode 100644 index 0000000..e533686 --- /dev/null +++ b/topics/architecture/cache-system/cache-read-write-strategy/code_reading.json @@ -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": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-read-write-strategy/fill_blank.json b/topics/architecture/cache-system/cache-read-write-strategy/fill_blank.json new file mode 100644 index 0000000..9930add --- /dev/null +++ b/topics/architecture/cache-system/cache-read-write-strategy/fill_blank.json @@ -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": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-read-write-strategy/meta.json b/topics/architecture/cache-system/cache-read-write-strategy/meta.json new file mode 100644 index 0000000..59c6bb7 --- /dev/null +++ b/topics/architecture/cache-system/cache-read-write-strategy/meta.json @@ -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 + } + } +} diff --git a/topics/architecture/cache-system/cache-read-write-strategy/short_answer.json b/topics/architecture/cache-system/cache-read-write-strategy/short_answer.json new file mode 100644 index 0000000..b63f4af --- /dev/null +++ b/topics/architecture/cache-system/cache-read-write-strategy/short_answer.json @@ -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": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-read-write-strategy/single_choice.json b/topics/architecture/cache-system/cache-read-write-strategy/single_choice.json new file mode 100644 index 0000000..3820d8b --- /dev/null +++ b/topics/architecture/cache-system/cache-read-write-strategy/single_choice.json @@ -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": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-three-problems/code_reading.json b/topics/architecture/cache-system/cache-three-problems/code_reading.json new file mode 100644 index 0000000..d1e2ce5 --- /dev/null +++ b/topics/architecture/cache-system/cache-three-problems/code_reading.json @@ -0,0 +1,260 @@ +{ + "topic": "cache-three-problems", + "type": "code_reading", + "schema_version": "1.0.0", + "generated": "2026-09-05T00:00:00+08:00", + "questions": [ + { + "id": "cr-001", + "type": "code_reading", + "difficulty": 2, + "tags": [ + "缓存穿透", + "缓存空对象" + ], + "question": "阅读以下 Go 代码(空值缓存封装),回答子问题。", + "code": "func GetUser(ctx context.Context, id string) (*User, error) {\n key := \"user:\" + id\n v, err := rdb.Get(ctx, key).Result()\n if err == nil {\n if v == \"__NULL__\" {\n return nil, nil // 命中空缓存,视为不存在\n }\n u := &User{}\n json.Unmarshal([]byte(v), u)\n return u, nil\n }\n user, qerr := db.QueryUser(id)\n if qerr != nil {\n return nil, qerr\n }\n if user == nil {\n rdb.Set(ctx, key, \"__NULL__\", 300*time.Second) // 空值缓存,短TTL\n return nil, nil\n }\n b, _ := json.Marshal(user)\n rdb.Set(ctx, key, b, time.Hour)\n return user, nil\n}", + "language": "go", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "这段代码主要用于解决哪种缓存问题?", + "options": { + "A": "缓存击穿", + "B": "缓存穿透", + "C": "缓存雪崩", + "D": "缓存一致性问题" + }, + "answer": "B", + "explanation": "代码把数据库查不到的结果(user==nil)也缓存为 __NULL__ 占位空值并设置较短 TTL,这正是「缓存空对象」防穿透的典型实现。" + }, + { + "index": 2, + "type": "short_answer", + "question": "为什么空值缓存要设置 60 秒的【短】TTL 而不是与正常数据同样长?", + "answer": "因为空值占据缓存但永远不会被命中有价值数据,如果 TTL 过长,大量不同的不存在 key 会被长期缓存、挤占内存甚至写满缓存(缓存空间被空值污染)。用短 TTL 可让空值及时失效释放空间,同时仍能在 TTL 内挡住对同一不存在 key 的重复穿透。", + "explanation": "空值缓存是「空间换时间」,空间代价必须通过短 TTL 控制,否则大量空格 key 会耗尽内存。" + }, + { + "index": 3, + "type": "single_choice", + "question": "若攻击者改用大量【不同】的不存在 ID 请求,这段空值缓存能彻底挡住穿透吗?", + "options": { + "A": "能,因为空值已缓存", + "B": "不能,因为攻击者换新 ID 就产生新空 key,空缓存会积累并失效后再穿透", + "C": "能,因为 5 分钟 TTL 足够", + "D": "不能,且会使 DB 丢失全部数据" + }, + "answer": "B", + "explanation": "空值缓存只对「来过且不存在」的 key 有效。攻击者不断换新 ID,每次都产生新的空 key 再次穿透 DB,并会积累空值占用内存。因此需要结合布隆过滤器在入口拦截,才能挡住批量变化 ID 的穿透。" + } + ], + "explanation": "这段 Go 代码是缓存穿透「空值缓存」方案的完整封装:miss 后回源,DB 无结果则写短 TTL 的空值占位。注意空值的 TTL 与拦截面(单 key vs 多 key)的平衡。", + "source": null, + "related": [] + }, + { + "id": "cr-002", + "type": "code_reading", + "difficulty": 3, + "tags": [ + "缓存击穿", + "互斥锁", + "SETNX" + ], + "question": "阅读以下 Redis 命令片段(互斥锁防击穿),回答子问题。", + "code": "==== 流程 1:加锁回源 ====\nSET lock:hotkey 1 NX EX 3\n# 锁 key=lock:hotkey, 值 1, 仅当 key 不存在时设置(NX), 3 秒过期(EX)\n# 返回 OK: 抢到锁 → 回源 DB 并回填缓存\n# 返回 nil: 未抢到锁 → 等待 10ms 后重试或返回缓存旧值\n\n==== 流程 2:释放锁 ====\nDEL lock:hotkey\n\n==== 备注 ====\n回填后删除锁, 让其它等待请求命中缓存", + "language": "redis", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "SET lock:hotkey123 1 NX EX 200 中,NX 和 EX 的作用分别是?", + "options": { + "A": "NX 设置过期时间,EX 表示仅当不存在时设置", + "B": "NX 表示仅当 key 不存在时才设置,EX 设置过期时间为 200 秒", + "C": "NX 表示强制覆盖,EX 表示永久有效", + "D": "NX 是加锁,EX 是释放锁" + }, + "answer": "B", + "explanation": "SET key value NX EX seconds:NX 表示仅当 key 不存在时才设置(用于互斥抢占),EX 200 设置 200 秒过期。返回 OK/nil 表示是否抢到锁。" + }, + { + "index": 2, + "type": "short_answer", + "question": "为什么该互斥锁方案能避免大量请求同时打数据库(防止击穿)?", + "answer": "因为 NX 保证了在同一时刻只有一个请求能成功设置 lock:hotkey1 这个锁并进入回源流程,其余大量请求因 SET 返回 nil 而拿不到锁,只能等待重试或直接读缓存旧值。于是原本瞬间几万打 DB 的并发,被收敛为「一个回源 + 其余等待/命中缓存」,数据库压力被限流正常水平,从而避免被瞬时尖峰冲垮。", + "explanation": "互斥锁的核心是「并发串行化一个回源」,用等待换取 DB 安全,是击穿最直接的防御。" + }, + { + "index": 3, + "type": "single_choice", + "question": "若回源过程中线程崩溃、锁没被 DEL 删除,会怎样?", + "options": { + "A": "永远无影响", + "B": "锁会因 EX 200 秒过期自动失效,后续请求可重新抢锁", + "C": "缓存的 key 也会被删除", + "D": "数据库会报错" + }, + "answer": "B", + "explanation": "给锁设置过期时间 EX 200 是防止死锁的关键:即使线程崩溃没有执行 DEL,锁也会在 200 秒后自动释放,避免永久锁住导致后续请求长期等待。" + } + ], + "explanation": "这是一套基于 Redis SET NX EX 的分布式互斥锁:NX 保证互斥、EX 设置超时防死锁。通过把并发回源收敛为一个,有效解决单热点 key 过期导致的缓存击穿。", + "source": null, + "related": [] + }, + { + "id": "cr-003", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "缓存击穿", + "逻辑过期" + ], + "question": "阅读以下 Go 代码“逻辑过期”防击穿片段,回答子问题。", + "code": "type CachedValue struct {\n Value interface{} `json:\"value\"`\n ExpireAt int64 `json:\"expire_at\"` // 逻辑过期时间(ms)\n}\n\nfunc Get(key string) interface{} {\n raw := rdb.Get(ctx, key).Val()\n var cv CachedValue\n json.Unmarshal([]{byte(raw)}, &cv)\n if time.Now().UnixMilli() < cv.ExpireAt {\n return cv.Value // 未到逻辑过期, 直接返回\n }\n // 已到逻辑过期 → 发起后台异步刷新\n go asyncRefresh(key)\n return cv.Value // 仍返回旧值, 保证可用\n}\n\nfunc asyncRefresh(key string) {\n fresh := db.Query(key)\n rdb.Set(key, marshal(CachedValue{value: fresh, expireAt: ...}))\n}", + "language": "go", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "逻辑过期到达后,代码做了什么?", + "options": { + "A": "物理 DEL 删除 key,下次请求重新回源", + "B": "不删 key,用 goroutine 异步刷新数据,并仍返回旧值", + "C": "直接抛异常", + "D": "把 key 永久保留不过期" + }, + "answer": "B", + "explanation": "逻辑过期到达后不删除缓存:go asyncRefresh 在后台异步刷新,同时 return cv.Value 仍返回旧值,保证数据持续可用并挡住并发瞬时冲击。" + }, + { + "index": 2, + "type": "single_choice", + "question": "这比“到期 del 后所有请求回源”最大的优势是什么?", + "options": { + "A": "DB 数据更准", + "B": "在过期窗口内请求仍能命中缓存,不会在瞬间对 DB 形成大量并发冲击", + "C": "减少网络带宽", + "D": "缓存永远不过期所以更快" + }, + "answer": "B", + "explanation": "物理删除会让窗口期所有请求同时回源打 DB(击穿);逻辑过期把请求留在缓存命中,只有后台异步线程刷新 DB,从源头消除了并发尖峰。" + }, + { + "index": 3, + "type": "short_answer", + "question": "逻辑过期方案的已知代价是什么?在什么场景要谨慎使用?", + "answer": "代价:在后台刷新完成前,客户端读取到的是「旧数据」,存在短暂的数据时效性问题。因此在要求强一致、对实时性要求极严格(如金融实时账本、超高并发一致性敏感的写入)场景中应谨慎使用,更适用于可以短暂容忍旧值的读多写少的热点数据场景。", + "explanation": "逻辑过期是用「短暂旧数据」换「删除 DB」的并发安全,数据可用性强但一致性弱,需根据场景权衡。" + } + ], + "explanation": "逻辑过期是击穿的另一主流方案:value 内嵌逻辑过期时间,过期后不删 key,由后台异步线程刷新并继续返回旧值,实现吞吐与可用性的平衡。", + "source": null, + "related": [] + }, + { + "id": "cr-004", + "type": "code_reading", + "difficulty": 3, + "tags": [ + "缓存穿透", + "布隆过滤器" + ], + "question": "阅读以下 Go 代码(布隆过滤器防穿透),回答子问题。", + "code": "import (\n cachedow \"github.com/.../bloom\"\n)\n\nvar bf = bloom.New(1000000, 0.01) // 容量100万, 误判率≤1%\n// 启动时把全部已存在 userId 加入过滤器\nfor _, id := range loadAllUserIDs() {\n bf.Add(id)\n}\n\nfunc GetUser(id string) *User {\n // 先用布隆过滤器判断是否存在\n if !bf.MightContain(id) {\n return nil // 过滤器说“不存在”→ 直接拦截,不查DB\n }\n // 过滤器可能误判返回“存在”, 继续走缓存/DB\n if u := getCache(id); u != nil {\n return u\n }\n u := db.QueryUser(id) // 真正的回源\n setCache(id, u)\n return u\n}", + "language": "go", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "当 bf.MightContain(id) 返回 false 时,说明什么?", + "options": { + "A": "id 一定在数据库中", + "B": "id 可能不存在,也可能存在", + "C": "id 一定不存在", + "D": "布隆过滤器误判了" + }, + "answer": "C", + "explanation": "布隆过滤器返回 false 表示「一定不存在」(无假阴性),因此可直接拦截不查 DB,这是防穿透的关键。只有返回 true 才是「可能存在」。" + }, + { + "index": 2, + "type": "short_answer", + "question": "既然 MightContain 可能返回 true 把不存在的 id 漏放下去,为什么这样设计仍然安全?", + "answer": "因为布隆过滤器只存在假阳性(不存在可能被判存在),但不存在假阴性(存在不会被判不存在)。对于防穿透,我们需要拦住「确定不存在的 key」,这类 key 会被 MightContain 精确拦截;即使少量不存在的 key 被误判为 possible、放行到 DB 查询,命中率也很低且可被空值缓存兜底,不会形成持续穿透。漏放的是「假阳性的空查询」,而不是被隐藏真实存在的数据,仍然在防穿透可接受的错误范围内。", + "explanation": "关键在于误判只发生在「判存在」方向,而拦截的是「判不存在」,方向性正确决定了用于穿透防线的合理可靠。" + }, + { + "index": 3, + "type": "single_choice", + "question": "新增了一个 userId 并写入数据库后,如果只对新 id 发起请求而不重新构建布隆过滤器,会怎样?", + "options": { + "A": "过滤器会自动感知新 id,无需任何处理", + "B": "由于新 id 未被 Add 进过滤器,MightContain 可能返回 false,导致本已存在的数据被误判为不存在而被直接拦截", + "C": "数据库会自动把新 userId 加入布隆过滤器", + "D": "布隆过滤器会直接报错" + }, + "answer": "B", + "explanation": "布隆过滤器只支持添加、不支持删除,且已建好的过滤器集合不会自动包含新增数据。新的 userId 未被 Add,MightContain 极可能返回 false(视为「不存在」)而被 GetUser 直接拦截,导致 DB 中真实存在的新数据无法被访问。因此数据新增后必须【重建/增量更新】布隆过滤器,这也是它不支持删除之外的另一运维注意点。" + } + ], + "explanation": "布隆过滤器作为穿透前置屏障:以一定假阳性为代价换得对「不存在数据」的零假阴性拦截。但对动态新增的数据需要重新构建过滤器,否则新 id 会被误判不存在。", + "source": null, + "related": [] + }, + { + "id": "cr-005", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "缓存雪崩", + "TTL加随机", + "多级缓存" + ], + "question": "阅读以下 Redis/Go 片段(TTL 加随机防雪崩),回答子问题。", + "code": "// Go: 写缓存时 TTL 加随机, 打散到期时刻\nfunc SetWithJitter(key string, val interface{}, ttl time.Duration) {\n jitter := time.Duration(rand.Int63n(300)) * time.Second // 0~300s 随机\n rdb.Set(ctx, key, val, ttl+jitter)\n}\n\n// 批量初始化热点 key 的调用(原来都用了相同 TTL 60s)\nSetWithJitter(\"hot:a\", userA, 60*time.Second)\nSetWithJitter(\"hot:b\", userB, 60*time.Second)\nSetWithJitter(\"hot:c\", userC, 60*time.Second)\nSetWithJitter(\"hot:d\", userD, 60*time.Second)", + "language": "go", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "如果去掉 jitter(全部用 60 秒固定 TTL),这些 key 会怎样?", + "options": { + "A": "各自独立随机过期", + "B": "约在同一时刻(60秒后)集体过期, 瞬间全部穿透DB, 触发缓存雪崩", + "C": "永不过期", + "D": "过期时间会互相影响自动错开" + }, + "answer": "B", + "explanation": "相同的 TTL 会让同一批同时写入的 key 在【同一时刻】集体失效,请求瞬间全落 DB,正是雪崩的典型成因之一。" + }, + { + "index": 2, + "type": "single_choice", + "question": "加随机 jitter 后,最终达到什么效果?", + "options": { + "A": "让 key 永不过期", + "B": "把各 key 的到期时刻分散到一个时间区间内,避免在同一时刻大面积集体失效", + "C": "提高缓存准确率", + "D": "成批 key 反而更集中过期" + }, + "answer": "B", + "explanation": "TTL 加随机把到期时刻错开,使同一时刻并发失效的 key 数量显著下降,从而降低 DB 瞬时压力,是防雪崩最常用最直接的手段。" + }, + { + "index": 3, + "type": "short_answer", + "question": "除 TTL 加随机外,还可以用哪些手段进一步防御该批 key 的雪崩(至少写两种)?", + "answer": "1) 多级缓存:增加本地进程内缓存层,Redis 失效时由本地缓存兜底,再有 DB 压力。2) 加锁/限流/熔断降级:对 DB 访问做限流,超限则熔断或降级返回旧值/默认值,护住 DB。3) Redis 高可用:主从 + 哨兵 + 集群,避免单机宕机;4) 关键可以让人工预热/异步预加载,错峰刷新。任写两种即可。", + "explanation": "雪崩需多层防线叠加:打散 TTL 是一层,多级缓存兜底、限流降级、高可用是补充层,确保即使某层失效也不会全盘崩溃。" + } + ], + "explanation": "TTL 加随机是防雪崩的第一道防线:把集体到期打散为分布到期。但单有它还不够,需配合多级缓存、限流降级与 Redis 高可用共同筑牢防御体系。", + "source": null, + "related": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-three-problems/fill_blank.json b/topics/architecture/cache-system/cache-three-problems/fill_blank.json new file mode 100644 index 0000000..350da0b --- /dev/null +++ b/topics/architecture/cache-system/cache-three-problems/fill_blank.json @@ -0,0 +1,203 @@ +{ + "topic": "cache-three-problems", + "type": "fill_blank", + "schema_version": "1.0.0", + "generated": "2026-09-05T00:00:00+08:00", + "questions": [ + { + "id": "fb-001", + "type": "fill_blank", + "difficulty": 1, + "tags": [ + "缓存穿透" + ], + "question": "缓存穿透是指查询一个数据库中也______的数据,导致每次请求都直达数据库。", + "answer": [ + "不存在" + ], + "answer_rule": "any", + "explanation": "缓存穿透的本质是查询的 key 在数据库中也【不存在】,缓存查不到、写不回,因此每次请求都要穿透到 DB。", + "source": null, + "related": [] + }, + { + "id": "fb-002", + "type": "fill_blank", + "difficulty": 1, + "tags": [ + "缓存击穿" + ], + "question": "缓存击穿是______个热点 key 缓存【刚好过期】,大量请求同时打数据库,特点是单 key、高并发。", + "answer": [ + "一", + "单", + "1", + "一个" + ], + "answer_rule": "any", + "explanation": "击穿针对的是「单个特别热门的 key」刚过期,与穿透(不存在)和雪崩(成批)都不同,关键词是「单」。", + "source": null, + "related": [] + }, + { + "id": "fb-003", + "type": "fill_blank", + "difficulty": 1, + "tags": [ + "缓存雪崩" + ], + "question": "缓存雪崩指______大批 key 在同一时刻集体失效,或整台缓存服务宕机,瞬时请求全部穿透到数据库。", + "answer": [ + "一批", + "成批", + "多个", + "很多", + "大量" + ], + "answer_rule": "any", + "explanation": "雪崩的关键词是「多 key / 成批 / 集体失效」,与击穿的单 key 形成对比。", + "source": null, + "related": [] + }, + { + "id": "fb-004", + "type": "fill_blank", + "difficulty": 2, + "tags": [ + "缓存穿透", + "缓存空对象" + ], + "question": "用「缓存空对象」防穿透:DB 查不到时也缓存一个 null,并设置______(如 5 分钟)的 TTL。", + "answer": [ + "极短", + "很短", + "较短", + "短" + ], + "answer_rule": "any", + "explanation": "空值必须用极短 TTL,防止大量不同空 key 长期占用内存导致缓存爆掉。", + "source": null, + "related": [] + }, + { + "id": "fb-005", + "type": "fill_blank", + "difficulty": 2, + "tags": [ + "缓存穿透", + "布隆过滤器" + ], + "question": "布隆过滤器判断「不存在」时数据一定不存在(无______),判断「存在」时却可能存在(有______),且不支持删除。", + "answer": [ + "假阴性", + "假阳性" + ], + "answer_rule": "ordered", + "explanation": "布隆过滤器无假阴性(说不存在就一定不存在),有假阳性(说存在却可能不存在)。空按题目顺序填写「假阴性、假阳性」,因此 answer_rule 用 ordered。", + "source": null, + "related": [] + }, + { + "id": "fb-006", + "type": "fill_blank", + "difficulty": 2, + "tags": [ + "缓存击穿", + "互斥锁", + "SETNX" + ], + "question": "用互斥锁解决击穿时,常用 Redis 的______命令实现分布式锁,保证同时只有一个请求回源数据库并回填缓存。", + "answer": [ + "SETNX", + "setnx", + "SET NX", + "SETNXEX" + ], + "answer_rule": "any", + "explanation": "SETNX(SET if Not eXists)是 Redis 实现分布式锁最常用的命令:抢到锁的请求才回源,其余等待或降级。", + "source": null, + "related": [] + }, + { + "id": "fb-007", + "type": "fill_blank", + "difficulty": 3, + "tags": [ + "缓存击穿", + "逻辑过期" + ], + "question": "「逻辑过期」方案不真正删除缓存,到期后由______异步线程去更新,保证数据持续可用。", + "answer": [ + "后台", + "异步", + "后台异步", + "独立" + ], + "answer_rule": "any", + "explanation": "逻辑过期由后台异步线程在后台刷新缓存,从而挡住所拦截的并发瞬时冲击,保证数据持续可用。", + "source": null, + "related": [] + }, + { + "id": "fb-008", + "type": "fill_blank", + "difficulty": 3, + "tags": [ + "缓存雪崩", + "TTL加随机" + ], + "question": "给缓存 key 的过期时间加______值(如 60+random(0~300) 秒),可把到期时刻打散,避免一批 key 同时失效。", + "answer": [ + "随机", + "随机偏差", + "随机数", + "随机量" + ], + "answer_rule": "any", + "explanation": "TTL 加随机值把各 key 的到期时间分散开,防止同一时刻集体失效的雪崩。", + "source": null, + "related": [] + }, + { + "id": "fb-009", + "type": "fill_blank", + "difficulty": 4, + "tags": [ + "多级缓存", + "缓存雪崩" + ], + "question": "多级缓存的访问顺序是:本地进程内缓存 → ______ → DB,层层兜底防雪崩。", + "answer": [ + "Redis", + "分布式缓存", + "缓存层", + "分布式缓存层" + ], + "answer_rule": "any", + "explanation": "典型多级缓存顺序为本地缓存(进程内)→ Redis(分布式缓存层)→ DB,逐层兜底,减小某层失效带来的冲击。", + "source": null, + "related": [] + }, + { + "id": "fb-010", + "type": "fill_blank", + "difficulty": 4, + "tags": [ + "Redis高可用", + "缓存雪崩" + ], + "question": "为避免整台缓存服务宕机引发雪崩,可采用______(主从+哨兵)或集群方案保证缓存层高可用。", + "answer": [ + "主从", + "主从复制", + "哨兵", + "主从哨兵", + "高可用架构" + ], + "answer_rule": "any", + "explanation": "Redis 高可用通常由主从复制 + 哨兵(故障自动切换)+ 集群(分片)组成,避免整机宕机导致缓存雪崩。", + "source": null, + "related": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-three-problems/meta.json b/topics/architecture/cache-system/cache-three-problems/meta.json new file mode 100644 index 0000000..b8419c9 --- /dev/null +++ b/topics/architecture/cache-system/cache-three-problems/meta.json @@ -0,0 +1,41 @@ +{ + "slug": "cache-three-problems", + "name": "缓存穿透 / 击穿 / 雪崩", + "description": "缓存的三大经典问题:缓存穿透、缓存击穿、缓存雪崩的定义、区别与优化方案", + "tags": [ + "Redis高可用", + "SETNX", + "TTL加随机", + "互斥锁", + "参数校验", + "多级缓存", + "布隆过滤器", + "热点key", + "缓存击穿", + "缓存空对象", + "缓存穿透", + "缓存雪崩", + "逻辑过期" + ], + "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 + } + } +} diff --git a/topics/architecture/cache-system/cache-three-problems/short_answer.json b/topics/architecture/cache-system/cache-three-problems/short_answer.json new file mode 100644 index 0000000..155c408 --- /dev/null +++ b/topics/architecture/cache-system/cache-three-problems/short_answer.json @@ -0,0 +1,238 @@ +{ + "topic": "cache-three-problems", + "type": "short_answer", + "schema_version": "1.0.0", + "generated": "2026-09-05T00:00:00+08:00", + "questions": [ + { + "id": "sa-001", + "type": "short_answer", + "difficulty": 2, + "tags": [ + "缓存穿透" + ], + "question": "什么是缓存穿透?它的根本危害是什么?", + "answer": "缓存穿透指查询一个数据库中也【不存在】的数据。缓存查不到、DB 也查不到,无法写回缓存,导致每一次请求都直达 DB。其根本危害是:反复回源会对数据库造成持续高压,恶意攻击者用大量不存在的 ID 请求即可把 DB 打垮。", + "keywords": [ + "不存在", + "每次请求", + "直达", + "数据库", + "打垮DB", + "穿透" + ], + "scoring_rubric": "正确给出定义(查询不存在的数据)得 40 分;说明缓存无法写回导致每次直达 DB 得 30 分;点出对 DB 持续高压的危害得 30 分。", + "explanation": "穿透的本质是「没数据可缓存」,所以常规 miss→回源流程失效,必须前置拦截。", + "source": null, + "related": [] + }, + { + "id": "sa-002", + "type": "short_answer", + "difficulty": 2, + "tags": [ + "缓存穿透", + "参数校验", + "布隆过滤器", + "缓存空对象" + ], + "question": "缓存穿透的常见解决方式有哪些?请至少列出三种并简述原理。", + "answer": "1) 参数校验:在入口拦截非法/明显不存在的参数;2) 缓存空对象:DB 查不到也缓存 null + 极短 TTL,防止同一 key 反复穿透;3) 布隆过滤器:请求先过布隆过滤器,判断不存在直接拦截不查 DB(无假阴性);4) 还可以在数据库层做兜底校验等。", + "keywords": [ + "参数校验", + "缓存空对象", + "布隆过滤器", + "空值", + "短TTL", + "拦截" + ], + "scoring_rubric": "答出每种正确方案得 30 分(共可答多但至少三种得 90 分);能说明每种原理(为何能防穿透)再得 10 分。", + "explanation": "穿透的关键是「根本不存在」,所以必须前置拦截(参数校验、布隆)或对空结果做缓存(空对象)避免重复回源。", + "source": null, + "related": [] + }, + { + "id": "sa-003", + "type": "short_answer", + "difficulty": 1, + "tags": [ + "缓存击穿" + ], + "question": "简述缓存击穿的定义与它的典型场景特征。", + "answer": "缓存击穿指一个特别热门的热点 key(如微博热搜)缓存刚好过期的瞬间,大量请求同时 miss 全部打到一个数据库。特征是【单 key、高并发、瞬时尖峰】——只有那一个 key 失效,却因并发极高而冲垮 DB。", + "keywords": [ + "单个热点key", + "过期", + "高并发", + "瞬时尖峰", + "打数据库", + "单key" + ], + "scoring_rubric": "正确界定为单个热点 key 过期得 50 分;写出大量请求同时回源打 DB 得 30 分;总结单 key/高并发/瞬时尖峰特征得 20 分。", + "explanation": "击穿针对的是「单 key」,这一点是与雪崩(多 key)最关键的区分。", + "source": null, + "related": [] + }, + { + "id": "sa-004", + "type": "short_answer", + "difficulty": 2, + "tags": [ + "缓存击穿", + "互斥锁" + ], + "question": "用互斥锁解决缓存击穿的思路是什么?请说明加锁与不加锁的区别。", + "answer": "思路:当热点 key 过期且多个请求同时请求时,只有【一个请求拿到锁(如 SETNX)去数据库回源并回填缓存】,其他请求阻塞等待锁释放后命中缓存,或直接返回旧值/降级。区别:不加锁时 N 个请求会同时打 DB 造成数据库瞬时峰值;加锁后同一时刻只有一个请求回源,其余请求等待或命中缓存,从而把峰值压平,保护 DB。", + "keywords": [ + "互斥锁", + "SETNX", + "只有一个回源", + "其余等待", + "命中缓存", + "保护数据库" + ], + "scoring_rubric": "正确说明只有一个请求回源并回填得 50 分;解释其余请求等待/命中缓存得 30 分;说明加锁与不加锁的区别得 20 分。", + "explanation": "互斥锁的核心是把「N 个线程同时回源」收敛为「1 个线程回源 + 其余等待」,通过牺牲少量延迟换取数据库安全。", + "source": null, + "related": [] + }, + { + "id": "sa-005", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "缓存击穿", + "逻辑过期" + ], + "question": "说明「逻辑过期」方案解决缓存击穿的原理,并指出它相比真删除的一个关键优势。", + "answer": "逻辑过期不物理删除 key,而是在 value 中额外记录一个逻辑过期时间。请求发现逻辑过期后不直接删除,而是由后台异步线程去更新数据并重设缓存;期间返回的仍是旧值持续可用。关键优势:在缓存「看似已过期」到「后台刷新完成」的窗口内,请求依旧能命中缓存而无需全部穿透到 DB,从而挡住并发瞬时冲击。", + "keywords": [ + "逻辑过期时间", + "后台异步", + "异步线程", + "旧值可用", + "不删缓存", + "挡并发" + ], + "scoring_rubric": "正确描述不删除 key 且记录逻辑过期时间得 40 分;说明由后台异步线程刷新得 30 分;写出返回旧值/持续可用、挡住并发 的认知得 30 分。", + "explanation": "逻辑过期用「数据可用性优先」避免窗口期打 DB,代价是可能短暂读到旧数据,适合对实时性要求不极端的场景。", + "source": null, + "related": [] + }, + { + "id": "sa-006", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "缓存雪崩", + "TTL加随机" + ], + "question": "缓存雪崩的成因有哪些?分别说明其对应的治理思路。", + "answer": "成因一:一大批 key 同时设置了相同过期时间,到了同一时刻集体失效;对应思路——给 TTL 加随机偏差打散到期时间。成因二:缓存服务整机宕机导致所有请求穿透;对应思路——多级缓存兜底、加锁/限流/熔断降级保护 DB,以及 Redis 主从+哨兵+集群的高可用部署。", + "keywords": [ + "相同过期时间", + "集体失效", + "加随机", + "宕机", + "多级缓存", + "限流降级", + "高可用" + ], + "scoring_rubric": "正确识别集体失效并给出加随机策略得 40 分;正确识别宕机并给出兜底/限流/降级得 35 分;给出 Redis 高可用部署得 25 分。", + "explanation": "雪崩是「批量」失效叠加「整机宕机」两大风险,治理必须组合 TTL 打散 + 多层兜底 + 限流降级 + 高可用。", + "source": null, + "related": [] + }, + { + "id": "sa-007", + "type": "short_answer", + "difficulty": 1, + "tags": [ + "缓存穿透", + "缓存击穿", + "缓存雪崩" + ], + "question": "缓存穿透、击穿、雪崩三者的核心区别是什么?", + "answer": "穿透——根因是查询【不存在】的数据,缓存永远 miss,DB 持续高压,解法是空值缓存+布隆过滤器+参数校验;击穿——单个热点 key 刚过期遭遇高并发,DB 瞬时尖峰,解法是互斥锁+逻辑过期;雪崩——大批 key 同时过期或缓存宕机,多 key 集体失效冲垮 DB,解法是 TTL 加随机+多级缓存+限流降级+Redis 高可用。", + "keywords": [ + "不存在", + "单key", + "多key", + "持续高压", + "瞬时尖峰", + "集体失效" + ], + "scoring_rubric": "准确定义穿透并答对应对策 33 分;准确定义击穿并答对 33 分;准确定义雪崩并答对 34 分。", + "explanation": "记忆口诀:穿透查「空」,击穿防「单点失效」,雪崩防「批量失效/宕机」。三者最终都要保护 DB。", + "source": null, + "related": [] + }, + { + "id": "sa-008", + "type": "short_answer", + "difficulty": 2, + "tags": [ + "缓存穿透", + "布隆过滤器" + ], + "question": "既然布隆过滤器有误判可能,为什么仍可用于防缓存穿透?", + "answer": "因为布隆过滤器只存在假阳性(判断存在可能不存在),而不存在假阴性(判断不存在则一定不存在)。防穿透时我们最需要的是把「不存在的 key」挡在 DB 之前,对这类 key 布隆过滤器会给出确定的「不存在」结论从而直接拦截,不会漏放;即使有极少数误判为「存在」的空 key 放行,也会被后续空值缓存等兜底。误判只可能多放、不会漏放的不存在请求,所以可用。", + "keywords": [ + "无假阴性", + "不存在一定拦截", + "假阳性可接受", + "空值兜底" + ], + "scoring_rubric": "说明无假阴性(不存在→一定不存在)得 40 分;解释用它拦截不存在的 key 得 30 分;解释误判为正存在可被兜底得 30 分。", + "explanation": "关键是方向:布隆过滤器对「不存在」的结论是 100% 可靠,而防穿透恰好需要拦截人多不存在数据不被穿透。", + "source": null, + "related": [] + }, + { + "id": "sa-009", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "缓存空对象", + "缓存穿透" + ], + "question": "缓存空对象方案解决了穿透,但可能带来什么新问题?如何缓解?", + "answer": "新问题:大量【不同的不存在 key】都会被缓存为空对象,而这些键永远占着内存且无法被正常命中,长期积累会挤满缓存内存、浪费空间。缓解:1) 给空对象设置极短 TTL(如 5~10 分钟)让其及时失效;2) 结合布隆过滤器在入口先拦截明显不存在的 key,减少写入空缓存的数量;3) 做好内存淘汰与监控。", + "keywords": [ + "占满内存", + "多空key", + "极短TTL", + "布隆过滤器拦截", + "内存告避免" + ], + "scoring_rubric": "准确指出内存被大量空 key 占用得 40 分;给出短 TTL 缓解得 30 分;给出布隆过滤器/内存控制补充方案得 30 分。", + "explanation": "空值缓存是「空间换时间」,代价是空间,所以必须用短 TTL + 前置拦截控制空 key 数量。", + "source": null, + "related": [] + }, + { + "id": "sa-010", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "多级缓存", + "Redis高可用", + "缓存雪崩" + ], + "question": "结合多级缓存思路,说明在缓存雪崩/宕机风险下如何设计一套层层兜底的缓存架构。", + "answer": "可设计三层:最前一层用本地进程内缓存(Caffeine 类),承接热读取吞吐;中间层用 Redis 分布式缓存,主从+哨兵/集群保证高可用,避免宕机;最后层是数据库。访问顺序本地→Redis→DB,每层 miss 才下沉到下一层。配合机制:TTL 加随机打散集体过期;对 DB 再加限流、熔断、降级(如降级返回旧值或默认数据)。即使 Redis 瞬时不可用,本地缓存仍能扛住大部分读请求,DB 请求被限流保护,避免整体雪崩。", + "keywords": [ + "本地缓存", + "Redis", + "数据库", + "层层兜底", + "高可用", + "限流熔断降级" + ], + "scoring_rubric": "给出本地+分布式+DB 分层架构得 40 分;说明访问顺序与每层兜底得 30 分;结合 TTL随机、限流降级、高可用写出 30 分。", + "explanation": "多级缓存的本质是「冗余缓存层 + 兜底限流」,即使上层失效也由下层承接或降级,从架构上消除单点雪崩。", + "source": null, + "related": [] + } + ] +} diff --git a/topics/architecture/cache-system/cache-three-problems/single_choice.json b/topics/architecture/cache-system/cache-three-problems/single_choice.json new file mode 100644 index 0000000..39b9302 --- /dev/null +++ b/topics/architecture/cache-system/cache-three-problems/single_choice.json @@ -0,0 +1,209 @@ +{ + "topic": "cache-three-problems", + "type": "single_choice", + "schema_version": "1.0.0", + "generated": "2026-09-05T00:00:00+08:00", + "questions": [ + { + "id": "sc-001", + "type": "single_choice", + "difficulty": 1, + "tags": [ + "缓存穿透" + ], + "question": "「缓存穿透」的本质与下列哪种情况最匹配?", + "options": { + "A": "查询数据库中存在但缓存中不存在的数据", + "B": "查询数据库中也根本不存在的数据,每次请求都直达数据库", + "C": "单个热点 key 刚好过期导致大量请求同时打数据库", + "D": "大批量 key 在同一时刻集体失效" + }, + "answer": "B", + "explanation": "缓存穿透指查询一个数据库中也【不存在】的数据。缓存查不到、DB 也查不到,无法写回缓存,导致每一次请求都直达 DB。选项 A 属于正常缓存未命中可回源;C 是缓存击穿;D 是缓存雪崩。", + "source": null, + "related": [] + }, + { + "id": "sc-002", + "type": "single_choice", + "difficulty": 1, + "tags": [ + "缓存雪崩" + ], + "question": "「缓存雪崩」的典型特征是?", + "options": { + "A": "单个热点 key 过期导致的高并发尖峰", + "B": "查询不存在的数据导致持续打到数据库", + "C": "大批 key 同时过期或整台缓存服务宕机,瞬间请求全部穿透到数据库", + "D": "布隆过滤器误判导致正常请求被拦截" + }, + "answer": "C", + "explanation": "缓存雪崩的特征是【多 key、集中失效、集体崩溃】:一大批 key 在同一时刻集体失效(如同期设置了相同过期时间),或整台缓存服务宕机,导致瞬间所有请求穿透到 DB。选项 A 是击穿,B 是穿透,D 是布隆过滤器的副作用。", + "source": null, + "related": [] + }, + { + "id": "sc-003", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "缓存击穿", + "热点key" + ], + "question": "某个微博热搜 key 缓存恰好过期,瞬时大量请求同时回源打数据库,这属于哪种问题?", + "options": { + "A": "缓存穿透", + "B": "缓存击穿", + "C": "缓存雪崩", + "D": "缓存一致性问题" + }, + "answer": "B", + "explanation": "该场景是【单个特别热门的 key】缓存过期,此刻大量请求同时来全部 miss、同时打 DB。特点是单 key、高并发、瞬时尖峰,正符合缓存击穿的定义。穿透针对不存在的 key,雪崩针对多 key 集体失效,一致性问题与本题无关。", + "source": null, + "related": [] + }, + { + "id": "sc-004", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "缓存穿透", + "布隆过滤器" + ], + "question": "关于布隆过滤器判断数据「是否存在」,下列说法正确的是?", + "options": { + "A": "判断不存在时,数据可能实际存在", + "B": "判断不存在时,数据一定不存在", + "C": "判断存在时,数据一定存在", + "D": "不支持删除,因此只能用于处理缓存击穿" + }, + "answer": "B", + "explanation": "布隆过滤器不存在假阴性:判断为「不存在」则一定不存在。存在假阳性:判断为「存在」时可能实际不存在。选项 C 错误(存在→可能存在),选项 A 描述的是假阳性而非不存在,选项 D 布隆过滤器是解决穿透的方案而非击穿。", + "source": null, + "related": [] + }, + { + "id": "sc-005", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "缓存穿透", + "缓存空对象" + ], + "question": "使用「缓存空对象」方案解决缓存穿透时,下列说法错误的是?", + "options": { + "A": "数据库查不到时,也缓存一个 null 值并设置较短的 TTL", + "B": "可以有效防止对同一个不存在 key 的反复穿透", + "C": "大量不同的空 key 都会占满内存,存在内存被写满的风险", + "D": "空值对象应该设置与正常数据相同甚至更长的 TTL" + }, + "answer": "D", + "explanation": "缓存空对象应设置【极短 TTL】(如 5 分钟),防止大量空 key 长期占用内存,而不是设置相同或更长的 TTL。A、B 正确描述了原理,C 正确指出了空值缓存的主要内存风险,故错误的选项是 D。", + "source": null, + "related": [] + }, + { + "id": "sc-006", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "缓存击穿", + "互斥锁", + "SETNX" + ], + "question": "使用 Redis SETNX 实现互斥锁解决缓存击穿时,只有抢到锁的请求才能回源数据库,它的核心作用是?", + "options": { + "A": "让所有请求都穿透到数据库以加快数据同步", + "B": "保证只有一个线程去查库回填缓存,其余请求等待或降级", + "C": "把锁上的请求直接丢弃以减少数据库压力", + "D": "用于给缓存 key 设置统一的过期时间" + }, + "answer": "B", + "explanation": "互斥锁保证同一时刻只有一个请求能去查 DB 并回填缓存,其他请求阻塞等待或返回旧值,从而避免雪崩式地打 DB。A、C 与思路相悖,D 与过期时间无关。", + "source": null, + "related": [] + }, + { + "id": "sc-007", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "缓存雪崩", + "TTL加随机" + ], + "question": "为防止缓存雪崩,给大量缓存 key 的 TTL 增加随机偏差(如 60+random(0~300) 秒)能达到什么效果?", + "options": { + "A": "让所有 key 在同时到期,保证数据最新", + "B": "把到期时间打散,避免一批 key 在同一时刻集体失效", + "C": "彻底消除缓存过期,让数据永不失效", + "D": "减少缓存换取流量,降低服务器带宽消耗" + }, + "answer": "B", + "explanation": "给每个 key 的 TTL 加随机偏差可以把到期时刻分散开,避免一大批复集中在同一时刻失效触发雪崩。A、C 与目标相反,D 与带宽无关。加随机是雪崩最直接有效的兜底手段之一。", + "source": null, + "related": [] + }, + { + "id": "sc-008", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "缓存击穿", + "逻辑过期" + ], + "question": "关于「逻辑过期」解决缓存击穿的方案,下列说法正确的是?", + "options": { + "A": "到逻辑过期时间就立即物理删除缓存里的 key", + "B": "不真正删除缓存,缓存到期后由异步线程后台更新,数据保持持续可用", + "C": "逻辑过期会增加 DB 的瞬时并发压力", + "D": "逻辑过期是专门用来解决缓存穿透的方案" + }, + "answer": "B", + "explanation": "逻辑过期不删除 key,而是在 value 中记录逻辑过期时间,允许返回旧值或标记过期后由后台异步线程刷新,从而挡住并发的瞬时冲击。A 是物理丢弃,C 与 B 的异步刷新理念相悖,D 它是解决击穿的方案。", + "source": null, + "related": [] + }, + { + "id": "sc-009", + "type": "single_choice", + "difficulty": 5, + "tags": [ + "缓存穿透", + "参数校验", + "布隆过滤器" + ], + "question": "某系统收到大量针对「不存在 userId」的请求,为在【不合法参数被鉴权】之前、从入口处优先拦截绝大多数非法请求,最直接的做法是?", + "options": { + "A": "直接返回数据库全表数据给客户端", + "B": "对所有请求做参数校验,在入口拦截非法参数与不存在数据", + "C": "给数据库添加唯一约束", + "D": "把所有请求都用 SETNX 加锁串行化" + }, + "answer": "B", + "explanation": "参数校验是穿透的第一道防线:在入口处拦截非法参数、明显不存在的请求,避免其打到 DB。A 会雪上加霜,C 是数据库层措施但不防穿透,D 的加锁是为击穿而设且无法区分合法不存在的 key。", + "source": null, + "related": [] + }, + { + "id": "sc-010", + "type": "single_choice", + "difficulty": 5, + "tags": [ + "多级缓存", + "Redis高可用", + "缓存雪崩" + ], + "question": "以下属于「缓存雪崩」完整治理组合方案的是?", + "options": { + "A": "仅设置:对所有 key 采用永久不过期", + "B": "过期时间加随机值 + 多级缓存(本地/Redis/DB 兜底)+ 限流熔断降级 + Redis 主从/集群高可用", + "C": "只使用布隆过滤器拦截不存在的 key", + "D": "只采用互斥锁让单 key 回源串行" + }, + "answer": "B", + "explanation": "雪崩是多 key 集体失效 + 服务宕机的双重风险,治理需要组合:TTL 加随机打散失效时间、多级缓存层层兜底、加锁/限流/熔断降级保护 DB、Redis 主从哨兵集群保证高可用。A 永不失效既不现实也不能防宕机,C 针对穿透,D 针对单 key 击穿而非成批雪崩。", + "source": null, + "related": [] + } + ] +} diff --git a/topics/index.json b/topics/index.json index 2161ff4..e4f0ef9 100644 --- a/topics/index.json +++ b/topics/index.json @@ -240,6 +240,51 @@ "code_reading": 10 } } + }, + { + "slug": "cache-three-problems", + "name": "缓存穿透 / 击穿 / 雪崩", + "description": "缓存的三大经典问题:缓存穿透、缓存击穿、缓存雪崩的定义、区别与优化方案", + "path": "topics/architecture/cache-system/cache-three-problems", + "stats": { + "total": 35, + "by_type": { + "single_choice": 10, + "fill_blank": 10, + "short_answer": 10, + "code_reading": 5 + } + } + }, + { + "slug": "cache-read-write-strategy", + "name": "多级缓存与读写策略", + "description": "Cache-Aside、读穿写、多级缓存路径、双删与版本号竞态优化、Caffeine 本地缓存", + "path": "topics/architecture/cache-system/cache-read-write-strategy", + "stats": { + "total": 35, + "by_type": { + "single_choice": 10, + "fill_blank": 10, + "short_answer": 10, + "code_reading": 5 + } + } + }, + { + "slug": "cache-architecture", + "name": "缓存架构与组件选型", + "description": "单机是否需 Redis、客户端只读写缓存(Write-Behind)、多级缓存一致性、组件选型与降级高可用", + "path": "topics/architecture/cache-system/cache-architecture", + "stats": { + "total": 35, + "by_type": { + "single_choice": 10, + "fill_blank": 10, + "short_answer": 10, + "code_reading": 5 + } + } } ] }