Files
examination/topics/architecture/cache-system/cache-three-problems/code_reading.json
T
2026-09-05 12:03:21 +08:00

261 lines
17 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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": []
}
]
}