{ "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": [] } ] }