缓存一致性工程实践

缓存上线后最常收到的一类反馈是“数据不对,刷新一下又好了”。这类问题几乎都出在写路径:数据库改了,缓存还是旧的。本章把写策略、失效链路和排查工具串成一套可落地的工程做法。

先更库还是先删缓存

写缓存有几种顺序,逐一分析:

写策略过程主要问题
先删缓存,再更新库删 → 更新 DB删完之后、更新之前若有读请求,会把旧值回填缓存,脏数据长期存活
先更新库,再删缓存更新 DB → 删不一致窗口极短;若删除失败,缓存一直是旧值
更新库后更新缓存更新 DB → set 缓存并发下可能后写的旧值覆盖新值,且浪费一次写

推荐“先更新数据库,再删除缓存”,理由:

  • 删除比更新安全:删除后下一次读会回源拿最新值,而更新缓存可能写入过期数据。
  • 删除失败可以重试,且窗口很窄。
  • 不在写路径上计算缓存内容,避免把复杂逻辑压到写链路。

延迟双删

“先更库再删缓存”的残余窗口来自这样的并发交错:读请求查库拿到旧值 → 写请求更新库并删缓存 → 读请求把旧值写回缓存。为了把这段窗口挤掉,可以在删除后等一小会儿再删一次:

// 以下片段需放进对应工程执行,省略了类外壳
public void updateGoodsName(long id, String name) throws InterruptedException {
    updateDb(id, name);        // 1. 先更新数据库
    deleteCache(id);           // 2. 立即删缓存
    Thread.sleep(500);         // 3. 等可能回填旧值的读请求走完
    deleteCache(id);           // 4. 再删一次,清掉可能被回填的旧值
}

它的局限要清楚:

  • sleep 时长靠估,读链路慢或偶发卡顿时仍会漏。
  • 让写请求多等 500ms,写多场景不合适;可以改成用延迟队列异步执行第二次删除。
  • 只能缩小窗口,不能消灭不一致。

消息驱动失效

把“删缓存”从业务代码里抽出来,改成发消息 + 消费者删除,好处是删除失败可以重试:

业务更新 DB → 发送失效消息(topic=cache.invalidate, key=goods:detail:10086)
            → 消费者删除 Redis 缓存 + 删除本地缓存
            → 删除成功则 ack;失败则重试(指数退避,最多 N 次)
            → 超过上限进入死信队列,告警并人工处理

工程要点:

关注点做法
消息丢失业务侧用本地消息表,保证“改库”和“发消息”要么都成,要么都不成
重复消费删除操作天然幂等,重复执行无害
顺序同一 key 的失效消息按 key 哈希到同一分区,避免后发先至
兜底缓存仍设 TTL,即使消息全丢也能自然收敛

binlog 订阅

更彻底的做法是让业务只管写库,缓存失效由数据库变更日志驱动。以 Canal 思路为例:它伪装成 MySQL 从库,向主库请求 binlog,解析出 ROW 格式的行变更后投递到 MQ:

DB 变更 → binlog(ROW) → Canal 解析出 表名/主键/变更类型
        → 按主键哈希发到 MQ 对应分区 → 消费者删除或重建对应缓存
优点代价
业务代码零侵入,不怕漏删多引入一套组件,需运维和监控
能捕获所有来源的变更(含手工 SQL、其他服务)binlog 解析对表结构变化敏感,要配监控
天然带顺序信息,便于按主键保序有秒级延迟,仍要靠 TTL 兜底

最终一致 vs 强一致

目标手段代价适用
最终一致删缓存 + TTL + 消息/binlog 重试存在秒级窗口详情页、列表、统计
读己之写写后短时间该用户强制读主库/绕过缓存主库读压力略增下单后跳转详情
强一致读也走主库、不缓存,或分布式锁 + 版本校验串行化性能损失大余额、库存精确值

多数业务取“最终一致 + 读己之写”的组合即可。真正要求强一致的数据,最省心的办法是不缓存

排查不一致的工具

数据已经不一致了,怎么定位是谁写坏的?

  1. 版本号:写入时把 version(或 updated_at)一起写进缓存值和数据库。读到的缓存值若版本低于库里的版本,说明缓存过期。
  2. 时间戳比对:缓存里带上写入时间,一旦发现缓存时间早于数据库 updated_at,就能确认是失效链路没走通。
  3. 对账任务:离线抽样比对缓存与库,统计不一致条数与类型,判断是“漏删”还是“回填旧值”。
  4. 监控回源量与命中率:命中率突降通常意味着大批缓存被删或失效;回源量飙升则是雪崩或热点。
-- 对账用:找出更新时间晚于缓存快照时间的记录,即疑似未失效
SELECT id, updated_at
FROM goods
WHERE updated_at > ?
  AND id IN (/* 抽样的一批缓存 key 对应主键 */);

一套可落地的写流程

写请求:
  1. 开启本地事务
  2. 更新业务表(同事务写一条本地消息记录)
  3. 提交事务
  4. 异步投递失效消息 → MQ
  5. 标记本地消息为已投递

失效消费:
  6. 收到消息,删除 Redis key
  7. 广播清除各实例本地缓存(可选,配合短本地 TTL)
  8. 失败按退避重试;超限进死信 + 告警

读请求(Cache Aside):
  9. 查本地缓存 → 查 Redis → 查库并回填(回填带短 TTL + 随机抖动)
 10. 若带有“刚写过”的会话标记,则跳过缓存直接读主库

小结:写路径统一用“更新库 + 删缓存”,窗口敏感时加延迟双删;把失效动作交给消息或 binlog 订阅,配上重试、死信和 TTL 兜底;对一致性的要求分级处理——普通数据接受最终一致,读己之写走主库,强一致数据不缓存。最后保留版本号、对账与命中率监控,出问题时才查得动。

笔记加载中…