Redis与MySQL缓存一致性:先更新DB再删缓存、延迟双删与兜底
2026/9/18 11:24:10 网站建设 项目流程

缓存和数据库打架,是每个写过线上服务的人都躲不开的事。只要你的系统里同时存在 Redis 和 MySQL,就一定会在某个深夜被问到:"缓存里的数据怎么和库里不一样?"这个问题的答案通常藏在一行看起来人畜无害的更新代码里——updateDB(); redis.del(key);。代码没错,顺序也常被认为是对的,但线上出问题的时候,往往就是这套"标准写法"在极端并发下露了馅。这篇内容我想把"先更新DB,再删除缓存"这条路线从头到尾拆一遍:它为什么比"先删缓存再更新DB"稳,它的脏数据窗口到底长什么样,延迟双删的延迟该设多少毫秒,删除失败之后怎么兜底,还有我在真实项目里踩过的几个坑。适合已经用过 Redis 做缓存、但还没系统梳理过一致性问题的后端同学,也适合正在准备面试想把这题讲透的人——因为面试官真正想听的不是结论,而是你能把并发时序画出来。

1. 缓存一致性问题的本质与方案选型

1.1 为什么"更新缓存"和"删除缓存"是两码事

很多人第一次接触缓存更新时,直觉反应是"数据变了就把缓存也改成新值",也就是更新缓存(Set)。这个思路在单线程世界里完全成立,但在并发世界里它会引入一个非常隐蔽的问题:两个写请求的执行顺序和生效顺序可能不一致。

设想两个写请求 A 和 B 同时对同一条记录做修改。A 先把数据库改成值 1,B 接着把数据库改成值 2。但到了缓存这一层,由于线程调度、网络抖动、GC 停顿等原因,B 的"更新缓存"操作可能先执行,A 的反而后执行——最终数据库里是 2,缓存里却是 1,而且这个错误值会一直挂到过期为止。这种"后写的被先写的覆盖"现象,本质上是因为更新是有状态的写操作,而删除是无状态的幂等操作。删除不管执行几次、以什么顺序执行,结果都一样:缓存空了,下次读会回源。这就是"删除缓存"被广泛推荐的核心原因——它把一个容易乱的赋值操作,换成了一个天然幂等的动作。

另一个常被忽略的点是缓存值的计算成本。如果缓存里的值不是数据库行的简单映射,而是需要聚合多张表、调几次 RPC 才能算出来的结果,那么"更新缓存"意味着每次写都要重算一遍,哪怕这个 key 根本没人读。删除则把计算推迟到真正有读请求的时候,属于典型的懒加载思路,省下的算力在写多读少的场景里非常可观。

1.2 四种经典组合的推演与取舍

把"操作缓存"和"操作数据库"的先后顺序排列组合,一共能得到四种方案。我习惯用一个表格把它们摊开对比,这样讨论的时候不容易漏:

方案执行顺序脏数据风险适用性
先删缓存再更新DBdel cache → update DB高,读请求容易把旧值写回不推荐
先更新DB再删缓存update DB → del cache低,窗口极小主流推荐
先更新DB再更新缓存update DB → set cache中,并发写顺序错乱不推荐
先更新缓存再更新DBset cache → update DB高,DB失败即不一致不推荐

重点说被否掉的两个"高"风险方案。先删缓存再更新DB的问题在于:请求 A 删掉缓存后、还没来得及更新数据库,请求 B 就来读,发现缓存空了,于是从数据库读到旧值,再写回缓存。等 A 的更新完成,缓存里躺着的仍然是 B 写回的旧值,而且这个旧值不会被自动纠正。要修这个洞,就得引入延迟双删,等于绕了一圈又回到"删除"思路上来,复杂度反而更高。

先更新缓存再更新DB的问题更直接:缓存写入成功、数据库更新失败(比如唯一索引冲突、连接超时),缓存里就成了永远无法回滚的脏数据。缓存通常没有事务,也没有回滚能力,所以任何"缓存先行"的方案都要面对这个无法收场的问题。

剩下两种"先更新DB再动缓存"的方案里,更新缓存败给了删除缓存,理由就是前面说的幂等性和并发覆盖。所以最终胜出的就是"先更新DB,再删除缓存"。这个结论不是拍脑袋定的,而是把并发场景逐个推演之后剩下的那个风险最小的选项。需要强调的是,它并不消灭不一致,只是把不一致的时间窗口压缩到极小,并且给了我们做兜底的空间。

2. 先更新DB再删除缓存的完整链路拆解

2.1 为什么是这个顺序:从并发时序说起

要真正理解这个顺序,得把读请求和写请求的时序摆在一起看。写请求的链路是:更新数据库 → 删除缓存。读请求的链路是:查缓存 → 命中则返回 → 未命中则查数据库 → 回写缓存 → 返回。

正常情况下,这个组合不会产生长期脏数据。真正的风险窗口是这样的:读请求 B 在缓存失效后去查数据库,此时写请求 A 已经完成数据库更新但还没删缓存,B 读到的是 A 更新前还是更新后的值?取决于 B 的查询落在 A 的 UPDATE 提交之前还是之后。如果 B 读到旧值,然后 A 删掉了缓存,B 才把旧值写回缓存,脏数据就产生了。

注意这个窗口的成立条件很苛刻:B 必须恰好在"缓存已失效"和"缓存被写回"之间,完成了一次读到旧值的数据库查询,同时 A 的删除操作必须落在 B 写回之前。也就是说,读操作的耗时必须长于写操作从开始到删缓存结束的耗时。在绝大多数业务里,读就是一条主键查询,几毫秒到几十毫秒;而写操作往往涉及多表更新、加锁、事务提交,反而更慢。这就解释了为什么这个窗口在实践中很难被击中——它需要读慢写快这个反常的组合。

如果你的系统恰好是反过来的:读操作很重(比如要聚合好几张表、调外部接口),写操作很轻(单表单行更新且无事务),那这个窗口就会变大,必须靠延迟双删或者订阅 binlog 来加固。这也是很多文章只讲结论不讲条件的地方——"先更新DB再删缓存"不是银弹,它成立的前提是业务读写耗时符合常规分布。

2.2 删除失败的兜底:重试与延迟双删

即使顺序对了,还有一个硬伤绕不过去:删除缓存这个动作本身可能失败。网络抖动、Redis 主从切换、客户端超时,都会让del没有真正生效,而数据库那边已经提交了事务。这属于"操作顺序正确但执行不完整",如果不管,缓存里就会长时间挂着旧值直到过期。

最朴素的兜底是同步重试,但重试几次都失败怎么办?继续在请求线程里重试会拖慢接口响应,得不偿失。更靠谱的做法是把删除操作丢进消息队列,让消费者异步重试,直到成功为止。这里有个细节:消息队列本身也可能丢消息,所以生产端和消费端都要考虑幂等和确认机制,删除操作天然幂等,这点倒是不用额外处理。

延迟双删是另一种被广泛采用的加固手段。它的做法是:更新数据库后先删一次缓存,然后延迟一段时间再删一次。第一次删除负责清理正常情况下的旧值,第二次删除负责兜底那个"读请求把旧值写回"的窗口——因为第二次删除发生在窗口关闭之后,能把可能被写回的脏数据再清一遍。

注意:延迟双删的"延迟"如果直接用Thread.sleep放在请求线程里,会显著增加接口的响应时间,高并发下还会把线程池打满。正确做法是交给定时任务、延时队列或者独立的调度线程来执行。

延迟双删还有一个容易被忽略的副产品:它并不能保证强一致,只能把脏数据的存在时间进一步缩短。如果你的业务真的要求缓存和数据库分毫不差(比如账户余额),那就不该用"缓存 + 失效"这套模型,而应该考虑把这类强一致数据直接落库,或者用读写锁、分布式锁把读写串起来,代价是性能下降。

3. 落地实操:从代码到参数配置

3.1 缓存Key设计与序列化选型

在写更新逻辑之前,key 的设计和序列化方式会直接影响后续排查问题时的体验。key 的命名建议统一带上业务域和主键,例如user:profile:1024,冒号分层便于在可视化管理工具里按前缀筛选。不要用中文、不要用可能冲突的短前缀,也不要为了省几个字节把 key 压得看不出含义——后期排查脏数据时,一个能一眼看懂的 key 能省下大量时间。

序列化方式的选择直接关系到缓存的可读性和兼容性。用 JDK 原生序列化虽然省事,但存进去的是二进制乱码,可视化工具里没法直接看,而且类结构一变就容易反序列化失败。相比之下,JSON 序列化(如 Jackson、Fastjson 或 Gson)在可读性上优势明显,跨语言也更友好。代价是体积略大、序列化稍慢,但对于大多数业务系统来说这点开销完全可以接受。

这里有个我踩过的坑:序列化器换了之后,老缓存里的数据还是旧格式,新代码读出来会抛异常。所以序列化方式一旦上线就很难改,最好在项目初期就定下来,并且在缓存里加一个版本前缀,相当于给 key 留一条后路,后续要换格式时可以通过改前缀整体切换。

3.2 代码实现:更新DB加删除缓存的标准写法

下面是一段基于 Spring 的典型写法,重点看注释里的顺序和异常处理:

@Service public class UserService { @Autowired private UserMapper userMapper; @Autowired private StringRedisTemplate redisTemplate; private static final String CACHE_KEY = "user:profile:%d"; public void updateUser(User user) { // 1. 先更新数据库(事务由上层或本方法的 @Transactional 保证) userMapper.updateById(user); // 2. 再删除缓存。放在事务提交之后才安全 String key = String.format(CACHE_KEY, user.getId()); try { redisTemplate.delete(key); } catch (Exception e) { // 删除失败不能影响主流程,交给补偿机制 log.error("cache delete failed, key={}", key, e); // 投递到重试队列 retryQueue.send(key); } } }

这段代码里最关键的一行其实是"放在事务提交之后才安全"。如果这个方法被@Transactional包着,那么redisTemplate.delete会在事务提交之前执行——数据库还没真正提交,缓存就已经被删了。此时如果另一个读请求进来,读到的是事务未提交前的旧值,又写回了缓存,脏数据窗口瞬间被放大。正确的做法是用TransactionSynchronizationManager注册一个事务提交后的回调,或者把删除逻辑放到事务方法外面调用。

TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { safeDelete(key); } } );

这个细节是很多"标准写法"示例里不会写的,但它在真实项目里非常致命。我见过不止一个团队因为删除操作在事务内执行,导致压测时缓存一致性频繁告警。

3.3 延迟双删的时间窗口该怎么算

延迟双删的延迟时间不是拍脑袋定的,它要覆盖住"读请求从查库到写回缓存"的完整耗时。这个时间可以粗略拆成三部分:数据库查询耗时、网络往返耗时、加上主从复制延迟(如果读请求走的是从库)。

假设你的主从复制延迟在跨机房情况下稳定在 100ms 以内,单次数据库查询平均 20ms,读请求回写缓存的网络耗时 5ms,那么一个周期大约是 125ms。延迟时间应该在这个基础上留出足够冗余,取 3 到 5 倍比较稳妥,也就是 500ms 到 1s。如果业务读链路特别重,比如一次查询要 200ms,那就得把延迟拉到 1s 以上。

业务读链路特征单次读耗时建议延迟
单表主键查询10ms 以内300ms
多表关联查询50ms 左右500ms
聚合加远程调用200ms 以上1s 到 2s

但这个数值不是越大越好。延迟越长,删除任务在队列里堆积得越久,对调度系统的内存和可靠性要求就越高;而且第二次删除完成之前,缓存里可能一直躺着脏数据。所以延迟时间的本质是一个权衡:太小覆盖不住窗口,太大又拖长不一致时间。我的经验是先用理论值算一个下界,再结合线上监控的 P99 读耗时上调,最终定下来的值要写进配置中心,方便后续调整而不用发版。

4. 高并发场景下的加固手段

4.1 缓存穿透、击穿、雪崩的连带处理

缓存一致性问题很少单独出现,它往往和穿透、击穿、雪崩纠缠在一起。先区分一下这三个概念:穿透是查询一个数据库里根本不存在的 key,导致每次都打到数据库;击穿是某个热点 key 过期瞬间,大量请求同时涌向数据库;雪崩是大量 key 在同一时间点集体过期。

穿透的常见解法是缓存空值或者用布隆过滤器。缓存空值实现简单,但要注意给空值设置较短的过期时间,避免后续真的有数据写入时读到空的旧结果。布隆过滤器内存占用小,但存在误判率,而且删除元素麻烦,适合数据集合相对稳定的场景。

击穿的解法是给热点 key 加互斥锁,只放一个请求去查库、回填缓存,其余请求短暂等待。这里要小心锁的粒度,锁太粗会把并发度压得很低,锁太细又起不到保护作用。

雪崩的解法是给过期时间加随机抖动,比如原本统一 30 分钟的过期时间,改成 30 分钟加上 0 到 5 分钟的随机值。这样大量 key 的过期时间被错开,不会在同一秒集体失效。这个技巧实现成本极低,但在真实事故里能救命。

这三个问题和不一致性的关系在于:它们都会放大缓存回源的频率。回源越频繁,前面说的"读请求把旧值写回"的窗口被触发的概率就越高。所以一致性治理不能只看更新逻辑,还要把回源频率控制住。

4.2 基于binlog的最终一致性方案

如果你的团队对一致性的要求已经高于"能接受秒级脏数据",那么订阅数据库变更日志是一条更彻底的路。思路是:业务代码只负责更新数据库,不再手动操作缓存;一个独立的组件伪装成数据库的从库,实时拉取 binlog,解析出数据变更事件,再根据变更内容去删除或者更新缓存。

这套方案的好处是把缓存维护从业务代码里解耦出来。业务方不需要记住"先更新DB再删缓存"的顺序,也不会因为忘记删缓存而埋雷。所有缓存的更新逻辑集中在一处,出问题只需看一个组件。

代价也很明显:多了一个中间件的运维成本;binlog 解析存在延迟,所以这套方案给的是最终一致而不是强一致;如果缓存 key 的构造规则比较复杂(比如一个数据库行要拆成多个缓存 key),解析逻辑会写得比较绕。所以它更适合数据模型相对简单、读多写多、团队有运维能力的场景。中小项目用"更新DB加删缓存"配上重试队列,通常已经够用了,不必为了架构好看而引入额外复杂度。

提示:无论用哪种方案,都要给缓存 key 设置过期时间作为最后一道防线。即使一致性逻辑全部失效,过期时间也能保证脏数据不会永久存在。

5. 常见问题与排查技巧实录

5.1 典型故障速查表

线上出现缓存不一致时,排查顺序很重要,乱查一通只会浪费时间。下面这张表是我自己总结的速查路径:

现象可能原因排查动作
缓存值与库中长期不符删除操作在事务内执行检查删除代码是否在事务提交后
批量更新后部分key脏删除失败且无重试查删除日志与重试队列积压
高峰期才出现不一致读慢写快触发窗口统计读写的 P99 耗时分布
更新后立刻读仍是旧值主从复制延迟检查读请求是否走了从库
缓存里出现不存在的值穿透写入了空值检查空值缓存的过期时间

排查时有个很实用的手段:在更新逻辑里打上 traceId,把"数据库更新完成时间"和"缓存删除完成时间"都记进日志,出问题时按 key 去捞这段时间窗内的所有读写日志。只要把两组时间戳对齐,脏数据的产生过程基本能还原出来。这个方法比盯着监控图表猜要高效得多。

另外,不要一上来就怀疑 Redis 本身。Redis 的并发模型决定了它不会"吞掉"你的删除命令,删除失败通常出在客户端到服务端的这一段:连接池耗尽、超时设置过短、主从切换期间命令被拒。先确认命令有没有发出去,再怀疑服务端。

5.2 几个只有踩过才知道的细节

第一个细节关于连接池。很多人给 Redis 客户端配了很短的超时时间,认为这样能快速失败。但在删除操作上,超时过短会导致本来只是慢了一点的删除被判定为失败,触发重试,反而增加了堆积。删除操作本身很轻,超时时间可以比查询类操作放宽一些。

第二个细节关于批量更新。如果一次业务操作要更新几十上百条记录,逐条删除缓存的耗时会线性增长,很容易触发接口超时。这时候更适合的做法是把要删的 key 收集起来,用批量删除命令一次性提交,或者干脆异步化,把 key 列表丢给队列慢慢删。

第三个细节关于缓存预热。系统重启或者缓存整体失效后,大量请求会同时回源,此时如果恰好有写操作在进行,不一致的概率会明显上升。所以在大促、发版这类时间点之前,主动预热热点数据能有效降低这类风险。

第四个细节是关于监控。一致性问题是"沉默的故障",它不会报错,只会让用户看到不对的数据。建议在关键业务上加一个抽样比对任务,定期拿缓存值和数据库值做比对,发现不一致就告警。这个任务本身要控制频率,避免给数据库带来额外压力,比如每分钟随机抽几百个 key 就够了。

我个人在实际操作中的体会是,缓存一致性这件事,从来没有"配一次就永远对"的方案。业务在变,读写比例在变,数据模型也在变,去年成立的假设今年可能就不成立了。所以与其追求一个完美的方案,不如把可观测性做扎实:能快速发现不一致、能定位到具体是哪个环节出的问题、能在不改代码的情况下调整延迟时间。做到这几点,就算出了问题也不慌。另外还有一个小技巧,如果你不确定延迟双删该设多少,可以先设一个大一点的值观察一段时间,统计重试队列的积压情况再逐步往下调,从大往小调比从小往大调安全得多。

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

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

立即咨询