Redis 缓存与数据库一致性:先删缓存还是先更新库的 4 种方案

个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
其他栏目: > mysql <




文章目录

"先删缓存还是先更新数据库"这个问题在面试里被问烂了,但真正落到线上,踩的往往不是选错顺序,而是以为选对了顺序就不会不一致。

先把结论摆出来:缓存和数据库是两个独立的存储,中间没有事务 。除非你放弃缓存、或者上分布式事务,否则一致性只能"把窗口缩到业务能接受的程度",做不到消除。所以下面所有的讨论,本质都是在回答一句话:脏数据的窗口有多大、会持续多久、失败之后能不能自愈。

一、先把读写流程定死

后面四种方案都基于同一套流程,先约定清楚,避免歧义:

text 复制代码
读流程(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。如果你有读写分离,这就是必须一起考虑的因素。

四、延迟双删:能降低概率,但不解决问题

标准写法是"删一次、改库、睡一会儿、再删一次":

java 复制代码
public void updateOrder(Order order) {
    String key = "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),删除成功再标记完成;失败就重投。要点是删除操作要幂等且可重放,这也是"删除"比"更新缓存"更好用的原因。

java 复制代码
public void evictWithRetry(String key) {
    for (int i = 0; i < 3; i++) {                 // 立即重试几次
        try {
            redis.delete(key);
            return;
        } catch (Exception e) {
            sleep(50L << i);                      // 50/100/200ms 退避
        }
    }
    mq.send("cache-evict", key);                  // 还失败就交给 MQ 重投
}

第 2 层:用 binlog 订阅替代"业务里删缓存"

这是我认为最值得投入的一层:把删缓存的时机从业务代码挪到数据库变更事件上。

text 复制代码
业务写入 → 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. 记住那句判断标准:缓存一致性的问题不是"会不会不一致",而是"不一致能持续多久、能不能自愈"。
相关推荐
在繁华处1 小时前
1.2 Harness 工程:模型之外的竞争
数据库
云贝贝贝1 小时前
【无标题】TDSQL 分片键怎么选?选错等于数据倾斜加跨分片慢查询
运维·服务器·数据库·腾讯云
IvorySQL1 小时前
VACUUM FULL 之后 ROWID 就废了? IvorySQL 兼容性实测
数据库·人工智能·ai·postgresql·开源
A.说学逗唱的Coke1 小时前
【人工智能专题】PolarDB 深度解析:存算分离到 AI Lakebase,云原生数据库的架构演进与实战指南
数据库·人工智能·云原生
我不会起名字3221 小时前
InnoDB 间隙锁与死锁:两个 UPDATE 互相等待的 3 个原因
数据库·mysql·事务·innodb·锁
ClickHouseDB1 小时前
ClickHouse Terraform Provider 正式支持 ClickStack 资源管理
网络·数据库·python
步行cgn1 小时前
Spring 事务属性详解
java·数据库·spring
我滴老baby1 小时前
Navidrome:飞牛OS安装、曲库导入与远程收听全流程
开发语言·数据库·php
@#¥&~是乱码鱼啦2 小时前
ArkWeb开发手记02|权限、网络白名单与页面缓存控制
网络·缓存