跳转到内容

缓存一致性策略

用途:决定“更新数据时缓存怎么处理”,并解释偶发脏数据

模式 做法 一致性 评价
Cache Aside(旁路) 读:未命中查 DB 回填;写:更新 DB 后删除缓存 最终一致,窗口小 主流选择
Read/Write Through 缓存层代理读写,DB 由缓存组件同步 较好 需要缓存组件支持
Write Behind(回写) 先写缓存,异步批量落 DB 弱,可能丢 只适合可丢或可重放的数据

为什么是“删除”而不是“更新”缓存:并发写时“更新”会产生乱序(后写 DB 的先更新缓存 → 脏值长期存在);删除让下一次读重建,收敛更快。

经典问题:读请求未命中 → 读到 DB 旧值 → 写请求更新 DB 并删缓存 → 读请求把旧值写回缓存 → 缓存长期脏。

缓解手段(没有零成本完美方案):

  1. 延迟双删:更新 DB → 删缓存 → 延迟(略大于读回填耗时,如数百毫秒)再删一次。简单但依赖时间估计。
  2. 版本号/时间戳:缓存值携带数据版本,回填时比较,旧版本不覆盖新版本(CAS 语义或 Lua 脚本实现)。
  3. 订阅 binlog 失效:由 CDC(如 Canal、Debezium)监听变更事件驱动删除,天然带顺序,避免应用漏删。
  4. 短 TTL 兜底:任何策略都配一个可容忍的最大过期时间,把不一致限制在窗口内。

写走主库、读走从库时,从库延迟会让“回填缓存”读到旧数据。对策:写后短时间内强制读主、或写路径直接更新/删除缓存而不是依赖回填。

  1. 明确可容忍的不一致窗口(毫秒 / 秒 / 分钟),据此选策略,而不是追求“绝对一致”。
  2. 缓存不承担唯一性判定:库存、名额、幂等这类判定要么在 DB 用条件更新,要么用 Redis 原子操作作为权威源(此时它不是缓存)。
  3. 失效必须幂等可重入:删除操作可重复执行;订阅链路要能重放。
  4. 可观测:命中率、回源 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 差异?
  • 缓存不可用时是否限流回源而不是全量穿透?