ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis与MySQL缓存一致性:先更新DB再删缓存、延迟双删与兜底

Redis与MySQL缓存一致性:先更新DB再删缓存、延迟双删与兜底 缓存和数据库打架是每个写过线上服务的人都躲不开的事。只要你的系统里同时存在 Redis 和 MySQL就一定会在某个深夜被问到缓存里的数据怎么和库里不一样这个问题的答案通常藏在一行看起来人畜无害的更新代码里——updateDB(); redis.del(key);。代码没错顺序也常被认为是对的但线上出问题的时候往往就是这套标准写法在极端并发下露了馅。这篇内容我想把先更新DB再删除缓存这条路线从头到尾拆一遍它为什么比先删缓存再更新DB稳它的脏数据窗口到底长什么样延迟双删的延迟该设多少毫秒删除失败之后怎么兜底还有我在真实项目里踩过的几个坑。适合已经用过 Redis 做缓存、但还没系统梳理过一致性问题的后端同学也适合正在准备面试想把这题讲透的人——因为面试官真正想听的不是结论而是你能把并发时序画出来。1. 缓存一致性问题的本质与方案选型1.1 为什么更新缓存和删除缓存是两码事很多人第一次接触缓存更新时直觉反应是数据变了就把缓存也改成新值也就是更新缓存Set。这个思路在单线程世界里完全成立但在并发世界里它会引入一个非常隐蔽的问题两个写请求的执行顺序和生效顺序可能不一致。设想两个写请求 A 和 B 同时对同一条记录做修改。A 先把数据库改成值 1B 接着把数据库改成值 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 就够了。我个人在实际操作中的体会是缓存一致性这件事从来没有配一次就永远对的方案。业务在变读写比例在变数据模型也在变去年成立的假设今年可能就不成立了。所以与其追求一个完美的方案不如把可观测性做扎实能快速发现不一致、能定位到具体是哪个环节出的问题、能在不改代码的情况下调整延迟时间。做到这几点就算出了问题也不慌。另外还有一个小技巧如果你不确定延迟双删该设多少可以先设一个大一点的值观察一段时间统计重试队列的积压情况再逐步往下调从大往小调比从小往大调安全得多。
返回列表