缓存一致性工程实践
缓存上线后最常收到的一类反馈是“数据不对,刷新一下又好了”。这类问题几乎都出在写路径:数据库改了,缓存还是旧的。本章把写策略、失效链路和排查工具串成一套可落地的工程做法。
先更库还是先删缓存
写缓存有几种顺序,逐一分析:
| 写策略 | 过程 | 主要问题 |
|---|---|---|
| 先删缓存,再更新库 | 删 → 更新 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 重试 | 存在秒级窗口 | 详情页、列表、统计 |
| 读己之写 | 写后短时间该用户强制读主库/绕过缓存 | 主库读压力略增 | 下单后跳转详情 |
| 强一致 | 读也走主库、不缓存,或分布式锁 + 版本校验串行化 | 性能损失大 | 余额、库存精确值 |
多数业务取“最终一致 + 读己之写”的组合即可。真正要求强一致的数据,最省心的办法是不缓存。
排查不一致的工具
数据已经不一致了,怎么定位是谁写坏的?
- 版本号:写入时把
version(或updated_at)一起写进缓存值和数据库。读到的缓存值若版本低于库里的版本,说明缓存过期。 - 时间戳比对:缓存里带上写入时间,一旦发现缓存时间早于数据库
updated_at,就能确认是失效链路没走通。 - 对账任务:离线抽样比对缓存与库,统计不一致条数与类型,判断是“漏删”还是“回填旧值”。
- 监控回源量与命中率:命中率突降通常意味着大批缓存被删或失效;回源量飙升则是雪崩或热点。
-- 对账用:找出更新时间晚于缓存快照时间的记录,即疑似未失效
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 兜底;对一致性的要求分级处理——普通数据接受最终一致,读己之写走主库,强一致数据不缓存。最后保留版本号、对账与命中率监控,出问题时才查得动。