文章目录
- 一、先把读写流程定死
- 二、四种写顺序逐个推演
- 方案 1:先更新数据库,再更新缓存
- 方案 2:先更新数据库,再删除缓存(Cache Aside,推荐)
- 方案 3:先删除缓存,再更新数据库
- 方案 4:先更新缓存,再更新数据库
- 四种方案横向对比
- 三、为什么"先更库再删缓存"仍然会不一致
- 场景 1:读请求在"删缓存"之前拿到旧值,在之后回填
- 场景 2:删缓存失败
- 场景 3:读写分离 + 复制延迟
- 四、延迟双删:能降低概率,但不解决问题
- 五、让不一致能自愈的三层兜底
- 第 1 层:删除失败必须重试
- 第 2 层:用 binlog 订阅替代"业务里删缓存"
- 第 3 层:TTL 是最终的一致性保证
- 六、多级缓存别忘了本地缓存
- 七、怎么选:按业务容忍度决策
- 小结
"先删缓存还是先更新数据库"这个问题在面试里被问烂了,但真正落到线上,踩的往往不是选错顺序,而是以为选对了顺序就不会不一致。
先把结论摆出来:缓存和数据库是两个独立的存储,中间没有事务。除非你放弃缓存、或者上分布式事务,否则一致性只能"把窗口缩到业务能接受的程度",做不到消除。所以下面所有的讨论,本质都是在回答一句话:脏数据的窗口有多大、会持续多久、失败之后能不能自愈。
一、先把读写流程定死
后面四种方案都基于同一套流程,先约定清楚,避免歧义:
读流程(Cache Aside): 1. GET cache:key → 命中直接返回 2. 未命中 → 查数据库 3. 把结果 SET 回缓存(设 TTL) 4. 返回 写流程(四种方案的差别就在这两步的顺序): A. 操作数据库 B. 操作缓存(更新 or 删除)关键点:读流程里有"回填"这一步。所有不一致的根源几乎都能归结为——写请求在动数据库/缓存的同时,读请求把旧值回填了进去。
二、四种写顺序逐个推演
方案 1:先更新数据库,再更新缓存
两个并发写请求,时序交错:
| 时刻 | 请求 A(要写入值 2) | 请求 B(要写入值 3) | 库里 | 缓存里 |
|---|---|---|---|---|
| T1 | UPDATE 库 = 2 | 2 | 旧值 1 | |
| T2 | UPDATE 库 = 3 | 3 | 旧值 1 | |
| T3 | SET 缓存 = 3 | 3 | 3 | |
| T4 | SET 缓存 = 2 | 3 | 2(脏) |
后果是长期的:缓存里是 2,库里是 3,而缓存不会自己变回 3(除非等 TTL 过期)。这种"写覆盖乱序"是更新缓存方案最致命的地方。
另外还有两个附加问题:如果缓存的 value 是多次计算拼出来的,每次写都重算一遍很浪费(写多读少时纯亏);缓存不是可靠存储,可能被淘汰/宕机,写进去了也可能没了。
方案 2:先更新数据库,再删除缓存(Cache Aside,推荐)
还是并发写,但因为"删除"是幂等的,乱序不会造成长期脏数据:
| 时刻 | 请求 A(写入 2) | 请求 B(写入 3) | 库里 | 缓存里 |
|---|---|---|---|---|
| T1 | UPDATE 库 = 2 | 2 | 旧值 1 | |
| T2 | UPDATE 库 = 3 | 3 | 旧值 1 | |
| T3 | DEL 缓存 | 3 | 空 | |
| T4 | DEL 缓存 | 3 | 空 |
无论谁先谁后,最后缓存都是空的,下一次读会回填 3。这就是它成为主流方案的原因:删除是幂等的,乱序不产生长期脏数据。
但它仍然有一个"读请求插队"的窗口,见第四节。
方案 3:先删除缓存,再更新数据库
这个方案的窗口比想象中大得多:
| 时刻 | 写请求 | 读请求 | 库里 | 缓存里 |
|---|---|---|---|---|
| T1 | DEL 缓存 | 1(旧) | 空 | |
| T2 | 查库 → 1 →回填 1 | 1 | 1(旧的) | |
| T3 | UPDATE 库 = 2 | 2 | 1(脏) |
注意 T2 和 T3 之间的间隔是整个写库耗时(包含事务、索引更新、甚至这条 SQL 的排队时间)。也就是说,"读请求在这段时间里插进来"的概率远高于方案 2 里那个极短的窗口。挂掉的后果同样是长期脏数据。
所以顺序推荐是明确的:先更新库,再删缓存。
方案 4:先更新缓存,再更新数据库
基本不能用:缓存写成功、数据库失败时,缓存里是新值、库里是旧值,而且缓存无法回滚;反过来若先成功数据库也救不了缓存侧。除非有补偿机制,否则别选。
四种方案横向对比
| 方案 | 脏数据窗口 | 会不会长期不一致 | 实现成本 | 推荐度 |
|---|---|---|---|---|
| 先更库 → 更缓存 | 并发写错序 | 会,直到 TTL 过期 | 低 | ❌ |
| 先更库 → 删缓存 | 读请求插队 | 窗口内短暂不一致 | 低 | ✅ 首选 |
| 先删缓存 → 更库 | 整个写库耗时 | 很可能 | 低 | ❌ |
| 先更缓存 → 更库 | 写失败即不一致 | 会 | 高(需补偿) | ❌ |
三、为什么"先更库再删缓存"仍然会不一致
场景 1:读请求在"删缓存"之前拿到旧值,在之后回填
| 时刻 | 线程 A(写) | 线程 B(读) |
|---|---|---|
| T1 | GET 缓存未命中 | |
| T2 | 查库,读到旧值 1 | |
| T3 | UPDATE 库 = 2 | |
| T4 | DEL 缓存 | |
| T5 | SET 缓存 =1(旧的) |
结果缓存里是很旧的 1。它发生的条件是:读请求查库这一步比写请求的"更新库 + 删缓存"更慢。现实中这个概率不高——写库通常要拿锁、写 undo、刷 binlog,比一次主键查询慢得多——但概率低不等于不会,尤其是大事务、慢 SQL、从库读取的场景。
场景 2:删缓存失败
DEL超时、Redis 抖动、主从切换都会让删除失败。而失败之后没有任何机制会再来删一次,脏数据就一直在那儿,直到 TTL 到期。这是线上最常见的一种"偶发不一致",也是最容易被忽略的——很多人只写了redisTemplate.delete(key),连返回值都没看。
场景 3:读写分离 + 复制延迟
先更新主库 → 删缓存 → 读请求落到从库,从库还没同步完,读到旧值并回填。此时缓存里是旧值,且会持续到 TTL。如果你有读写分离,这就是必须一起考虑的因素。
四、延迟双删:能降低概率,但不解决问题
标准写法是"删一次、改库、睡一会儿、再删一次":
publicvoidupdateOrder(Orderorder){Stringkey="order:"+order.getId();redis.delete(key);// 第一次:清掉旧值orderMapper.updateById(order);// 更新数据库executor.schedule(()->{// 第二次:延迟再删一次redis.delete(key);},800,TimeUnit.MILLISECONDS);}它解决的是场景 1:把"读请求在窗口内回填的旧值"再清一次。但要用对,必须回答三个问题:
- 延迟多久?理论要求大于"一次读请求(查库 + 回填)"的耗时。经验值是 500ms~1s,但它没有任何保证——慢查询、GC、网络抖动都可能超过这个值。
- 能不能 sleep?不要在业务线程里
Thread.sleep,会占着 Tomcat 线程和数据库连接。用延迟队列、ScheduledExecutorService或 MQ 延时消息。 - 第二次删除失败怎么办?如果不重试,等于没做。
所以延迟双删是"概率优化",不是"一致性方案"。真正让系统自愈的是下面三件事。
五、让不一致能自愈的三层兜底
第 1 层:删除失败必须重试
把"要删的 key"落一条本地消息表(或直接投递 MQ),删除成功再标记完成;失败就重投。要点是删除操作要幂等且可重放,这也是"删除"比"更新缓存"更好用的原因。
publicvoidevictWithRetry(Stringkey){for(inti=0;i<3;i++){// 立即重试几次try{redis.delete(key);return;}catch(Exceptione){sleep(50L<<i);// 50/100/200ms 退避}}mq.send("cache-evict",key);// 还失败就交给 MQ 重投}第 2 层:用 binlog 订阅替代"业务里删缓存"
这是我认为最值得投入的一层:把删缓存的时机从业务代码挪到数据库变更事件上。
业务写入 → MySQL 提交 → binlog → Canal/Debezium 解析 → 投递到 MQ → 消费者按顺序删除对应缓存 key好处很实在:删除动作与业务解耦(业务只负责写库,不用记得删缓存)、binlog 是有序且完整的(不会因为代码分支漏删)、失败可以重放(MQ 有重试和死信)、还能顺手处理多级缓存和搜索索引的同步。
代价是需要维护一个中间件、要处理"缓存删除的延迟"(binlog 解析到消费有几十到几百毫秒),所以仍然建议配合 TTL。
第 3 层:TTL 是最终的一致性保证
无论前面几层多完善,一定要给缓存设置过期时间。TTL 是"最坏情况下多久能自愈"的硬承诺:
- 强一致要求(如账户余额):TTL 设短(几秒),甚至考虑不走缓存;
- 一般商品/详情类:TTL 几分钟到几十分钟,配合上面的兜底足够;
- 静态字典类:TTL 可以很长,甚至主动预热。
没有 TTL 的缓存 + 一次删除失败 = 永久脏数据。这是我见过最贵的一个"省事"。
六、多级缓存别忘了本地缓存
加了 Caffeine 之后,DEL Redis对本地缓存无效,本地缓存可能长时间提供旧值。可行做法:
- 广播失效:通过 Redis Pub/Sub 或 MQ 广播"key 失效"消息,各实例清本地缓存(注意消息丢失时靠 TTL 兜底);
- 极短 TTL:本地缓存只放 1~5 秒,把不一致窗口控制在秒级;
- 版本号:本地缓存里存一个全局版本号,写操作递增版本,读时校验版本是否落后。
七、怎么选:按业务容忍度决策
| 业务对不一致的容忍度 | 建议方案 |
|---|---|
| 完全不能容忍(金额、库存扣减) | 不用缓存或只做只读缓存;必须走数据库 + 分布式锁/乐观锁 |
| 秒级可容忍(订单详情、用户资料) | 先更库再删缓存 + 删除重试 + 短 TTL(几十秒) |
| 分钟级可容忍(商品列表、文章内容) | Cache Aside + TTL 几分钟 + binlog 订阅兜底 |
| 小时级可容忍(配置、字典) | TTL 长一些,定期主动刷新即可 |
小结
- 顺序选"先更新数据库、再删除缓存":删除幂等,乱序不产生长期脏数据,"先删缓存再更库"的窗口是整整一个写库耗时,明显更差。
- 单靠顺序解决不了一致性,必须配三样东西:删除失败重试、binlog 订阅兜底、TTL。
- 延迟双删只是降概率,延迟时间没有理论保证,别把它当成一致性方案。
- 记住那句判断标准:缓存一致性的问题不是"会不会不一致",而是"不一致能持续多久、能不能自愈"。