缓存一致性策略
用途:决定“更新数据时缓存怎么处理”,并解释偶发脏数据
三种基本模式
「三种基本模式」小节链接| 模式 | 做法 | 一致性 | 评价 |
|---|---|---|---|
| Cache Aside(旁路) | 读:未命中查 DB 回填;写:更新 DB 后删除缓存 | 最终一致,窗口小 | 主流选择 |
| Read/Write Through | 缓存层代理读写,DB 由缓存组件同步 | 较好 | 需要缓存组件支持 |
| Write Behind(回写) | 先写缓存,异步批量落 DB | 弱,可能丢 | 只适合可丢或可重放的数据 |
为什么是“删除”而不是“更新”缓存:并发写时“更新”会产生乱序(后写 DB 的先更新缓存 → 脏值长期存在);删除让下一次读重建,收敛更快。
Cache Aside 的并发窗口
「Cache Aside 的并发窗口」小节链接经典问题:读请求未命中 → 读到 DB 旧值 → 写请求更新 DB 并删缓存 → 读请求把旧值写回缓存 → 缓存长期脏。
缓解手段(没有零成本完美方案):
- 延迟双删:更新 DB → 删缓存 → 延迟(略大于读回填耗时,如数百毫秒)再删一次。简单但依赖时间估计。
- 版本号/时间戳:缓存值携带数据版本,回填时比较,旧版本不覆盖新版本(
CAS语义或 Lua 脚本实现)。 - 订阅 binlog 失效:由 CDC(如 Canal、Debezium)监听变更事件驱动删除,天然带顺序,避免应用漏删。
- 短 TTL 兜底:任何策略都配一个可容忍的最大过期时间,把不一致限制在窗口内。
读写分离带来的额外问题
「读写分离带来的额外问题」小节链接写走主库、读走从库时,从库延迟会让“回填缓存”读到旧数据。对策:写后短时间内强制读主、或写路径直接更新/删除缓存而不是依赖回填。
设计原则
「设计原则」小节链接- 明确可容忍的不一致窗口(毫秒 / 秒 / 分钟),据此选策略,而不是追求“绝对一致”。
- 缓存不承担唯一性判定:库存、名额、幂等这类判定要么在 DB 用条件更新,要么用 Redis 原子操作作为权威源(此时它不是缓存)。
- 失效必须幂等可重入:删除操作可重复执行;订阅链路要能重放。
- 可观测:命中率、回源 QPS、缓存与 DB 的差异抽样比对(定期校验任务)。
常见误区
「常见误区」小节链接| 误区 | 修正 |
|---|---|
| 更新 DB 后先更缓存再更 DB | 顺序错误会放大脏值窗口;标准做法是 DB 优先 + 删缓存 |
| 忘记删缓存(异常路径) | 删除放在事务提交后且捕获失败重试(或走 binlog 兜底) |
| 用缓存当分布式锁的存储 | 锁语义需要专门的原子结构与超时设计 |
| 只考虑单 key,忽略多 key 聚合视图 | 派生结构(列表缓存、统计缓存)需要显式失效链路 |
| 认为“延迟双删”能保证一致 | 它只是缩小窗口,需与 TTL 兜底配合 |
延伸阅读
「延伸阅读」小节链接- Martin Kleppmann《数据密集型应用系统设计》第 1 章(缓存与一致性)
- Canal / Debezium 官方文档(变更订阅)
- “How to think about cache invalidation” 类公开技术文章(含 Fowler 关于两难的名句)
按不一致窗口选策略
「按不一致窗口选策略」小节链接| 可容忍窗口 | 推荐策略 | 必备配套 |
|---|---|---|
| 不允许 | 不用缓存承载该字段,或让 Redis 成为权威源(原子操作) | 恢复与回源方案 |
| 毫秒级 | 更新 DB → 删缓存 → 延迟二次删除 | 延迟量 > 读回填耗时;TTL 兜底 |
| 秒级 | 更新 DB → 删缓存 + binlog 订阅失效 | 订阅幂等、延迟监控 |
| 分钟级 | 短 TTL + 被动重建 | 击穿保护(互斥回源) |
版本号方案(确定性更强)
「版本号方案(确定性更强)」小节链接缓存值结构 {v: 数据版本, d: payload};DB 侧维护单调递增版本(优先自增版本号,避免依赖时钟)。
读:命中即返回;未命中查 DB(含版本)→ 带版本写缓存写:UPDATE ... SET version = version + 1 → 删缓存回填保护(Lua):缓存中版本 >= 待写版本 则拒绝覆盖可复算示例:脏缓存如何产生
「可复算示例:脏缓存如何产生」小节链接t0 缓存为空t1 读请求 R1 未命中,查 DB 得 v1(耗时 80ms)t2 写请求 W 更新 DB 为 v2 并删缓存(此时缓存本就为空,删除无效果)t3 R1 回填 v1,TTL 30 分钟t4 之后 30 分钟内所有读都拿到 v1,且不会自愈三种修复对比:延迟双删(t2 后 100ms 再删一次,清掉 t3 的旧值);版本号(t3 因版本低于 v2 被拒写);binlog 订阅(W 的事件在 t3 之后到达并删除)。只有版本号能同时防住“回填晚于删除”与“多次重复回填”。
排查清单
「排查清单」小节链接- 每个缓存 key 是否声明了可容忍窗口与 TTL?
- 写路径异常分支(DB 成功、删缓存失败)是否有重试或订阅兜底?
- 派生缓存(列表、统计、聚合视图)是否都有失效链路?
- 读写分离是否处理了“回填读到从库旧值”?
- 是否有抽样比对任务定期校验缓存与 DB 差异?
- 缓存不可用时是否限流回源而不是全量穿透?