改完昵称,页面还是旧的
讲个 Weird 但真实的事。同事改了自己的昵称,后台显示保存成功,数据库里查也确实是新昵称。可他刷新十次页面,看到的还是旧的。更邪门的是:同一时刻,有人看到新昵称,有人看到旧昵称,还有人过了十分钟才「自动」看到新昵称,仿佛系统在掷骰子。
排查到最后,答案是「缓存没删干净」——但为什么删不干净、什么时候删、先删还是后删,才是这篇文章真正想聊的。缓存和数据库是两个独立系统,没有任何事务能把它们原子地绑在一起改。读写一并发,「改库」「写缓存」「读库」「读缓存」四个动作的先后顺序能排出十几种组合,其中大多数都是坑。
四种写法,三种是坑
写路径的顺序无非四种,先用一张表看结论,再挑两个典型的坑推演一遍:
| 写路径 | 并发下的问题 | 结论 |
|---|---|---|
| 先更新缓存,再更新库 | 库更新失败时缓存已是新值,两边永久不一致 | 排除 |
| 先更新库,再更新缓存 | 两个写请求并发,后完成的拿着旧数据覆盖新数据 | 排除 |
| 先删缓存,再更新库 | 删后改库前的窗口里,读请求把旧值回填,脏到 TTL 过期 | 不推荐 |
| 先更新库,再删缓存 | 理论上存在回填旧值的极小概率窗口 | 标准答案 |
推演第一种坑:先删缓存,再改库。请求 A 先把缓存删了,还没来得及改库;这时请求 B 进来读,未命中缓存,去查库拿到旧值;A 改库成功;B 慢悠悠地把刚查到的旧值回填进缓存。从此所有人都读到旧值,要脏就脏到 TTL 过期——这就是开头昵称事故的元凶。
那标准答案就没有漏洞吗?也有:请求 B 先查库拿到旧值,接着 A 改库、删缓存,最后 B 才把旧值回填。但凑齐这个时序的条件苛刻——B 的查库必须落在 A 改库之前,回填又必须落在 A 删缓存之后,也就是说 B 一次「读」要比 A 整个「写」还慢。读通常比写快几个量级,这个概率小到工程上可以忽略。两害相权,先改库再删缓存赢。
标准答案的三个细节
public void updateProduct(Product product) {
// 第一步:先改库
productMapper.updateById(product);
// 第二步:再删缓存。注意是删,不是改
redisTemplate.delete("product:detail:" + product.getId());
}方案本身五行代码,功夫全在细节里:
- 用删而不用改:并发写时「更新缓存」会互相覆盖,后提交的请求可能拿着旧数据把新数据刷掉;删是幂等的,删完下次读自然回填最新值,这正是第 2 篇讲过的懒加载思想。
- 事务提交后再删:方法上如果套着 @Transactional,删缓存要挂到事务提交之后执行。否则事务还没提交,读请求就又把旧值回填进去了。
- 删失败要有补偿:删缓存那一刻 Redis 恰好抖了一下,脏数据又要活到 TTL。把删除动作投进 MQ 或定时任务重试,比在业务代码里硬 catch 靠谱得多。
还有个「改库后直接更新缓存」的野路子,只适合写完必须立刻读到新值、且写并发极低的场景,一般业务别碰。
延迟双删:给极端时序上双保险
「先删缓存再改库」也不是一无是处,在读写分离的架构里它有翻身的理由:改库写到主库,读请求走从库,主从同步有几毫秒到几秒的延迟。此时哪怕你规规矩矩地「改库后删缓存」,读请求依然可能从还没同步的从库里捞出旧值回填。于是有了延迟双删:改完库等主从追平,再补一刀。
public void updateProductDoubleDelete(Product product) {
String key = "product:detail:" + product.getId();
// 第一次删:清掉现有缓存
redisTemplate.delete(key);
// 改库
productMapper.updateById(product);
// 异步延迟再删一次,兜住「删后旧值回填」的极端时序
delayExecutor.schedule(() -> redisTemplate.delete(key), 500, TimeUnit.MILLISECONDS);
}两个使用要点:第二次删除务必异步,别让主线程干等;延迟时长要略大于主从同步的耗时,通常几百毫秒到一秒,设太短等于没删。
更稳的玩法:订阅 binlog,异步删缓存
前面所有方案的共同痛点是:业务代码要「记得」删缓存,而人总会忘。更工程化的做法是让删缓存这件事彻底离开业务代码——用 Canal 这类组件监听 MySQL 的 binlog,把数据变更投进 MQ,消费端统一负责删缓存。改库一旦成功,binlog 里就一定有记录,一条都跑不掉。
// Canal 把 binlog 变更投进 MQ,消费端只做一件事:删缓存
@RabbitListener(queues = "cache-evict-queue")
public void onRowChanged(RowChangedMessage msg) {
if ("product".equals(msg.getTable())) {
redisTemplate.delete("product:detail:" + msg.getPk());
}
// 删失败就抛异常,MQ 会重试,最终一定删掉
}这套方案叫最终一致性:允许缓存和数据库在极短的窗口内不一致,但保证最终对齐。它换来了三个好处——业务代码只管改库、删除逻辑集中维护、MQ 重试天然兜底。绝大多数业务要的就是这个,而不是随时随地的强一致。
真要强一致怎么办?
只有极少数场景(库存扣减、余额)才真正需要强一致。办法要么上分布式锁把读写串行化,要么干脆别用缓存、直接读库。缓存的本质,就是拿「短暂不一致」换性能。想清楚这一点,选型就不再纠结:能容忍几百毫秒旧读的,用删缓存加 binlog;不能容忍的,加锁或者别缓存。
到这里,缓存的读路径三问(穿透、击穿、雪崩)和写路径的一致性问题都聊完了。下一篇进入系列下半场——每个后端都绕不开的分布式锁:SET NX EX 为什么还不够安全?解锁为什么必须用 Lua 脚本?Redlock 的争议又在哪里?下篇见。
咖啡凉了,记得趁热喝。
评论 (0)