连载中 7/16

缓存一致性:先改库还是先删缓存,一次讲透

2026-05-18 · 8361 阅读 · 0 评论 · 0 赞

改完昵称,页面还是旧的

讲个 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 的争议又在哪里?下篇见。

咖啡凉了,记得趁热喝。

503

10 年全栈工程师 · 503咖啡馆主理人

#Redis#缓存一致性#延迟双删#binlog#最终一致性

评论 (0)

相关推荐

连载中 12/20

排查四件套:jstack、jmap、jstat、jcmd 的实战分工

jstack 看线程在干什么,jmap 看堆里装了什么,jstat 看运行时在变什么,jcmd 是统一入口。四把刀各管一段,配合着用没有查不动的现场。

#jstack#jmap#jstat#jcmd#排查工具
2026-09-16 · 5 阅读 · 0 评论 · 0 赞
连载中 11/20

GC 日志:把回收过程翻译成人话

一行 GC 日志里塞着七种信息:谁触发的、收了哪、停了多久、活了哪些。加上 -Xlog 配置,再加上日志分析工具,GC 不再是只能盯监控曲线的黑盒。

#GC日志#Xlog#日志分析#GC监控#Full GC排查
2026-09-16 · 13 阅读 · 0 评论 · 0 赞
连载中 10/20

ZGC:亚毫秒停顿是怎么炼成的

百 G 大堆停顿不到一毫秒,靠的是把搬家全部挪到并发阶段——着色指针让引用自带状态,读屏障让搬运中的对象依然可访问。代价是吞吐与内存,收益是停顿与堆大小解耦。

#ZGC#着色指针#读屏障#亚毫秒停顿#分代ZGC
2026-09-15 · 8 阅读 · 0 评论 · 0 赞