☰
Redis 缓存与数据库一致性:先删缓存还是先更新库的 4 种方案
2026/10/9 10:25:47 网站建设 项目流程
个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
其他栏目: > mysql <



文章目录

    • 一、先把读写流程定死
    • 二、四种写顺序逐个推演
      • 方案 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)库里缓存里
T1UPDATE 库 = 22旧值 1
T2UPDATE 库 = 33旧值 1
T3SET 缓存 = 333
T4SET 缓存 = 232(脏)

后果是长期的:缓存里是 2,库里是 3,而缓存不会自己变回 3(除非等 TTL 过期)。这种"写覆盖乱序"是更新缓存方案最致命的地方。

另外还有两个附加问题:如果缓存的 value 是多次计算拼出来的,每次写都重算一遍很浪费(写多读少时纯亏);缓存不是可靠存储,可能被淘汰/宕机,写进去了也可能没了。

方案 2:先更新数据库,再删除缓存(Cache Aside,推荐)

还是并发写,但因为"删除"是幂等的,乱序不会造成长期脏数据:

时刻请求 A(写入 2)请求 B(写入 3)库里缓存里
T1UPDATE 库 = 22旧值 1
T2UPDATE 库 = 33旧值 1
T3DEL 缓存3空
T4DEL 缓存3空

无论谁先谁后,最后缓存都是空的,下一次读会回填 3。这就是它成为主流方案的原因:删除是幂等的,乱序不产生长期脏数据。

但它仍然有一个"读请求插队"的窗口,见第四节。

方案 3:先删除缓存,再更新数据库

这个方案的窗口比想象中大得多:

时刻写请求读请求库里缓存里
T1DEL 缓存1(旧)空
T2查库 → 1 →回填 111(旧的)
T3UPDATE 库 = 221(脏)

注意 T2 和 T3 之间的间隔是整个写库耗时(包含事务、索引更新、甚至这条 SQL 的排队时间)。也就是说,"读请求在这段时间里插进来"的概率远高于方案 2 里那个极短的窗口。挂掉的后果同样是长期脏数据。

所以顺序推荐是明确的:先更新库,再删缓存。

方案 4:先更新缓存,再更新数据库

基本不能用:缓存写成功、数据库失败时,缓存里是新值、库里是旧值,而且缓存无法回滚;反过来若先成功数据库也救不了缓存侧。除非有补偿机制,否则别选。

四种方案横向对比

方案脏数据窗口会不会长期不一致实现成本推荐度
先更库 → 更缓存并发写错序会,直到 TTL 过期低❌
先更库 → 删缓存读请求插队窗口内短暂不一致低✅ 首选
先删缓存 → 更库整个写库耗时很可能低❌
先更缓存 → 更库写失败即不一致会高(需补偿)❌

三、为什么"先更库再删缓存"仍然会不一致

场景 1:读请求在"删缓存"之前拿到旧值,在之后回填

时刻线程 A(写)线程 B(读)
T1GET 缓存未命中
T2查库,读到旧值 1
T3UPDATE 库 = 2
T4DEL 缓存
T5SET 缓存 =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:把"读请求在窗口内回填的旧值"再清一次。但要用对,必须回答三个问题:

  1. 延迟多久?理论要求大于"一次读请求(查库 + 回填)"的耗时。经验值是 500ms~1s,但它没有任何保证——慢查询、GC、网络抖动都可能超过这个值。
  2. 能不能 sleep?不要在业务线程里Thread.sleep,会占着 Tomcat 线程和数据库连接。用延迟队列、ScheduledExecutorService或 MQ 延时消息。
  3. 第二次删除失败怎么办?如果不重试,等于没做。

所以延迟双删是"概率优化",不是"一致性方案"。真正让系统自愈的是下面三件事。

五、让不一致能自愈的三层兜底

第 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对本地缓存无效,本地缓存可能长时间提供旧值。可行做法:

  1. 广播失效:通过 Redis Pub/Sub 或 MQ 广播"key 失效"消息,各实例清本地缓存(注意消息丢失时靠 TTL 兜底);
  2. 极短 TTL:本地缓存只放 1~5 秒,把不一致窗口控制在秒级;
  3. 版本号:本地缓存里存一个全局版本号,写操作递增版本,读时校验版本是否落后。

七、怎么选:按业务容忍度决策

业务对不一致的容忍度建议方案
完全不能容忍(金额、库存扣减)不用缓存或只做只读缓存;必须走数据库 + 分布式锁/乐观锁
秒级可容忍(订单详情、用户资料)先更库再删缓存 + 删除重试 + 短 TTL(几十秒)
分钟级可容忍(商品列表、文章内容)Cache Aside + TTL 几分钟 + binlog 订阅兜底
小时级可容忍(配置、字典)TTL 长一些,定期主动刷新即可

小结

  1. 顺序选"先更新数据库、再删除缓存":删除幂等,乱序不产生长期脏数据,"先删缓存再更库"的窗口是整整一个写库耗时,明显更差。
  2. 单靠顺序解决不了一致性,必须配三样东西:删除失败重试、binlog 订阅兜底、TTL。
  3. 延迟双删只是降概率,延迟时间没有理论保证,别把它当成一致性方案。
  4. 记住那句判断标准:缓存一致性的问题不是"会不会不一致",而是"不一致能持续多久、能不能自愈"。

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

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

立即咨询