
MySQL 和 Redis 这对组合在任何一个有点流量的后端项目里几乎都是标配。但只要是双写一致性就一定跑不掉——缓存里是旧值、数据库里是新值或者反过来用户看到的数据一会儿对一会儿错。这篇文章我就结合自己多年的实操经验把 MySQL 和 Redis 的数据一致性问题的根源、常见方案、踩坑记录一次说清楚尤其适合正在做缓存架构、或者被缓存脏数据折磨过的后端开发。1. 先理解问题根源为什么 MySQL 和 Redis 会不一致很多同学遇到的第一个困惑是我只是把 Redis 当缓存用数据最终都在 MySQL 里怎么可能不一致问题恰恰出在“读”和“写”是两条路径只要并发一上来路径交错就出乱子。1.1 缓存与数据库天然存在“双主”问题MySQL 是持久化存储Redis 是基于内存的缓存两者是两套独立的存储系统不存在事务上的强绑定。业务代码通常的做法是写请求先更新 MySQL再更新或删除 Redis读请求先查 Redis查不到再回源 MySQL。这个流程本身没问题但并发场景下两条线程的执行顺序可能完全颠倒。我举个最经典的例子线程 A 把 MySQL 里的值从 1 改成 2线程 B 在读旧数据时把 1 写进了 Redis。这时候 Redis 里是 1MySQL 里是 2A 还没来得及删缓存或者删除的 key 已经被 B 重新写入覆盖脏数据就留在了缓存里。越是高并发的系统这种交错概率越高因为读写请求几乎同时在发生。1.2 不一致的影响面从商品价格到库存扣减不一致的影响要看业务容忍度。展示类的数据比如用户昵称、文章浏览量稍微延迟几秒用户感知不明显。但交易链路就不一样了价格、库存、订单状态这些数据一旦读到旧值轻则用户体验差重则直接资损。我之前处理过一个线上事故后台运营把某个商品的限时折扣改了MySQL 更新成功后缓存没及时失效结果用户端展示的还是旧价格用户提交订单时后端校验用的是 MySQL 新价格但页面显示的是旧价格大量用户投诉“价格不符”。最后排查发现就是缓存更新逻辑写反了先删缓存后更新数据库在并发期间有请求把旧值填回去了。这个案例让我意识到缓存一致性不是纯技术问题而是实打实的资金安全和人效问题。2. 几套主流一致性方案各自的取舍在哪解决 MySQL 和 Redis 数据一致性业界有几个经典范式Cache Aside、Read Through、Write Through、Write Behind。很多人把这些方案挂在嘴边但真正要落地的时候还是要理解每一步操作的顺序为什么这么设计。2.1 Cache Aside应用最广的“旁路缓存”策略Cache Aside 的核心思想是读的时候先读缓存缓存没有就去数据库读读回来后放入缓存写的时候先更新数据库然后删除缓存。很多团队称它为“先更新库再删缓存”模式。为什么是“删缓存”而不是“更新缓存”因为更新缓存有竞态风险两个并发写请求 A 和 BA 先把数据库改成了 2B 把数据库改成了 3假设 B 先更新缓存把 3 写进去A 后写缓存但把 2 覆盖进去缓存就永久保留了旧值。删除缓存则不同下次读请求会强制回源数据库拿到的一定是最新的值。所以 Cache Aside 里“删”比“写”更安全这是整个方案的灵魂。2.2 Read Through 与 Write Through把一致性交给缓存组件Read Through 和 Write Through 是让缓存组件作为数据访问的“唯一入口”应用不直接操作数据库。读数据时缓存没有由缓存组件自己加载数据库内容写数据时也先写缓存由缓存组件同步写数据库。这套方案的好处是业务代码简单一致性逻辑收敛在缓存组件的内部实现里。但坏处也很明显它要求缓存组件具备较强的中间件能力不是每家公司都有精力自研市面上的开源产品要么没实现要么实现得不够成熟。实际落地时我很少在中小团队见到真正用 Write Through 的更多是直接上 Cache Aside 加上业务侧兜底。2.3 Write Behind异步写回性能高但一致性弱Write Behind 又叫异步写回数据先写 Redis再由后台任务异步刷到 MySQL。性能极高但风险也极高万一 Redis 宕机且没来得及刷盘数据就永久丢失。所以在金融、交易这类强一致场景我基本不建议用但如果是点赞数、浏览数这类允许丢了也无所谓的计数场景用 Write Behind 反而是性价比最高的选择。3. 双写一致性实操顺序、延时与兜底方案定下来之后真正的细节全在操作顺序上。这一步顺序错了后面全都白搭。我把自己验证过的一套流程拆开讲附带边界情况和注意事项。3.1 先更新数据库还是先删缓存这个问题的标准答案优先选择“先更新数据库再删缓存”。原因我在 2.1 已经提过更新缓存有竞态问题删除缓存天然避免了旧值覆盖新值的问题。但这里有一个很多人忽略的前提删除缓存这个动作必须成功。如果缓存删除失败Redis 里的旧值依然存在下一次读请求还是会命中旧数据。所以代码里必须有重试机制最好是结合消息队列或者定时任务做兜底。我见过太多团队只写了 delete 一行代码没有处理失败分支等线上出问题才发现缓存里全是旧值。3.2 延迟双删怎么用才不被反噬延迟双删是热门方案核心是在更新数据库之后先删一次缓存然后休眠一小段时间再删一次缓存。第一次删除是保证后续读请求能拿到新数据第二次删除是为了干掉在“第一次删除后、休眠期间”被旧值填回去的缓存。这里的坑在于休眠时间怎么定。如果时间太短并发线程还没来得及把旧值写回第二次删除就执行了等于白删如果时间太长影响性能。我一般建议休眠时间取“业务读请求的平均耗时 200ms”左右比如读请求平均 50ms就休眠 250ms。这并不严谨但实际工程中很好用至少能把脏数据的窗口压缩到可接受范围。还需要注意一个额外情况在 MySQL 主从架构下如果先更新从库还未同步完成读请求回源时可能读到旧值再写回缓存这也会造成不一致。这种情况下延迟双删只能缓解不能根治真正的解法是把读写链路也做路由或者接受短时间内的弱一致。3.3 基于 Binlog 订阅的最终一致方案如果要求更强的一致性我推荐用 binlog 订阅的方式把“删缓存”的操作从业务代码里拆出来。MySQL 的 binlog 会记录所有数据变更通过 Canal 或 Debezium 等组件订阅监控到变更后主动清理对应的缓存 key。这个方案的好处是业务代码无侵入原生解决了“先更新库再删缓存”中删除失败、重试困难的问题。坏处是需要额外部署一套组件增加了架构复杂度。我的经验是只有核心交易链路或数据一致性要求很高的场景才值得上普通的用户资料、商品信息用延迟双删加重试就足够了。4. 我在真实项目里的几种落地取舍理论讲一堆不如看具体场景怎么选。我挑三个自己做过的典型需求把当时的取舍过程和关键参数写出来。4.1 场景一读多写少的商品详情页商品详情的特征是读并发极高、写频率低、数据偶尔改一次。这种场景我选了 Cache Aside具体参数如下缓存过期时间设置为 2 小时更新数据库成功后主动删除缓存删除失败走消息队列重试最多重试 3 次。这里出现过一个小问题删缓存和更新数据库不是原子操作删缓存失败后用户继续读到旧商品信息。虽然订单提交时有二次校验但用户看到的价格是错的体验很糟糕。后来我把删除失败的日志接到告警并增加了一个 5 分钟定时任务扫描待删除列表才彻底解决。所谓彻底也不是绝对只是把窗口压缩到了秒级。4.2 场景二秒杀库存的强一致场景秒杀库存这种场景我强烈不建议用缓存做主数据源。一开始有同事为了抗住高峰流量把库存直接放到 Redis 里扣减异步回写 MySQL。结果 MySQL 那边经常对不上账对账脚本能找出几百条差异线上客诉不断。后来改成 Redis 只做“预扣减”真正的库存校验和扣减以 MySQL 事务为准。也就是说 Redis 的扣减结果只是一个“资格凭证”每个请求带着凭证去 MySQL 再扣一次。这样一来Redis 帮 MySQL 挡住了绝大部分无效流量MySQL 保证了最终强一致。这个方案对架构改动不大但效果极好秒杀高峰没有出现超卖。4.3 分布式锁与会话级一致性还有一种情况同一个用户在短时间内连续操作比如改了昵称又马上看个人信息如果缓存还没更新用户看到的是旧昵称体验很怪。这时候可以在更新操作上加分布式锁保证同一个用户的读写请求串行化。锁的粒度要控制好不要锁整个 Redis key而是锁用户维度或者订单维度的 key。锁的过期时间一定要设置防止某个节点挂了导致死锁。我习惯用 Redisson 的 tryLock设置 3 秒等待和 5 秒自动释放配合看门狗机制避免锁自动过期后业务还没执行完。5. 常见问题速查这些坑我基本都踩过从事后诸葛的角度我把这几年排查过的问题整理成一张速查表遇到相似症状可以直接对号入座。现象可能原因排查思路解决方向缓存里一直是旧值删除缓存失败且无重试看 Redis 日志、业务日志加消息队列重试 / 延迟双删偶尔新值偶尔旧值并发读写交错并发压测打印线程执行顺序串行化 / 分布式锁数据库主从切换后脏读从库同步延迟监控从库 seconds_behind_master读写路由强一致走主库缓存命中率突然降低删除缓存过于频繁查看缓存删除日志延长缓存过期时间 / 增加更新频率控制缓存穿透到数据库热点 key 过期查看回源 QPS加互斥锁 / 逻辑过期5.1 缓存穿透、击穿、雪崩和一致性有没有关系很多同学容易把缓存穿透、击穿、雪崩和一致性问题混在一起。严格来说穿透、击穿、雪崩讨论的是缓存不可用或失效时对后端的冲击一致性问题讨论的是缓存中的数据与数据库不一致。但实践中它们经常同时出现。比如缓存 key 过期大批请求同时回源数据库这时数据库压力剧增如果后台又有更新操作读请求把旧值写回缓存就既造成了穿透也造成了不一致。所以我在设计缓存时会习惯性给回源操作加互斥锁同一时刻只有一个线程去数据库加载其他线程等待锁释放后直接用缓存。这样既保护了数据库也在一定程度上避免了并发填充旧值的问题。5.2 序列化方式导致的“看起来不对”的缓存有一类诡异问题数据没问题但用户看到乱码或格式不对。这往往不是一致性问题而是序列化方式不一致。比如写缓存时用的 JSON读缓存时用 JDK 原生序列化或者字符串编码不对。排查这种问题最快的方法是用 Redis Desktop Manager 这类可视化工具直接看 key 的原始值。如果看到一串以\xAC\xED开头的字节基本就是 JDK 序列化的锅改成统一的 JSON 序列化就能解决。我当时接手一个老项目就是 RedisTemplate 序列化器配置不一致导致同一个 key 不同端读写互相不认识看起来像是缓存失效其实根子不在这。5.3 MySQL SSL 连接错误与连接池参数引发的连带问题这个和缓存一致性表面上无关但实际会影响数据回源质量。如果 MySQL SSL 连接配置错误客户端可能间歇性连接失败导致读请求无法从数据库加载新数据只能返回缓存旧值。尤其在高并发下连接池里的 idle 连接会被服务端主动断开客户端不自知就会出现偶发的读失败。排查时重点看连接池的testOnBorrow或validationQuery配置生产环境一定要开启连接有效性检查。另外 MySQL 8.x 默认启用 caching_sha2_password 认证老客户端驱动不兼容也会导致连接失败。这虽然不是缓存一致性本身的范畴但属于“回源链路不可靠”引发的间接脏读场景值得留意。6. 我的工程化建议一致性永远分等级最后聊一点个人体会。做技术方案的时候不要追求“绝对一致”而是要问业务到底能容忍多久的不一致。核心交易、支付、库存要求秒级甚至毫秒级一致用户资料、文章详情30 秒内的不一致完全可以接受计数类数据哪怕丢几条也不致命。根据这个等级去选择方案强一致场景用“先更新库 删缓存 binlog 订阅兜底”弱一致场景用 Cache Aside 加定时补偿可丢数据场景才考虑 Write Behind。另外不管选哪种方案务必要加监控和告警把“缓存中存在超过一定时间的脏数据”当成重要指标来观察。我自己现在做新项目最底线的防线是两条一是所有删缓存操作必须有重试和失败告警二是给缓存 key 都设置合理的过期时间即使逻辑上出了漏子过期兜底也能自愈。这两条看起来很朴素但能在绝大多数意外情况下挽回局面。做缓存一致性本质上做的不是高超的架构艺术而是把每一个失败分支都处理干净的工程耐心。