☰
缓存一致性:为什么必须“先更新数据库,再删缓存”?延迟双删又是干嘛的?
2026/10/6 2:52:39 网站建设 项目流程

缓存一致性:为什么必须"先更新数据库,再删缓存"?延迟双删又是干嘛的?

作者:鱼宵 | 实战驱动系列 · 第 2 篇

完整课程与可运行源码已开源在 Gitee:https://gitee.com/j67mk2/redis-journey (10 课实战教程,本课源码在 lesson-05/)


一、一个真实场景:奶茶店的账本和小黑板

想象你开了一家奶茶店:

  • 数据库= 后厨的账本(记录真实库存,很权威,但查起来慢)
  • Redis 缓存= 前台的小黑板(写着急着卖的畅销数字,读得快)

客人来问"还有几杯珍珠奶茶?"——店员懒得跑后厨翻账本,直接看小黑板,10 毫秒就答上来了。这就是缓存存在的意义:读多写少的数据,先放一份在 Redis,读的时候不走数据库。

但问题来了:后厨在账本上改了数字,前台小黑板没来得及擦掉重写,客人看到的还是旧数字——缓存和数据库对不上了。

为什么会这样?因为"写数据库"和"更新缓存"是两步,两步之间只要插进别的请求(并发交错),就会出现各种奇奇怪怪的时序事故。这篇文章就把最常见的几种方案讲透。

一句话:缓存一致性,解决的就是"账本改了、黑板没改"的问题。


二、主流方案:Cache Aside(旁路缓存)

这是工业界默认的做法,读和写分别有固定套路:

读请求: 查 Redis ──有──> 直接返回(缓存命中,美滋滋) │ 没有 └──> 查数据库 ──> 把结果写进 Redis ──> 返回 写请求: 更新数据库 ──> 删除缓存(注意:是删,不是更新!)

两个关键点,面试必问:

1. 写的时候为什么是"删缓存",而不是"更新缓存"?

  • 缓存的值往往要经过一堆计算才得出(比如要 join 好几张表、做统计),写请求里再算一遍纯属浪费;
  • 而且改完的数据不一定马上有人读,干脆删掉,等下次有人读时再"回源重建"(懒加载)。删 = 省事 + 省力。

2. 为什么是"先更新数据库,再删缓存",而不是反过来?

这是全文最核心的考点,下一节单独讲。


三、核心考点:为什么不能"先删缓存,再更新数据库"?

我们把这个错误顺序的并发交错写出来,亲眼看看它怎么出事的:

时刻 线程A(写) 线程B(读) T1 删除缓存 T2 缓存未命中 → 去查数据库 → 读到【旧值】 T3 更新数据库为【新值】 T4 把【旧值】写回缓存 ←💥 完了!

结果:数据库已经是新值,缓存却被写回了旧值,而且只要缓存不过期,以后一直读旧值——脏数据长期存在。

这就是经典的"读旧值写回"事故:线程 A 删完缓存,还没来得及更新数据库,线程 B 的读请求已经穿透到数据库拿到了旧值;等 A 更新完库,B 慢悠悠地把旧值写回缓存,把 A 刚删的空位又填上了旧货。

那"先更新数据库,再删缓存"呢?同样有时序问题,但窗口小得多:

时刻 线程A(写) 线程B(读) T1 更新数据库为【新值】 T2 缓存命中(还是旧值)→ 直接返回旧值 T3 删除缓存

看,在"更新完库 → 删缓存"之间,确实可能有一个读请求命中旧缓存、返回旧值。但这个窗口只有一瞬间(删缓存是毫秒级操作),而且下一次读就会因为缓存被删而回源拿到新值,旧值自然被覆盖——它是"自愈"的。

结论(背下来):先删缓存再更新库,脏数据长期存在;先更新库再删缓存,最多短暂读到旧值,下次读自愈。两害相权取其轻,工业界选后者。


四、延迟双删:给上面的方案"上保险"

"先库后删"虽然自愈,但极端并发下仍可能残留脏值。于是有了延迟双删:

1. 先更新数据库; 2. 先删一次缓存; 3. 延迟一小段时间(几百毫秒 ~ 1-2 秒); 4. 再删一次缓存。

为什么要延迟?为了"等"刚才那个"读旧值又写回缓存"的倒霉线程把旧值写完——它写完了,我们再补删一次,把它刚写回的旧值清掉。

延迟多久?要略大于一次读请求的耗时(读 = 缓存未命中 → 查库 → 写回缓存 的完整链路)。一般几百毫秒就够,具体看你接口压测出来的 RT(响应时间)。延迟太短等于没删(旧值还没写完你就删了,删了个寂寞),太长用户体验差。

一句话记忆:延迟双删 = 先库后删 + 多删一次,专治"读旧值写回"这个漏网之鱼。


五、更彻底的方案:binlog 订阅(Canal)

有没有办法让业务代码完全不用管删缓存?有——阿里开源的Canal:

业务代码:只更新数据库,完事。 Canal: 偷偷监听数据库的 binlog(变更日志), 数据库一改,它立刻收到通知,自动去删对应的缓存。

相当于雇了一个"监听员"盯着账本,账本一改他立刻去擦黑板,业务代码彻底解脱。很多大厂的缓存一致性基建就是这个思路。了解即可,面试能说出这个方向就够加分。


六、动手验证:亲手复现"先删缓存"的事故(2 个终端)

不需要 Java,开两个终端 + redis-cli 就行。模拟上面 T1~T4 的并发交错:

# 终端 1:先造点数据redis-cli SET product:1001 stock99# 数据库/缓存初始:99redis-cli GET product:1001# → 99# ===== 模拟并发交错 =====# 终端 1(线程A,写):T1 删缓存、T3 更新库redis-cli DEL product:1001# T1 先删缓存redis-cli SET product:1001 stock98# T3 更新数据库为新值 98# 终端 2(线程B,读):T2 读旧值、T4 写回缓存redis-cli GET product:1001# T2 缓存未命中,查库(假设读到旧值 99,你从 DB 查)redis-cli SET product:1001 stock99# T4 把旧值 99 写回缓存 ←💥

最后GET product:1001看到什么?是 98 还是 99?

亲手跑一遍你就永远记住了:缓存里是 99(旧值),而数据库是 98(新值)——脏数据出现了。这就是线上事故的完整复现,比背十遍概念都管用。

跑完可以继续挑战:按"先更新库(98)→ 删缓存"的正确顺序再来一遍,观察为什么这次最终能自愈。


七、挑战题(答案都在仓库里,跑起来才知道)

  1. ⭐把延迟双删的延迟时间改成 0(等于第二次删紧跟第一次),思考:在什么极端时序下依然会残留旧值?(提示:读请求的"查库→写回"耗时超过你的两次删缓存间隔)
  2. ⭐⭐ 用 redis-cli 模拟:缓存 miss 时并发两个读请求,一个读到旧值、一个读到新值,后写回缓存的是哪个?(提示:写回顺序不保证)
  3. ⭐⭐⭐ 去仓库看lesson-05/DistributedLock.java,思考:为什么"删缓存"和"释放分布式锁"都不能用"先判断、再删除"的两步写法?(共同点:都要原子操作——Lua 就是答案)

八、面试回答模板(背下来)

面试官:缓存和数据库怎么保持一致性?

默认用 Cache Aside:读 = 先查缓存、miss 再查库并回填;写 = 先更新数据库、再删缓存(删而不是更新,省计算且懒加载)。

为什么不先删缓存?因为"删完缓存 → 更新完库"之间,读请求会把旧值写回缓存,导致脏数据长期存在;而"先库后删"即使短暂读到旧值,下次读也会自愈。

极端并发下可加延迟双删:更新库 → 删缓存 → 延迟(略大于一次读耗时)→ 再删一次,兜住"读旧值写回"的窗口。更彻底的是 Canal 订阅 binlog 自动删缓存,业务无侵入。


九、总结

方案核心做法解决什么
Cache Aside读:先查缓存再回源;写:先更库再删缓存一致性默认方案
先库后删更新库 → 删缓存(顺序不能反)避免脏数据长期存在
延迟双删更新库 → 删 → 延迟 → 再删兜住"读旧值写回"窗口
Canal订阅 binlog 自动删缓存业务无侵入

一句话记忆:写缓存永远"先库后删";怕极端并发就"延迟双删";想省事就上 Canal。


十、关于这个系列

本文是「Java 后端实战精通营」系列第 2 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉Redis 实战精通营(10 课):https://gitee.com/j67mk2/redis-journey

  • 本文对应源码位置:lesson-05/(缓存一致性概念 + 分布式锁完整工程,挑战题 3 的答案就在DistributedLock.java里)
  • 第 1 篇《Redis 分布式锁:为什么必须用 SET NX EX?Lua 解锁又是干嘛的?》已发布,讲的是"多机器抢同一个资源怎么加锁"——和本文是姊妹篇,都出自 lesson-05
  • 后续系列(陆续发布):Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch

下一篇预告:《Redis 事务:MULTI/EXEC 为什么不支持回滚?Lua 原子扣减又是怎么做到的?》——两个线程并发扣余额,非原子写法扣成负数,Lua 写法分毫不差,同样可以亲手验证。

跑完上面任何一步遇到问题,把终端输出发评论区,一起排查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询