
1. 缓存一致性问题的本质到底在争什么先看一个几乎所有后端开发都踩过的场景商品详情页有个 Redis 缓存用户下单后库存变了但页面上的库存数还停留在旧值用户一脸懵客服被问爆技术开始查缓存到底什么时候刷新的。缓存和数据库之间的数据不一致是分布式系统里最经典、也最容易翻车的问题。尤其是读多写少的业务——商品信息、用户资料、配置数据——我们都会在 DB 前面加一层 Redis 或本地缓存来扛流量。但缓存一加就会引入一个新的问题数据在 DB 里更新了缓存里的旧值什么时候失效如果缓存一直不失效用户看到的永远是旧数据那缓存就从“性能利器”变成了“脏数据源头”。所谓的“缓存一致性方案”本质就是在回答一个问题写操作发生时先动缓存还是先动 DB动完之后要不要再做别的补偿动作。这里没有银弹但业界确实有一个相对最优的默认答案——先更新数据库再删除缓存。这篇文章我会把这条方案的底层逻辑拆开揉碎同时把“为什么不是先删缓存”“为什么不是先更新缓存”“为什么要删而不是改”讲清楚。最后再结合真实场景聊聊缓存删除失败怎么办、延迟双删是不是过度设计、本地缓存Caffeine要不要也走这套逻辑这类落地问题。如果你正在做电商、交易、内容平台这种强一致诉求的后端项目或者准备面试八股但想理解本质而不是背结论这篇文章应该能帮到你。2. 先看几个候选方案它们各踩了什么雷要理解为什么“先更 DB 再删缓存”是最优解先得知道别的方案输在哪里。2.1 方案一先删缓存再更新数据库这是很多新手的第一反应写请求来了先把缓存干掉再改 DB。逻辑上听起来没问题反正下次读会把新值写回缓存。但问题出在并发窗口上。假设缓存里存的 key 是老值 V1这时候来了两个请求请求 A写执行 delete Cache把 V1 删掉了。请求 B读缓存 miss去 DB 读数据。但这时候需求 A 还没提交事务B 读到的还是老值 V1。请求 B 把 V1 回填到缓存。请求 A写终于执行 update DB把数据库改成 V2。于是缓存里存的是 V1旧值DB 里是 V2新值这个不一致会一直持续到缓存再次过期。这个场景在真实业务里不是小概率事件。只要读请求频率稍微高一点B 在 A 的 DB 更新之前完成“读库 回填缓存”的概率就非常大尤其是 DB 更新比较慢大事务、锁竞争的时候这个坑几乎是必踩的。2.2 方案二先更新数据库再更新缓存有人会想删缓存会有空窗期那我直接把缓存里的值也更新成新值不就好了听起来顺滑但“更新缓存”这个动作比“删除缓存”危险得多。核心问题还是并发。两个并发写请求 W1把库存改成 50和 W2把库存改成 100顺序上是 W1 先更新 DBW2 后更新 DB所以 DB 最终是 100。但两个请求在写缓存的时刻可能跟写 DB 的顺序不一样——比如 W1 更新 DB 之后网络抖动导致写 Redis 慢了W2 更新 DB 后先写成功了 RedisW1 再写就覆盖成了 50。结果 DB 是 100缓存是 50不一致。你可能会说那我写缓存也加锁或保证顺序。可以但缓存写失败还得重试读请求在 DB 和缓存中间还可能读到半新不旧的中间态比如大事务更新前先改了缓存但 DB 还没提交。这套复杂度会上来但收益并没有比“删除”更高。另外还有一个容易被忽略的点不是所有字段都适合直接更新缓存。比如缓存里存的是对象 JSON某个字段更新了你得反序列化、改字段、再序列化写回去这本身就慢而且容易出错。删掉让读侧重建对代码侵入最小。2.3 方案三延迟双删Delay Double Delete延迟双删是对“先删缓存再更新 DB”的补丁先删缓存、更新 DB、睡 N 毫秒、再删一次缓存。目的就是处理先删缓存方案的并发回填问题——第二次删可以把“期间被读请求回填的旧值”删掉。这个方案有用但更准确的说是“可以用但不好用”。因为它引入了两个新问题延迟时间 N 怎么定设置太短读请求回填还没完成就白删了设置太长有 N 毫秒的脏数据可读期。业界一般建议读耗时 DB 查询时间 几百毫秒但实际不同业务差异很大很难一劳永逸。第二次删造成额外的缓存 miss。删两次意味着中间至少有一次新的缓存回填回填期间所有流量全打到 DB 上。而且延迟双删并不能解决“先更 DB 再删缓存”方案默认能解决的场景——它只是死磕“先删后更”这个本身就有先后手缺陷的方案。既然我们后面要讲的最优解本身就没有“先删旧缓存”这个动作为什么还要绕这个弯路2.4 方案四消息队列异步删缓存Cache Aside 异步补偿这是生产环境里经常看到的工程组合先更新 DB发送一条“删除缓存”的 MQ 消息由消费者执行删除。本质上是给“先更 DB 再删缓存”加了一层异步重试保险用来兜底“删除缓存失败”的场景。不过这里要区分清楚它是“先更 DB 再删缓存”的增强不是替代方案。正常路径上还是先更新 DB再删缓存MQ 只是当删除失败时用来重试。真正的 Cache Aside 模式先读缓miss 则读 DB 回填写请求更新 DB 后删除缓存本身就是以“先更 DB 再删缓存”为核心动作的。所以看完这四种方案你会发现一个问题你越想绕过“先更 DB 再删缓存”你越是要补一堆并发补偿机制。反过来老老实实走 Cache Aside天然就规避了大多数并发下的脏读写覆盖问题。3. 深度拆解为什么“先更 DB 再删缓存”才是最优解既然“先更 DB 再删缓存”是最优解那就得说清楚它好在哪免得像是背结论。3.1 从时间线上推导一遍它能规避什么我们用最关键的并发场景来走一遍。缓存里是 V1DB 里是 V1。现在来了一个写请求 W要把数据改成 V2和一个读请求 R可能在 W 执行期间任意时刻到达。场景 AW 先到R 在 W 更新 DB 之后、删缓存之前到达。W 执行 update DBDB 变成 V2。R 读缓存命中还是 V1。此时缓存是旧值DB 是新值读到的也不是最新数据。注意这个瞬间确实会读到旧值但它持续的时间窗口非常短——只有 W 从“更新完 DB”到“执行 del”之间的那一刻读者只可能读到旧缓存不会触发回填。一旦 W 的 del 执行完后续所有读请求都会 miss重新从 DB 拿到 V2 并回填。场景 BW 先到R 在 W 删缓存之后到达。W 删除缓存后DB 已是 V2。R miss读 DB 拿到 V2回填缓存一切正常。场景 CR 先到缓存 missR 去读 DBW 同时到来。这里要分两种线程时序看如果 R 在读 DB 之前W 还没更新 DBR 读到 V1然后回填缓存 V1。W 之后更新 DB 为 V2删缓存。缓存最终被删掉下一次读会拿到 V2。最终一致。如果 R 在读 DB 的时候W 已经更新完 DB 并删完缓存R 读到 V1假设 R 在读 DB 那一刻事务还没提交回填缓存 V1。但这时的顺序是——W 的删除动作要么已经发生要么发生在 R 回填之后。如果删除发生在回填之前那“回填 V1”又把旧值写回去了等等这就是它被质疑的地方。啊哈其实大家质疑的就是这个极端时序。R 读 DB 拿到 V1刚要回填W 删了缓存、更新 DB 成 V2然后 R 把 V1 写回缓存。缓存又变成旧值了。为什么这个时刻可以接受因为要触发这个场景R 必须是在 W 更新 DB 之前发起的“读 DB”操作且 DB 隔离级别如果是可重复读RRR 读到的会一直是 V1快照读那 V1 回填虽然脏但它是某一个合法旧版本业务可接受如果是读已提交RCR 在 W 提交后重新读可能读到 V2那就会回填正确值。更关键的是大部分业务对这个“极短暂读到旧值”是可以容忍的——因为缓存本身就不保证强一致最后一行代码的语义就是“最终一致最新的读请求尽量读到新值”。真正要避免的是“缓存被旧值覆盖后长期不更新”的长尾脏读。“先更 DB 再删缓存”虽然也可能被回填覆盖极微小概率且要求并发时序非常凑巧但它之后不会去做任何“再更新缓存”的动作——下次任何读请求 miss都会重新拉 DB。所以这个脏窗口只会在“回填旧值”的那一瞬间存在而不会持续到缓存过期。我在现实业务中排查过很多所谓的“缓存脏数据”问题绝大多数不是“先更 DB 再删缓存”本身的问题而是删除失败、删除漏掉、代码 bug 导致根本没有删除缓存。3.2 为什么“删缓存”优于“更新缓存”更新缓存的问题在于两个并发写容易导致缓存值不是最终值。删除缓存的优势非常明显它不写入任何值所以不存在“写覆盖”的错误可能性。删了之后所有读请求都会去 DB 拉最新值让 DB 做决策。打个生活化的比喻把 DB 当成“账本”缓存当成“贴在墙上的公告”。更新缓存相当于“有人手写改墙上的公告”两个店员同时改容易互相覆盖删除缓存相当于“把旧公告撕了”顾客走到柜台问服务员DB拿最新价虽然偶尔会多跑一趟但永远不会因为“公告上的价是错的”而出乱子。这个“不会写错”的优势是“先更 DB 再删缓存”成为最优解的根本原因。它不是性能最好的两次操作正常不会 miss只有删除后下一次读会 miss但它是语义最简单、最不容易出错、对业务侵入最小的。3.3 性能开销与业务适配分析“先更 DB 再删缓存”会带来一次额外的缓存 miss这个开销必须说清楚别让人觉得完全没有成本。写操作之后下一次读大概率 miss然后读 DB 回填。相比“先更新缓存”方案等于每次写后多了一次 DB 读和一次 Redis 写。如果写频率远小于读频率这个成本完全可接受。反过来如果某个 key 写得非常频繁比如秒杀库存每秒几十次写每次写后都删缓存会导致缓存形同虚设——因为每次都要 miss、回填、再被删。应对这种场景的做法是不是改变“先更 DB 再删缓存”这个原则而是调整缓存策略——对高频写 key 不做缓存或者用更长的缓存生命周期 定时刷新。你得先想清楚一个 key 的读写比再说用不用缓存、用多长过期时间。这是缓存设计里优先级最高的事比纠结如何删更重要。对比维度先更DB再删缓存先删缓存再更DB先更DB再更缓存延迟双删并发写覆盖缓存风险无缓存值被删不写值低旧的读回填可能覆盖高写顺序可能错乱中第二次删兜底旧值长期残留风险极低高高低但窗口期难调代码复杂度低低中中高额外性能开销一次miss/读回填一次miss/读回填无额外miss但更新缓存耗时两次删除两次回填适合场景绝大多数读写场景几乎不建议对缓存写入顺序有强保证的极端场景对旧值完全不可接受的少数场景4. 可落地的实践更新DB后删缓存的具体实现方案本身不复杂但落地时有不少细节值得聊。4.1 最基础的实现方式无论你用 Spring Cache 注解、MyBatis Cache 还是自己写 RedisUtil核心代码逻辑就三行// 伪代码示意 public void updateStock(Long skuId, Integer delta) { // 1. 更新 DB stockMapper.updateStock(skuId, delta); // 2. 删除缓存 redisTemplate.delete(cacheKey(skuId)); }这里有个很多人忽略的问题直接删除缓存可能会误删掉其他维度的缓存。比如商品详情页可能同时缓存了product:{id}:base、product:{id}:stock、product:{id}:sales如果某次更新只改了库存却把所有商品相关 key 都删了会导致大量不必要的回填。所以更合理的做法是精细控制缓存 key 的粒度更新了哪个字段就删哪个维度的缓存 key。宁可多删一个 key也不要少删。另外一个常见问题是事务边界。上面那份伪代码如果updateStock成功但redisTemplate.delete抛异常这个方法的调用方会认为更新失败可能触发事务回滚。但缓存删除失败通常只是因为网络抖动DB 并不想回滚。所以正确做法一般是先提交事务再删缓存。如果删缓存放在事务内且随事务回滚等于 DB 没改缓存却又删了下次读回填的还是旧值白折腾。反过来DB 提交后删缓存失败顶多缓存里多放一会儿旧值最终靠过期兜底好过 DB 回滚。用 Spring 的Transactional时我会避免把删缓存写在事务方法内部而是把缓存删除动作放到事务提交后的回调里Transactional public void updateStock(Long skuId, Integer delta) { stockMapper.updateStock(skuId, delta); // 事务提交后再删缓存 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { redisTemplate.delete(cacheKey(skuId)); } }); }虽然这样代码看起来啰嗦一点但能避免两个坑一是事务回滚时缓存被误删二是 DB 提交和缓存删除中间出现长事务锁导致别人读到旧值的时间太长。整个删缓存操作挪到事务提交后窗口大幅缩短。4.2 Spring Cache 和 MyBatis Cache 场景下怎么写很多人用 Spring Cache 的CacheEvict注解用法如下CacheEvict(cacheNames product, key #skuId) public void updateStock(Long skuId, Integer delta) { stockMapper.updateStock(skuId, delta); }注意这里的CacheEvict默认在方法成功返回后执行如果你在方法上加Transactional要确认缓存删除发生在事务提交后还是事务提交前。Spring Cache 默认的拦截器顺序里CacheAspectSupport是在方法调用后、事务提交前执行的所以极端情况下可能缓存删了、事务回滚了、下次读到旧值又要回填——虽然不是大问题但还是会造成一次多余的缓存删除和回填。最好查阅一下你所用 Spring 版本的执行顺序必要时用TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)来做删除动作。MyBatis 的二级缓存相关的问题常见于后端面试但实际生产环境很少有人单独用 MyBatis 的 Cache 做业务缓存因为它默认基于SqlSession或全局命名空间粒度太粗很难精细控制。如果你用了 MyBatis建议直接用 Redis 缓存而不要在 MyBatis 的 Cache 体系上叠业务缓存两个缓存叠加会带来双份一致性问题。4.3 删除失败时的兜底策略消息队列重试与 binlog 订阅“先更 DB 再删缓存”理论最优但真实环境中,Redis 可能宕机、网络可能抖动、key 可能被动失效所以删除失败是必然事件。生产环境必须有一个兜底。我见过很多项目的兜底方案是“捕获异常然后记日志”但日志只是记录没人看下次缓存过期后自然修复也不是不行——只要你能接受这段期间读到旧值。但更严谨的项目会用以下两种方式之一消息队列重试方案对代码侵入低在删除缓存失败时发送一条 MQ 消息由消费者重试删除。注意消息体里要包含缓存 key 和对应 DB 操作发生的时间重试时最好加一个“最小重试间隔”不要把秒级 MQ 消费变成缓存击穿放大器。public void deleteCacheWithRetry(String key) { try { redisTemplate.delete(key); } catch (Exception e) { // 发送 MQ由消费者异步重试 mqTemplate.send(cache-del-retry-topic, new CacheDelMessage(key)); } }binlog 订阅方案与业务代码解耦使用 Canal 之类的工具订阅 MySQL binlog解析出数据变更事件再根据变更的表和主键删除对应缓存。这种方式对业务代码无侵入但需要额外部署 Canal、维护表与缓存 key 的映射关系适合大团队、多业务方共享 DB 的场景。对于单业务单缓存的小团队来说这个方案有点重。兜底策略不是必须一开始就做全但至少你得有一个“一定时间后重试删除”的机制否则缓存删除失败就只能依赖缓存过期来修复如果过期时间设得很长比如 1 小时那一小时里用户全在看旧数据。在我的实践里最稳定的一种组合是正常路径直接删缓存 失败发 MQ 重试 缓存 key 设置 3 到 5 分钟的过期时间作为安全垫。即使 MQ 重试也挂了缓存过期后还是会自动修正把“脏数据存在时间”控制在一个很小的范围。5. 看上去很美但要注意的几个反模式5.1 单条写请求之后立刻读缓存 miss 击穿 DB“先更 DB 再删缓存”的副作用是每次写后下一次读会 miss。在写频繁且读频繁的 key 上可能会造成多次缓存穿透甚至打满数据库连接。我曾在某个秒杀项目中看到库存 key 在秒杀期间每次扣减都删缓存下一秒读请求全部 miss流入 DBDB 连接池瞬间被打满。后来怎么处理的对库存这类高频写且读 QPS 极高的 key不走 redis 缓存直接走 DB或者用 Redis 的原子自减DECR来同步扣减并定期同步回 DB。这其实是另一种缓存策略的取舍——不是所有场景都适合套用“先更 DB 再删缓存”。你只有在读远多于写时这个方案才是最优解。这里做个判断标准写频率超过每秒钟几次时一个 key 的缓存命中率会变得很低这时候缓存的意义已经不大了。高写入频率下优先考虑数据库直查、批量写合并、队列削峰而不是在缓存一致性上死磕。5.2 大事务里执行删缓存锁与网络延迟相互放大有人会把删缓存写在事务内部且没做提交后回调然后一个更新事务里有多个 SQL比如更新商品主表、更新 sku 表、更新库存表全部在事务里执行完再删缓存。如果这个事务执行了 200ms期间持有 DB 行锁那另外 5 个读请求在 waits 状态下读到旧缓存因为缓存还没删又因为 DB 锁拿不到读操作也被拖慢。优化方向有两个一是尽量缩短事务时间快进快出不在事务里做缓存删除二是把切缓存动作延到事务提交后让锁释放完毕后再通知缓存失效。不要小看这 200ms 的窗口在微服务里一次调用可能引发下游多个服务集体超时。5.3 缓存 key 设计不好删了个寂寞这个是真的踩过坑。以前接手过一个项目更新用户资料的代码里redisTemplate.delete(user: userId)但是查缓存时用的 key 还带了一个前缀后缀比如user:info:{userId}。结果更新完所有用户写请求都在删一个不存在的 key缓存里的旧值永远删不掉线上缓存命中率百分之百但全是脏数据。排查起来很痛苦因为读侧一切正常没有报错只有数据不对。后来把 key 统一抽到一个常量类里让读和写共用同一个 key 生成方案才彻底解决。实操建议缓存 key 的构建必须收敛到一个方法禁止在不同代码位置直接拼接 key至少要做到“所有需要删除缓存的地方都用同一个 key 生成器”。另外给 Redis key 加命名空间前缀、业务模块前缀不要在删缓存时用通配符keys命令生产环境会阻塞单线程的 Redis。5.4 缓存更新动作过于频繁可以考虑“双删”极端严格的场景下比如金额、库存、状态机流转这类业务方可能无法接受“先更 DB 再删缓存”那两个极短旧值窗口一个是更新 DB 后还没来得及删缓存时旧缓存仍可被读到另一个是极端的读回填旧值覆盖。这时候你可以在“先更 DB 再删缓存”基础上叠加一次延迟删延迟双删仅作为对 Cache Aside 的加强public void updateKey(String key, Object newValue) { // 第一步更新DB updateDb(newValue); // 第二步立即删除缓存 redisTemplate.delete(key); // 第三步延迟1秒后再删除一次 Thread.sleep(1000); // 生产环境请用异步调度不要同步sleep redisTemplate.delete(key); }但记住延迟双删是给极端一致性要求场景打的补丁不是默认方案。如果你真的要做第二次删除建议放到异步线程或延迟消息队列里不要在主线程 sleep否则会让写请求响应时间凭空多出 1 秒这在线上是不可接受的。6. 本地缓存Caffeine/Guava要不要也一致很多单体应用会用 Caffeine 做本地缓存因为它访问速度比 Redis 更快、不用网络 IO。但本地缓存的一致性处理比 Redis 难得多——每个服务实例都有自己的一份缓存你更新 DB 后删 Redis 很容易但没法直接删除所有实例的本地缓存因为各实例内存相互隔离。常见的做法是引入 Redis 发布订阅作为通知通道实例 A 更新 DB 后往 Redis 发一条频道消息比如cache:evict:{key}所有订阅了该频道的实例收到消息后删除本地 Caffeine 缓存。// 发布侧 redisTemplate.convertAndSend(cache:evict, cacheKey); // 订阅侧伪代码 redisTemplate.getConnectionFactory().getConnection().subscribe( (message, pattern) - { String key new String(message.getBody()); caffeineCache.invalidate(key); }, cache:evict.getBytes() );这套方案的优点是简单直接缺点是如果你有几十个实例并行订阅同一个频道每个实例的本地缓存都会失效一次可能造成同一时刻大量 DB 查询回填形成缓存雪崩。为了缓解可以给本地缓存设置一个较短的过期时间比如 30 秒到 1 分钟或者做“较短过期 主动失效”双通道机制。本地缓存的一致性做不到强一致只能做到最终一致这是由本地缓存的架构决定的。所以 Caffeine 适合放那些“瞬时一致性要求不高、过期几分钟无所谓”的数据而不是核心金额、库存类。我见过有人拿 Caffeine 缓存用户余额结果每次余额变动要手动写一堆发布订阅代码最后还会出问题这种设计本身就是选错了工具。做技术选型时要先把一致性需求分级强一致走 DB弱一致、读多写少才上缓存本地缓存只放低价值数据。7. 线上缓存故障排查一个实用检查清单最后分享点排障经验。如果你已经照“先更 DB 再删缓存”做了线上还是出现数据不一致通常不是方案本身的问题而是实现细节出了岔子。按下面的顺序排查一遍大概率能定位。第一步确认缓存 key 是否正确。把读侧和写侧的 key 生成逻辑贴到同一个测试用例里对比保证生成结果完全一致。这是最高频的错误来源。第二步确认删除动作真的执行了。打开 Redis Monitor 或者用MONITOR命令观察在写请求期间是否真的发出了DEL指令。有时候你以为删了实际上代码在某个「if」分支里没走到删缓存这一行。第三步确认删除失败时有没有告警。如果 Redis 连接池满了、网络抖动删除命令会抛出异常你是否捕获了异常并做了重试没有就是脏数据持续存在。第四步确认 DB 事务提交和删除缓存的先后顺序。如果删缓存动作写在事务内部事务回滚导致缓存被删但 DB 没更新看起来“缓存正常”但实际是另一种不一致。第五步确认有没有其他服务直接写了缓存。在微服务架构下多个服务可能都有写某个缓存 key 的代码比如订单服务写订单缓存、库存服务也写订单缓存。这种跨服务互相删缓存的情况最隐蔽通常用「缓存 key 的所有写入点必须唯一」的规范来约束。第六步确认缓存过期时间有没有被意外重置。如果有人调用了EXPIRE命令把过期时间从 60 秒改成了 7 天那缓存里即使有脏数据也要挂很久才能自愈。这种 bug 非常难排查最好在缓存 key 上统一管理 TTL。这些检查点我都实际用过尤其是第三、第五点基本涵盖了八成以上的“缓存不一致”工单。排查时不要一开始怀疑“先更 DB 再删缓存”这个方案大概率是实现的细节出了问题。8. 从“会用”到“会选”缓存一致性不是孤立命题写到最后我想说说这些年做缓存治理的一点体会。“先更 DB 再删缓存”为什么能成为一个默认解不在于它完美无缺而在于它把不确定性控制在了可接受的最小范围。它避免了写覆盖错误缩短了旧值可见窗口不依赖任何额外的调度与补偿就能在绝大多数业务里天然成立。但真正的架构能力不是记住某个方案是“最优解”而是学会判断一个场景是否需要“最优解”。读多写少、key 粒度清晰、缓存命中率高的业务用 Cache Aside 最舒服写多读少、缓存命中率极低的数据不如直接绕开缓存数据强一致要求极高、技术团队有足够运维能力才去上 binlog 订阅 缓存失效的复杂链路。架构设计里最重要的一个习惯就是为数据找到一致性与可用性的平衡点而不是追求绝对一致。个人经验里一个缓存设计是否成功的最终检验标准不是“方案听起来是不是很高级”而是“线上有没有脏数据工单、缓存命中率是不是稳定、故障恢复是不是快”。先把“先更 DB 再删缓存”这步走稳后面的补偿机制和精细控制都是在这条主线上的点缀。