缓存一致性:写库删缓存,删优于更——Java 工兵连第 17 篇
「Java 工兵连」第 17 篇,缓存篇下半场。上一篇的读路径管「不出事」,这篇的写路径管「不脏数据」——先更新数据库还是先动缓存,是每个用缓存的人绕不开的选择题。网上答案抄来抄去,这篇带你把四种组合逐一推演,让结论自己长出来。
一、先建直觉:便利店的价签
- 收银系统(DB)是真相源,货架价签(缓存)只是展示——价签可以旧,收银不能错;
- 顾客问价(读):看价签,没有价签就查收银系统,打一张新签贴上(懒加载回填);
- 改价(写)的正确动作:先改收银系统,再撕掉旧价签——下次顾客问价自然触发重打。
为什么不是「先撕签再改系统」?撕了签、顾客来问价,店员查的还是没改的旧系统,打出来的还是旧价签——白撕,脏数据反而被回填成了「正式价签」。
二、写路径四种组合,逐一推演
✗ 先删缓存,后更库——「白撕」
删完缓存到 DB 提交之间有个空窗:读请求 miss → 查到旧 DB 值 → 回填缓存 → DB 随后更新 → 缓存里躺着旧值,且没有到期就一直在。空窗不小(写事务的耗时),并发读一多必然踩中。pass。
✓ 先更库,后删缓存——标准答案(Cache Aside)
DB 提交后立刻删缓存。脏窗口只剩「读到旧缓存」到「删除生效」的毫秒级。理论上的脏场景长这样:读线程 miss → 读 DB 拿到旧值 →(此时写线程完成更新+删除)→ 读线程才回填旧值。注意这要求读线程的「查库」比写线程的「更新+删除」还慢——一个读几十毫秒、一个写几毫秒,交错概率极低,但不是零。所以还需要后面的兜底。
✗ 先更缓存,后更库——回滚地狱
缓存更新成功、DB 更新失败要回滚——缓存的回滚谁保证?网络一抖就是「缓存新值、DB 旧值」,而且这时的脏没有过期边界。分布式事务的复杂度全来了,不值。
✗ 先更库,后更新缓存——乱序覆盖
两个写并发:A、B 先后提交 DB(A→B),但「更新缓存」的两个请求网络乱序(B 先到)——缓存最终留下 A 的旧值,比删了还糟:更新是「最后到达者胜」,不是「最后提交者胜」。另有无用写放大:一个没人读的 key 也白写一次缓存。
四局推完,结论自己长出来了:写库删缓存,删优于更——删除幂等、天然懒加载、不怕乱序(多删一次无害)。
三、延迟双删与 TTL:两道兜底
延迟双删补的就是标准方案剩下的那个极低概率交错:写路径 = 更 DB → 删缓存 → 延迟 N 毫秒 → 再删一次。第二次删的目标很具体:那个「拿着旧值、正在回填路上」的慢读线程——N 取一次业务读的耗时(几百毫秒到 1 秒),异步执行(定时任务或延迟消息),千万别 Thread.sleep 堵在主流程里。
TTL 是最后防线,也是底线:任何方案、任何漏网之鱼,只要 key 带过期时间,脏数据就有界——最多脏到过期那一刻。反过来说:没有 TTL 的一致性方案都是裸奔,上面推演里每一个「永久脏」的事故,都因「无过期」而永久。
四、升级阶梯:按你能容忍的脏窗口挑
- 默认档:Cache Aside + 延迟双删 + TTL——脏窗口秒级以内,绝大多数业务够用;
- 顶配档:订阅 binlog 删缓存(Canal 伪装成 MySQL 从库,收到变更事件后异步删)——「删缓存」这个动作从业务代码里挪出去了:业务方忘了删?不存在;删除失败?消息重投。最终一致的工程化形态,代价是多一套组件;
- 强一致档只有一条路:不过缓存。要读时锁、写时排他的串行化,缓存的性能优势就被锁吃光了——缓存的一致性,本质是拿脏窗口换性能;需求要求零脏窗口(余额、库存扣减的判断路径),就直接读 DB,让缓存只服务「可以旧」的读。
老兵划重点:别把「余额扣减」这种强一致路径和「商品详情」这种可旧路径塞进同一套缓存策略——按数据分级,比按技术选型更重要。
老兵点评
- 一致性方案的选型问题,其实是「量化脏窗口」的问题。业务能容忍 1 秒旧价?Cache Aside+TTL 收工。能容忍 100ms?加延迟双删。一毫秒都不能?这份数据就不该过缓存。把容忍度说出来,方案自动唯一——这和第 15 篇「丢了多少能接受」是同一把尺子。
- Canal 方案最大的价值不是更快,是「删不丢」。业务代码删缓存最大的风险是「忘了删/删失败没重试」,binlog 订阅把删除变成可靠事件流——和你用 MQ 保证消息不丢是同一套思路(第 18 篇接着讲)。
- 面试段位差:初级背「先更数据库再删缓存」;中级能把四种组合的脏场景各推演一遍;高级讲延迟双删删的是谁、为什么更新缓存怕乱序、以及 Canal 的可靠删除链路——能把「为什么」讲成因果链的,永远是少数。
带走三句话
- 主口诀:写库删缓存,删优于更——删除幂等、懒加载、不怕乱序;「先删后更」是白撕,「更新缓存」怕并发乱序覆盖。
- 两道兜底:延迟双删清掉回填路上的慢读(N≈一次读耗时,异步做);TTL 让一切脏有界——没 TTL 的方案都是裸奔。
- 升级阶梯:Cache Aside → binlog 订阅(Canal,删不丢)→ 强一致的唯一出路是不过缓存;按数据分级比按技术选型更重要。
本篇是「Java 工兵连」第 17 篇 · 缓存篇完结。→ 上一篇:第 16 篇 · 缓存三件套:穿透 / 击穿 / 雪崩 | 继续阅读:第 18 篇 · Kafka 为什么快 | 浏览整个专栏 →。
❦ 终 ❦