做后端这几年有一个话题几乎每次面试都会被问到也是线上事故的高发区就是标题这行字MySQL 与 Redis 的数据一致性问题。只要你的系统用了 Redis 做缓存MySQL 做持久化存储那“双写一致”这四个字就绕不开。我见过太多团队前期为了性能果断上缓存结果某天线上出现“改了数据库页面还是老数据”的投诉排查半天发现是缓存删除失败、并发回填旧值或者事务提交前就把缓存删了各种诡异问题层出不穷。这篇文章我不打算只贴一个“标准答案”就完事。我会把缓存和数据库不一致的根因讲透把业界常用的几种方案拿出来逐个拆解然后给出一个我自己在项目中验证过、相对可靠的最终一致性落地组合拳最后把我踩过的坑和排查思路一并整理出来。无论你是刚接触缓存的新手还是被线上问题折磨过的老手这篇文章应该都能给你一些可复用的东西。1. 先搞清楚什么场景下才会纠结数据一致性1.1 缓存架构的基本形态先看最通用的缓存架构。你的系统有一个 MySQL 作为核心数据存储所有变更操作最终都要落库这是数据的“唯一真相源”。但 MySQL 有一个天然的短板在高并发读场景下连接数、磁盘IO、查询复杂度都会成为瓶颈。为了扛住读多写少的业务压力你在 MySQL 前面加了一层 Redis热点数据放在内存里读请求优先打 Redis命中就直接返回没命中再去查 MySQL 并回填缓存。这样一个非常经典的分层架构会带来一个非常现实的代价同一份数据在 MySQL 和 Redis 里各存了一份副本。只要是副本就存在“两份数据不一致”的可能。所以并不是所有业务都会遇到这个问题只有在“需要把 MySQL 里的数据同步到 Redis 缓存里供读取”的场景下一致性问题才真正进入你的视野。举个典型例子商品详情页。商品价格、库存、标题这些字段存在 MySQL查询时先走 Redis。后台运营修改了商品价格MySQL 里的价格已经变了但 Redis 里还是旧价格用户刷新页面看到的还是老价格这就是一次典型的缓存不一致事故。再比如用户信息、订单状态、配置数据只要存在缓存理论上都有这个隐患。1.2 一致性问题的根因写入被拆成了两次不原子操作为什么 MySQL 和 Redis 会出现不一致核心原因其实非常简单你没办法用一次原子操作同时更新两个独立的系统。你写 MySQL 是一回事写 Redis 是另一回事这两次操作之间没有事务保护也没有内建的分布式事务协调机制。只要两次操作之间发生任何意外——程序崩溃、网络超时、进程被杀、服务器宕机——就必然出现一边成功一边失败的局面。更麻烦的是并发。假设同时有两个线程在操作同一条数据一个在写 MySQL一个在读 MySQL 并回填 Redis读写交叉进行时即使每一步操作本身都是正确的组合出来的顺序也可能把一份旧数据回填到缓存里然后长时间不失效。这就是教科书上常见的“竞态条件”也是无数线上诡异的缓存问题的来源。我用一个生活化的类比来解释MySQL 是一本正式的账本Redis 是一张贴在桌上的便利贴用来快速查看常用数据。你每次修改正式账本后本应该在便利贴上同步改一下。但问题是改账本和改便利贴是两件事中间总有时间差。账本改了、便利贴没改就是数据不一致便利贴被别人顺手写了个旧值那就更乱套了。1.3 先说清楚多核一致性不等于缓存一致性有个热搜词是“多核数据一致性”这里提一嘴避免混淆。多核数据一致性是计算机体系结构里的概念指多个 CPU 核心在共享内存时通过缓存一致性协议比如 MESI保证每个核心看到的同一块内存数据是一致的。而 MySQL 与 Redis 的数据一致性指两个独立软件系统之间的数据同步问题。两者虽然都叫“一致性”但层次完全不同不要混为一谈。本文讨论的是后者而且在工程实践中它没有硬件协议那种强一致的保证只能靠业务方案去尽量逼近。2. 为什么教科书方案Cache Aside也会翻车2.1 Cache Aside 模式的正确理解大部分后端开发者第一次接触缓存时学到的最佳实践是 Cache Aside Pattern也叫旁路缓存。它的读操作流程是先读缓存命中直接返回未命中则查数据库再把结果写入缓存。写操作流程是先更新数据库然后删除缓存。整体逻辑非常简单但很多人只记住了“删缓存”这个动作却未必理解它背后的用意。为什么更新数据库之后要“删除”缓存而不是“更新”缓存这是很多人忽略的细节。如果你去更新缓存你得确保写入缓存的值和数据库完全一致万一你在更新缓存前另一个线程已经用新数据更新了缓存此时你再把旧数据写进去就会造成缓存被旧值覆盖。而删除缓存就安全得多删除后下次读取会自然回填最新值相当于把“构造缓存数据”这件事延后到读取发生时。这种“懒加载”思路天然规避了并发写覆盖的问题。// Cache Aside 模式的标准伪代码 public Product getProduct(Long id) { // 1. 先读缓存 Product product redisTemplate.opsForValue().get(product: id); if (product ! null) { return product; } // 2. 未命中读数据库 product productDao.getById(id); if (product ! null) { // 3. 回填缓存并设置过期时间 redisTemplate.opsForValue().set(product: id, product, 30, TimeUnit.MINUTES); } return product; } public void updateProduct(Product product) { // 1. 先更新数据库 productDao.updateById(product); // 2. 再删除缓存 redisTemplate.delete(product: product.getId()); }按这套流程走正常情况没问题。但问题在于如果“删除缓存”这一步失败了缓存里留下的旧数据就会一直存在直到它自然过期。如果过期时间是 30 分钟那在这 30 分钟内所有读请求都会拿到旧数据。教科书告诉你“更新数据库后删缓存”可教科书没有告诉你“删除缓存失败时该怎么办”。2.2 先更新 DB 再删缓存失败怎么办删除缓存失败是生产环境最常见的故障切入点。数据库连接成功业务逻辑正常偏偏 Redis 执行 delete 时超时或者服务端瞬间不可用代码抛出异常甚至被吞掉缓存里的旧值安然无恙继续服务于是不一致产生了。更隐蔽的情况是删除缓存这行代码没有放在事务提交之后而是放在事务方法内部。Spring 的 Transactional 默认在方法执行完才提交事务如果你在事务内先更新数据库再删缓存但事务还没提交此时另一个线程读缓存未命中去数据库查到的还是旧数据回填到 Redis等事务提交之后Redis 里已经是旧值了。解决删除失败常规手段是“失败补偿”。最简单的做法是重试几次但如果在请求线程内同步重试会阻塞用户请求而且如果 Redis 持续不可用重试也白搭。更工业化的做法是把删除缓存这个动作转化为一条消息丢进消息队列由一个独立的消费者去执行删除操作删除失败就按消息重试机制反复投递。这个思路的优点是解耦且可靠缺点是引入了额外的中间件架构复杂度上升。另外要注意 Spring 事务的坑。正确的姿势应该是通过事务同步器注册一个回调在事务成功提交后执行缓存删除这样能保证数据库操作和缓存操作之间至少有一个清晰的先后边界。Transactional public void updateProductWithCache(Product product) { // 1. 更新数据库 productDao.updateById(product); // 2. 事务提交后删除缓存 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { redisTemplate.delete(product: product.getId()); } }); }我之前见过一个项目开发把删除缓存的代码直接写在事务方法里数据库更新之后立刻删缓存表面看起来没问题。某个高峰期有一个读线程在“事务提交前”读取了旧数据并回填缓存导致这个 key 在接下来很长一段时间里一直是旧值。定位这个问题耗费了不少时间最后用事务同步器重构才解决。2.3 先删缓存再更新 DB 为什么更不靠谱既然“先更新 DB 再删缓存”有删除失败的风险那反过来“先删缓存再更新 DB”是不是更好这个方案的核心思想是先把缓存清掉让读请求短时间内直接打数据库等数据库更新完下次读请求自然会把新值回填到缓存。听起来合理但有一个严重的并发问题。考虑这样一个时间线线程 A 先删除了缓存正准备更新数据库。此时线程 B 发起读请求缓存未命中于是去数据库读但数据库还没被线程 A 更新所以线程 B 读到的是旧值。接着线程 B 把旧值回填到了缓存。之后线程 A 才完成数据库更新。结果是数据库里的值是新值缓存里的值是旧值而且这个旧值可能长期有效。缓存直接被旧值“污染”了比“先更新 DB 再删缓存”的问题更严重。为了补救这个问题业界提出了“延迟双删”Delay Double Delete策略先删除缓存再更新数据库然后休眠一小段时间再次删除缓存。第二次删除的目的是为了把线程 B 在中间窗口期回填的旧值清掉。延迟双删在逻辑上能解决一部分问题但注意它并没有从根本上消灭竞态窗口。如果线程 B 回填旧值的时间点发生在第二次删除之后那么问题依然存在。所以延迟双删只适合并发不极端、能接受极小概率不一致的场景。public void updateProductWithDoubleDelete(Product product) { // 1. 第一次删除缓存 redisTemplate.delete(product: product.getId()); // 2. 更新数据库 productDao.updateById(product); // 3. 休眠一段时间等并发读请求回填旧值的操作大概率完成 try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 4. 第二次删除缓存 redisTemplate.delete(product: product.getId()); }这个 500ms 是很令人纠结的地方。设置太长写接口延迟明显变大设置太短第二次删除执行时机早于并发读回填旧值的时机等于白删。而且 JVM 线程休眠并不能精确控制时序高并发下依然存在漏网之鱼。所以我的建议是延迟双删只作为一个过渡方案不要把它当成银弹。3. 常见的主流落地方案与选型3.1 方案 ACache Aside 加缓存过期兜底目前大多数业务系统最稳妥的默认方案仍然是 Cache Aside也就是先更新数据库、再删除缓存同时给所有缓存 key 设置一个合理的过期时间。删除失败的问题通过前面提到的“事务提交后删除 重试/MQ 补偿”机制来解决。过期时间则是兜底策略即使删除彻底失败、重试也失败旧数据最多存活到过期时间然后自动失效下次读取回填新值。这个方案的定位是“最终一致性”。它不追求数据库和缓存任何时刻都一致只保证在不超过过期时间的一个时间窗口内数据能收敛到一致。对于绝大多数读多写少、对实时性要求没那么苛刻的业务来说这个方案性价比是最高的。关键在于两点一是过期时间不能太长太长会导致不一致窗口过大二是补偿机制要可靠不能每次删除失败都当作没看见。过期时间怎么定要根据业务容忍度来。商品详情这类数据一般设置 30 分钟到 1 小时都没问题因为用户对价格、标题变化的感知可以接受分钟级延迟。但如果是库存数量、优惠券状态这种稍敏感的数据建议把过期时间压缩到 35 分钟甚至更短。同时补偿机制要兜底避免缓存长期不更新。3.2 方案 B延迟双删谨慎使用延迟双删适合写并发不那么高的内部系统。比如后台管理系统运营人员偶尔修改一条配置同时在线用户也不多并发读回填旧值的概率很低这时候延迟双删的成本最低效果也够用。但在高并发 C 端场景我强烈不建议依赖它。前面已经分析了竞态窗口无法彻底消除再叠加 JVM 线程调度的不确定性很容易出现概率极小但确实会发生的问题。线上一致性事故往往就是这种小概率事件在某个凌晨突然触发。如果非要用延迟双删第二次删除的延迟时间不要拍脑袋。一个相对靠谱的做法是统计一下从“读缓存未命中”到“回填缓存完成”的平均耗时把这个耗时放大若干倍比如 3 倍作为延迟时间。或者更简单粗暴做个压测观察高并发下回填操作的最长耗时再留足余量。我在一个内部项目中设置过 1 秒因为那个系统并发极低而且数据敏感性也不高实际运行半年没有出现一致性问题。但请记住这是业务选择不是技术银弹。3.3 方案 C基于 Binlog 订阅的异步删除如果你想让业务代码彻底解耦不关心“先更新 DB 还是先删缓存”同时还想保证高可靠可以考虑基于 MySQL Binlog 的异步方案。典型实现是使用 Canal 模拟 MySQL 从库订阅主库的 Binlog拿到数据变更事件后投递给 MQ再由消费者去删除 Redis 对应缓存。这套方案把“数据库变更”作为事件源只要 MySQL 的 Binlog 里记录了变更就一定能触达后续的缓存删除流程天然避免了业务代码里漏删、忘删的问题。这套方案的优势非常明显对业务代码侵入极小开发人员只需要在数据库层面正常更新所有缓存同步逻辑都由 Binlog 消费链路完成。可靠性非常高因为 Binlog 本身是 MySQL 持久化的日志一旦产生就不会丢失配合 MQ 的重试机制删除缓存失败后可以不断重试直到成功。缺点也很明显需要额外引入 Canal 和 MQ 组件运维成本、技术复杂度都上来了。Canal 本身要维护主从拉取位点、处理 Binlog 格式变更消费者那边还要考虑消息幂等性。而且从数据库更新到缓存删除之间存在一个天然的异步延迟通常是毫秒级到秒级这意味着极端情况下读请求会在很短的时间内读到旧缓存。如果业务对实时一致性要求很高这个方案还需要搭配其他手段。3.4 方案 D分布式锁串行化强一致场景专用前面几种方案追求的都是最终一致如果你的业务要求“数据库更新之后缓存必须立刻同步变化”比如秒杀库存、转账余额那就别再纠结缓存了。要么直接不设缓存所有读写都走数据库要么在写操作时加分布式锁让同一个 key 的读写操作串行执行。分布式锁可以基于 Redis 实现也可以基于 ZooKeeper 实现目的都一样同一时刻只允许一个线程操作这条数据从根源上消灭并发交叉。加锁的性能损耗很高。每次写操作都要申请锁、释放锁Redis 的分布式锁在某些极端情况下还有锁失效导致并发进入的问题。所以这个方案一般只在数据量小、写多读少、但一致性要求极高的场景使用。如果系统里只有极少数 key 需要这种强一致保证我倾向于单独为这些 key 加锁而不是全表无差别加锁。3.5 方案选型对比与建议方案一致性程度实现复杂度引入组件适用场景Cache Aside 过期兜底最终一致低无可选 MQ 做补偿绝大多数读多写少业务推荐默认延迟双删最终一致有极小概率失效低无并发低、一致性要求一般的内部系统Binlog 订阅 异步删除最终一致可靠性高高Canal、MQ对业务代码侵入要求低团队运维能力强分布式锁串行化强一致中Redis/ZooKeeper库存、余额等强一致场景且数据量不大我的选型建议很朴素默认选第一个把过期时间设好把删除失败补偿做扎实如果团队有精力逐步演进到 Binlog 订阅方案强一致场景不要跟缓存死磕直接走 DB 加锁。最怕的是每个方案都想要最后落成一个四不像。技术选型的重要原则是简化系统而不是为了炫技增加维护成本。4. 完整实操搭建一套相对可靠的最终一致性方案4.1 设计思路最终一致性的三层防线项目里我通常采用一套“三层防线”的设计理念目标不是把问题消灭在某一个点而是每一层都能兜住上一层漏掉的问题组合起来把不一致窗口压缩到最小。第一层防线是顺序正确任何写操作先更新 MySQL事务提交成功后再删除缓存。这层能解决大部分正常流程下的一致性问题。第二层防线是失败补偿如果删除缓存失败或者事务提交后删除动作没有执行就通过 MQ 异步重试、定时任务补偿等方式保证“缓存最终被删除”。第三层防线是过期兜底即使前两层都失效缓存 key 也会在过期时间后自动消失触发重新加载让数据收敛一致。这三层不是并列的而是递进的兜底关系。很多人只关注第一层的“删缓存顺序”忽略了第二层的补偿和第三层的过期时间所以才会在故障发生时被打得措手不及。真实的生产环境里网络抖动、GC 停顿、Redis 主从切换这些不可控因素无处不在你唯一能做的就是让系统在局部失败后依然有路可走。4.2 核心代码实现事务后删缓存加 MQ 重试以 Spring Boot 项目为例我给出一套我在项目里实际使用的代码骨架。首先是 Service 层正常的更新逻辑加上事务提交后的缓存删除。Service public class ProductService { Resource private ProductDao productDao; Resource private RedisTemplateString, Object redisTemplate; Resource private RabbitTemplate rabbitTemplate; Transactional public void updateProduct(Product product) { // 1. 更新数据库 productDao.updateById(product); // 2. 事务提交后先直接删除缓存同时发送一条 MQ 消息兜底 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { String key product: product.getId(); try { redisTemplate.delete(key); } catch (Exception e) { // 本地删除失败不抛异常把任务交给 MQ log.error(delete cache failed, key{}, key, e); } // 无论如何都发送一条删除缓存的消息消费者端做幂等 rabbitTemplate.convertAndSend(cache.delete.exchange, cache.delete, new CacheDeleteMessage(key)); } }); } }有人可能会问既然已经同步删了一次为什么还要发 MQ 消息原因是同步删除存在失败的可能而且删除动作发生在事务提交后如果这个线程后续宕机同步删除就丢了。MQ 消息相当于一个“存根”消费者收到消息会再次执行删除即使前一次删除已经成功重复删一次也无害。关键是消费者端要支持幂等同一个 key 删除多次不会产生副作用。消费者代码如下Component RabbitListener(queues cache.delete.queue) public class CacheDeleteConsumer { Resource private RedisTemplateString, Object redisTemplate; public void onMessage(CacheDeleteMessage message) { String key message.getKey(); try { redisTemplate.delete(key); } catch (Exception e) { log.error(mq delete cache failed, key{}, key, e); // 抛异常让 MQ 重试或者手动重试几次后进入死信队列 throw new AmqpRejectAndDontRequeueException(e); } } }这里的核心思想是能用同步删除解决的事情当场解决解决不了的事情交给异步重试。不要把宝全部押在同步代码上那太脆弱了。4.3 缓存过期时间到底怎么设过期时间是个非常值得认真对待的参数。设短了缓存命中率下降大量请求穿透到 MySQL缓存的意义被削弱设长了一旦删除补偿失效脏数据存活时间被拉长一致性风险变大。我的建议是先明确业务对数据新鲜度的容忍阈值然后在这个阈值内尽可能设长。比如一个商品详情接口运营修改价格后5 分钟内用户看到新价格是可以接受的那过期时间就设 5 分钟。但要注意这里的 5 分钟是“最大容忍时间”正常情况下通过删缓存用户几乎瞬间就能看到新值只有删除失败且补偿也失败时才会出现最坏情况——旧值存活 5 分钟。另一种做法是“动态过期时间”热点数据短过期非热点数据长过期。比如根据 key 的访问频率动态调整过期时间访问频率高就延长访问频率低就缩短。这个思路在大型系统里比较实用但实现成本也更高需要额外记录访问统计。初期的项目建议直接固定过期时间先跑稳再说。另外不管是读回填还是写删除都要尽可能避免缓存穿透。如果数据库里没有这个 id查出来是 null千万不要直接不写缓存否则每次读都会打到数据库。正确做法是把空值也缓存起来设置一个较短的过期时间比如 1 分钟这样即使数据不存在也能挡住大部分穿透查询。4.4 回填缓存时的版本号校验延迟双删的局限根本原因是“旧值回填成功后没办法自动失效”。一个更优雅的思路是给缓存数据加版本号。每次数据库更新时生成一个递增的版本号可以用更新时间戳也可以用数据库行版本号。回填缓存时把版本号和数据一起写进去。下一次回填时先比较当前缓存里的版本号如果待写入的版本号更旧就直接放弃写入。这就避免了旧值覆盖新值。这个思路相当于给缓存加了一道“版本校验闸门”即使并发场景下旧值晚到也因为它版本号低而无法写入。相比延迟双删这种靠“猜时间”的做法版本号方案在逻辑上更严密只是需要业务字段支持或者额外维护一个版本号字段。public Product getProductWithVersion(Long id) { // 从缓存读取带版本号的数据 CacheProduct cache redisTemplate.opsForValue().get(product: id); if (cache ! null) { return cache.getProduct(); } // 从数据库读取 Product product productDao.getById(id); if (product null) { return null; } // 回填前先检查版本号避免旧值覆盖 Long currentVersion redisTemplate.opsForHash().increment(product:version: id, version, 0); if (currentVersion ! null currentVersion product.getVersion()) { // 缓存里已有更新版本跳过回填 return product; } redisTemplate.opsForValue().set(product: id, new CacheProduct(product, product.getVersion()), 10, TimeUnit.MINUTES); return product; }这个方案在工程落地时要注意版本号的生成和传递需要上下文。如果业务表本身没有 version 字段需要额外增加如果更新操作发生在多个服务版本号还需要统一来源否则不同服务生成的版本号可能冲突。4.5 上线前要做的验证方案写好了别急着上线先做几轮验证。第一是并发读写压测用多线程工具模拟同一 key 的并发读写观察较长时间内是否出现缓存旧值。第二是故障注入测试故意让 Redis 删除操作失败比如断开 Redis 连接、给删除操作加超时限制观察 MQ 补偿是否生效系统是否出现不可用。第三是主从切换演练模拟 Redis 主从故障切换看补偿链路是否能正常工作。这些验证不需要一次做得很重但至少要跑通核心链路。很多上线后才发现的问题其实在测试环境就能提前暴露。既然选择了最终一致性方案就应该主动验证系统在“局部失败”下的表现而不是只测“一切正常”的 happy path。5. 我踩过的坑常见问题与排查技巧5.1 删了缓存还是读到旧值这是我被问得最多的问题。代码里明明执行了 redisTemplate.delete但页面还是旧数据。仔细排查后通常有几种可能。第一种是删缓存时用的 key 和读缓存时用的 key 不一致比如一个拼接了前缀另一个没有拼接一个用了不同的序列化器导致 key 实际存储形式不同。第二种是删除缓存后有并发线程把旧值回填了这在“先删缓存后更新 DB”或者“事务未提交就删缓存”的场景下特别常见。第三种是删除操作执行了但删除的是 Redis 主节点读请求走的是从节点主从复制延迟导致从节点还是旧值。排查思路也很固定先在 Redis 里查这个 key 是否存在value 是什么TTL 还剩多少再比对数据库当前值和缓存值判断哪个是旧值然后看应用日志里删除缓存有没有被成功执行执行时间和业务更新时间差了多少。绝大多数问题沿着这个路径都能找到根因。5.2 事务没提交就删缓存这个坑特别隐蔽。前面我提到过Spring 的 Transactional 事务是在方法返回后才提交如果你在事务方法内写了删缓存代码这个删除动作发生在事务提交之前。此时如果有读请求进来缓存未命中去数据库读到的还是旧数据然后回填缓存。等事务提交后数据库是新值缓存是旧值。这个问题在并发量不高的系统里可能永远不出现但一旦出现就很迷惑因为你看到代码顺序是对的先更新再删除。实战中我的做法是凡是涉及缓存删除的地方一律用事务同步器的 afterCommit 回调不要直接写在事务方法体里。还有一种变体是事务方法里先发了一条 MQ 消息造成消息发出去了但事务后来回滚了。这种情况也要小心消费者收到消息后可能去删除缓存但数据库并没有更新导致缓存被无端清掉增加了缓存穿透压力。MQ 消息尽可能放在事务提交后发送可以参考事务同步器或者 Spring 的 TransactionalEventListener。5.3 主从同步延迟把旧数据回填到缓存读写分离架构下这个坑更容易踩。更新操作写入 MySQL 主库读操作可能走从库。假设主库更新成功缓存也删除了但一个读请求此时打到从库从库还没同步到最新数据读到了旧值回填到缓存。用户刷新页面发现缓存里还是旧数据。这种问题在“先更新 DB 再删缓存”的方案里依然存在只是触发条件更苛刻。解决办法有几个方向一是延迟双删的第二次删除等主从同步完成后再执行二是对即时性要求特别高的读请求强制走主库三是在回填缓存时做版本校验从库读出来的旧版本数据不允许覆盖缓存里的新值。最稳妥的还是第三点配合版本号校验能把这个窗口堵上。5.4 Redis 序列化问题导致缓存数据异常Redis 本身不关心你存进去的是什么格式但 Spring Data Redis 的 RedisTemplate 默认使用 JdkSerializationRedisSerializer会把对象序列化成一堆二进制数据。这样有两个坏处一是可读性差用 redis-cli 查缓存看到的是乱码二是 key 会被加上序列化前缀导致你用肉眼看到的 key 和代码里拼接的 key 不一致排查问题时容易产生误解。更严重的是如果你在 A 服务用 RedisTemplate 写入缓存B 服务用 StringRedisTemplate 读取或者两个服务的序列化器不一致就会读到反序列化失败的数据。这类问题的排查也比较费劲因为问题往往不在业务逻辑而在底层序列化配置。我的建议是项目中统一 RedisTemplate 的序列化策略key 使用 StringRedisSerializervalue 使用 GenericJackson2JsonRedisSerializer并保证所有服务配置一致。这样不仅能避免数据乱码还能大幅降低排查难度。Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 使用字符串序列化器 template.setKeySerializer(new StringRedisSerializer()); // value 使用 Jackson JSON 序列化器 template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; }5.5 常见问题排查速查表现象可能原因排查思路解决方案删了缓存还是旧值key 不一致 / 并发回填 / 主从延迟查缓存 TTL、比对数据库值、看日志统一 key 规则、版本号校验、延迟双删事务回滚但缓存被删了删缓存代码写在事务体内看事务边界和删除代码位置用 afterCommit 回调删除缓存数据反序列化失败序列化器不统一查看 Redis 里存的数据格式统一 RedisTemplate 序列化策略删除缓存失败无人处理没有补偿机制看日志是否有删除异常被吞掉MQ 重试 定时任务补偿修改数据后长时间不一致过期时间太长查缓存剩余 TTL缩短过期时间建立快速失效通道这个速查表是我在实际运维中反复用到的建议你收藏起来遇到类似问题时可以对照着排查。6. 缓存一致性这件事我还想多说几句做了这么多年后端我越来越有一个体会数据库和缓存的一致性本质上是一个“取舍”问题而不是一个“消灭”问题。追求强一致就要付出性能代价追求高性能就要接受最终一致。真正的高手不是能找到一种“完美方案”而是能根据业务特点在性能、一致性、复杂度之间找到那个最合适的平衡点。我在实际项目中的默认组合是Cache Aside 模式 事务提交后删缓存 MQ 重试兜底 合理过期时间。这套组合应对绝大多数业务都够用而且实现成本不高。如果遇到强一致场景我宁可把缓存去掉也不愿意为了表面上的“高性能”把一致性风险埋在系统里。缓存是加速器不是救命稻草别让缓存问题变成压垮系统的最后一根稻草。最后再分享一个小技巧每次发布涉及缓存逻辑的版本时我都会在发布后的半小时内主动盯着 Redis 的删除成功率、缓存命中率和错误日志。如果发现某个 key 的删除出现超时或失败立刻查一下是不是新增的缓存代码引入了问题。这种“发布后观察期”的习惯帮我提前规避了好几次线上事故。希望对你有用。