
缓存一致性为什么必须先更新数据库再删缓存延迟双删又是干嘛的作者鱼宵 实战驱动系列 · 第 2 篇完整课程与可运行源码已开源在 Giteehttps://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→ 删缓存的正确顺序再来一遍观察为什么这次最终能自愈。七、挑战题答案都在仓库里跑起来才知道⭐把延迟双删的延迟时间改成 0等于第二次删紧跟第一次思考在什么极端时序下依然会残留旧值提示读请求的查库→写回耗时超过你的两次删缓存间隔⭐⭐ 用 redis-cli 模拟缓存 miss 时并发两个读请求一个读到旧值、一个读到新值后写回缓存的是哪个提示写回顺序不保证⭐⭐⭐ 去仓库看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 EXLua 解锁又是干嘛的》已发布讲的是多机器抢同一个资源怎么加锁——和本文是姊妹篇都出自 lesson-05后续系列陆续发布Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch下一篇预告《Redis 事务MULTI/EXEC 为什么不支持回滚Lua 原子扣减又是怎么做到的》——两个线程并发扣余额非原子写法扣成负数Lua 写法分毫不差同样可以亲手验证。跑完上面任何一步遇到问题把终端输出发评论区一起排查。