缓存一致性实战:先更新库还是先删缓存?延迟双删与最终一致
“缓存和数据库不一致"不是 bug,是设计决策——你要选的是"不一致多久、谁来兜底”。
用户改了昵称,列表页刷出来还是旧的;评论区删了内容,详情页还能看见——这类工单的根源都一样:缓存与数据库的一致性策略没想清楚。这篇把四种主流方案摆在一起对比,讲清各自的坑和适用场景,最后给你一套我实际在用的组合拳。
一、先把问题拆开:为什么会有不一致
缓存的读写路径天然是两步操作(缓存一次、数据库一次),而这两步之间没有任何原子性保障。不一致的窗口 = 两次操作之间的时间差 + 任何一方的失败。设计缓存策略,本质上是在回答三个问题:
先把一个执念放下:强一致基本做不到(除非缓存和库在同一事务里,那性能也就没了)。生产上追求的是最终一致 + 窗口尽量小 + 有兜底。
二、四种方案逐个拆
方案 1:先更新数据库,再更新缓存 ❌
直觉方案,但有两个致命问题:
- 并发写导致脏数据:线程 A 更新库(price=100) → 线程 B 更新库(price=200) → B 更新缓存(200) → A 更新缓存(100)。库里是 200,缓存里是 100,且再也不会被纠正(直到过期)。
- 写多读少时白算:每次写都更新缓存,但这个值可能根本没人读。
结论:别用"更新缓存",写操作只做失效(删除),把计算推迟到下一次读。
方案 2:先删除缓存,再更新数据库 ❌
并发下的经典灾难:
读线程(缓存未命中) 写线程
│ │
├─ 查缓存 miss │
├─ 查库,得旧值 100 │
│ ├─ 删缓存
│ └─ 更新库 → 200
└─ 把旧值 100 写回缓存 ← 脏数据驻留直到过期
写库和"读旧值回填"赛跑,读线程赢了就脏了。用得少,除非配合下面的延迟双删。
方案 3:Cache-Aside(先更新库,再删缓存)✅ 默认选择
这是绝大多数业务的正确答案:
# 读
def get(key):
v = redis.get(key)
if v is not None:
return v
v = db.query(key)
redis.setex(key, ttl, v) # 回填带过期时间
return v
# 写
def update(key, value):
db.update(key, value) # 1. 先更新数据库
redis.delete(key) # 2. 再删除缓存
它也会有不一致的窗口(写库成功、删缓存失败;或读线程在删除后瞬间回填旧值),但窗口是毫秒级且被下一个 TTL 兜底。把它的两个残余风险补掉:
- 删缓存失败 → 删除操作放进重试队列(本地重试 3 次 → 仍失败发消息队列异步重试),保证最终删掉。
- 读写并发回填旧值 → 窗口极小(要求"读请求在写库前查库、在删缓存后回填"这个交错),加上 TTL 30 分钟兜底,绝大多数业务可以接受。不能接受的,用方案 4。
方案 4:延迟双删(对付"先删后更"或高并发回填)
对方案 2 的补丁,也可用于写后读敏感场景:
def update(key, value):
redis.delete(key) # 第一次删
db.update(key, value) # 更新库
time.sleep(0.5) # 等"正在飞的旧读"全部回填完
redis.delete(key) # 第二次删,清掉可能被回填的旧值
sleep 的时间 = 读请求的回填窗口(一般几百毫秒,按你服务的 P99 读耗时定)。生产上这个延迟通常不用线程睡,而是丢进延迟队列异步执行,避免占着写请求的时间。
三、TTL 是最后的兜底,不是可选项
任何策略都会有漏网之鱼,TTL 是最终防线。设置原则:
| 配置类、基本不改 | 1 小时 ~ 1 天 | 长缓存 + 主动失效 |
| 用户资料、商品详情 | 5 ~ 30 分钟 | 短 TTL 减少不一致窗口 |
| 排行榜、计数类 | 30 秒 ~ 5 分钟 | 允许短暂不准,或走定时重算 |
| 实时性要求极高 | 不缓存或极短 TTL | 直读库 + 库侧优化 |
同一个 key 的 TTL 加随机抖动(ttl + random(0, 60)),防止大量 key 同时过期造成缓存雪崩——这是另一个话题,但写策略时顺手就做了。
四、进阶:靠 binlog 驱动的最终一致(订阅变更)
手动删缓存有个心累的地方:每个写入口都要记得删,漏一处就是线上事故。更工程化的做法是把"删缓存"从业务代码里拿走,交给数据库变更流:
业务只写数据库 → Canal/Debezium 订阅 binlog → 变更消息进队列 → 消费者删除对应缓存
好处:业务代码零侵入、不会漏删、天然有重试。代价:多一套订阅链路要维护。数据一致性敏感、写入口分散(多个服务写同一张表)的项目值得上;单体小项目,Cache-Aside + TTL 足够。
五、一次真实事故的复盘
用户反馈"修改头像后,详情页头像还是旧的,要等半小时"。排查:写入口走了"更新库 + 删缓存",但详情页读的是另一个 key(user:detail:{id},聚合了昵称头像),写入口只删了 user:{id}。修复两层:
教训:失效不是删一个 key,是失效"关于这条数据的所有缓存视图"。把 key 的清单和失效函数写在一起,改数据模型时同步改失效逻辑,这是缓存设计里最重要的纪律。
六、决策速查表
| 一般业务(读多写少) | Cache-Aside(先更库后删缓存)+ TTL 5~30 分钟 |
| 删除失败不能忍 | 删除进重试队列,最终一致 |
| 写后读不能容忍旧值 | 延迟双删 / 写后短暂回源直读库 |
| 写入口分散、多服务 | binlog 订阅(Canal/Debezium)统一失效 |
| 计数、排行榜 | 短 TTL 或定时任务重算,放弃实时一致 |
七、总结
缓存一致性三句话:写操作只删不改;先更库后删缓存是默认答案;TTL 是永远在岗的兜底。剩下一件事是纪律——把"哪些缓存依赖这份数据"写进失效函数,而不是散落在每个接口里。下次再收到"改了怎么还是旧的"的工单,按这张速查表对号入座,十分钟内能定位到是哪一环。



