261 lines
17 KiB
JSON
261 lines
17 KiB
JSON
{
|
||
"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": []
|
||
}
|
||
]
|
||
}
|