首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
MySQL与Redis缓存一致性实战:从延迟双删到最终一致
📅 2026/10/13 15:38:52
✍️ 爱科研究院
👁 阅读 3,247
缓存这东西用起来是真的顺手。接了个大流量接口查一次 MySQL 可能要几十毫秒扛不住压加一层 Redis 后直接变成 1 毫秒以内。但只要涉及到写操作头疼的事就来了MySQL 里已经是最新值Redis 里却还是旧的。你在搜“mysql 面试题”的时候大概率见过这个问题团队里那些老哥也天天把“延迟双删”“缓存一致性”挂在嘴边。真到了自己设计方案的时候才会明白没有一种方案能让 MySQL 和 Redis 在所有时刻都完全一致我们能做的只是在性能和一致性之间找到一个适合自己的最终一致方案。这篇文章我不打算讲教科书式的理论我会把线上真正会遇到的那些坑、几个可以落地的方案、还有排查步骤系统地说一遍。1. 数据不一致到底是怎么发生的先弄清敌人在哪1.1 缓存引入后数据库和缓存天然就差一拍正常情况下读请求会先打到 Redis命中就直接返回没命中才去查 MySQL查完把结果填回 Redis。于是问题来了MySQL 完成事务提交之后Redis 里可能还躺着旧值。哪怕你做了同步删除只要删除命令和读回填命令之间有一点点重叠这个旧值就可能重新跑到缓存里去。说到底你在系统里引入了第二份数据副本就必然要接受副本之间有时间差。这个时间差就是无数一致性事故的源头。你可以把 MySQL 当成账本Redis 当成一块小黑板黑板上的字总是比账本慢一拍。怎么把这一拍缩到最小甚至在某些环节消除掉就是今天要聊的核心。1.2 三种常见写顺序为什么都会捅娄子不少人第一次设计缓存的时候会本能地想先更新数据库再更新缓存。比如用户改完头像先 UPDATE MySQL再 SET Redis两条都执行完不就行了但并发场景下马上会翻车。假设线程 A 和线程 B 同时改同一个 keyA 把数据库改成值 1B 把数据库改成值 2然后 B 先把 Redis 更新成 2A 再把 Redis 覆盖成 1。最终数据库是 2缓存却变成了 1老线程把新值覆盖成旧值数据在很长一段时间里都处于错误状态。还有人是先更新缓存、再更新数据库这条路径看着快实际上更危险。缓存更新完了数据库事务如果提交失败缓存里就留下来一条永远不会被纠正的假数据。除非之后有别的写操作再删一次否则旧值就一直摆在 Redis 里。第三种是先删缓存、再更新数据库这是“延迟双删”的基础思路但删完缓存到数据库提交完成之间只要有一个读请求把旧值回填了数据库新值提交之后缓存里还是旧值。核心问题都一样写数据库和写缓存不是原子操作不管谁先谁后只要两个动作之间插入了一个读请求就会产出一段不一致窗口。1.3 一致性不是单选项先分清楚业务要哪种在分布式系统里谈“强一致”往往需要引入分布式事务、两阶段提交、共识算法成本极高普通业务完全吃不消。绝大多数系统的现实选择都是“最终一致性”也就是允许中间一小段时间读到旧值但要在 N 毫秒或 N 秒内收敛到最新状态。这里的取舍取决于业务对旧值的容忍度。商品详情页、文章内容这类读多写少的数据缓存里旧个几百毫秒用户基本无感用最简单的 Cache Aside 加上过期时间就够了。但如果是余额、库存、订单状态这些数据用户看到旧值就可能会投诉甚至造成资损那就不能用三脚猫方案得考虑 binlog 订阅、分布式锁、消息补偿组合在一起用。做技术选型时先别急着抄别人的方案而是要先问一句这个业务到底能不能容忍短时间不一致能容忍多久把这个答案定下来后面每一步才有依据。2. 最主流的 Cache Aside 策略删缓存而不是更新缓存2.1 为什么大家都愿意删除缓存而不是更新缓存很多初学者都会问同一个问题既然要同步为什么不把 MySQL 查出来的结果直接写回 Redis我见过不少人用 update 缓存的方式后来都改成了 delete。原因很简单更新缓存这件事在大多数场景下都是亏本买卖。第一更新缓存要先查出最新的完整数据再序列化成 JSON 或某种结构写进 Redis。如果这是一个复杂的聚合查询每次一条 update 语句落库你都要重新把多张表关联查一遍然后写缓存成本比单纯 del 一个 key 高太多。第二读多写少意味着数据可能被改十次但只被读一次前九次更新缓存全是白干。与其反复更新不如删掉让下次读的时候再回填。第三并发覆盖问题极其难搞。两个线程同时更新缓存数据库提交快的线程可能先执行更新缓存提交慢的线程后执行恰好把新值盖成了旧值而删除缓存不存在覆盖问题删掉就是删掉下次读自然会把库里最新的值查回来。所以 Cache Aside 模式的写路径最终都会收敛成同一个动作删缓存而不是更新缓存。2.2 标准 Cache Aside 流程代码长什么样Cache Aside 的读路径很直白先读 Redis命中就返回没命中就查 MySQL查到后写回 Redis同时设置过期时间。写路径也简单先更新 MySQL事务提交成功后删除 Redis 里的 key。这里有一个极其容易踩的细节删除缓存一定要放在事务提交之后。如果事务还没提交就删缓存读请求回填的还是事务提交前的旧数据等于白删。如果事务执行到一半删缓存事务回滚了缓存没了但数据库没变虽然不会产生脏数据但也白白增加了一次缓存穿透。所以代码上最好把 del 命令放到事务提交后的回调或者 finally 块里而且要保证“数据库成功”这个前置条件绝对成立。我见过有人在事务回调里忘了判断提交状态事务明明回滚了缓存照样被删掉后续读请求又把旧值查回来倒是不会出大问题但性能损耗很冤枉。2.3 先删缓存再写库的竞争窗口脏数据怎么溜进来的先删缓存再写库也叫延迟双删的前半段。执行流程是这样的线程 A 先删除 Redis 里的 key然后去更新 MySQL。在 A 更新数据库的过程中线程 B 发起了读请求缓存没命中于是从 MySQL 查出旧值回填到 Redis。等 A 更新完数据库Redis 里留下的却是 B 刚回填的旧值而且这个旧值带着新的过期时间短时间内不会自动消失。用户在这个窗口里读到的数据就是脏的。这个窗口有多大取决于“线程 B 从缓存未命中到完成回填”的耗时。运气好可能只有几毫秒运气不好碰上慢查询加网络抖动能到几百毫秒甚至一秒。所以网上才流行“延迟双删”第一次删缓存更新数据库后阻塞一小段时间再删一次缓存把窗口期被回填的旧值清掉。第二次删除不能太早要等所有可能读到旧值的读请求都完成回填之后再删一遍才有效果。2.4 延迟双删的延时到底应该设多少网上很多文章会直接给你一个固定值比如 500ms 或 1s看着方便实际上很不严谨。第二次删除的延时本质上应该由“读请求回填缓存的最坏耗时”决定。你至少要把该接口从 Redis 未命中、查 MySQL、序列化、写回 Redis 的完整链路 P99 时间压测出来再留一定余量。比如数据库查询平均 5msP99 是 80ms那第二次删除设在 300ms 左右就比较合理如果是一个复杂的多表聚合查询P99 到了 500ms延时设 1s 也未必够。建议做法是线上先观察一段时间读回填链路耗时取 P99 乘以 1.5 到 2 倍作为第二次删除的延迟。这里还有另一个坑延时不能设得过大。如果第二次删除太晚期间已经有一个读请求把数据库里的新值回填到了 Redis第二次删除反而会把新值删掉造成一次不必要的缓存穿透。穿透后如果又来一个并发读可能又会回填一个中间值反而放大了不一致窗口。所以延迟双删只能算概率性补偿不是银弹真正要做得更稳还得看下一节的方案。提示延迟双删的“延迟”背后必须有自己的压测数据支撑不要从网上抄一个固定值就上生产。缓存这种表面简单的东西出问题往往都是细节参数没校准。3. 删除失败与并发写多实例时的补救方案3.1 缓存删除失败后靠重试队列兜底写业务代码的时候大多数人默认 Redis 的 del 命令一定会成功。实际上 Redis 超时、连接池打满、应用进程重启都可能让删除动作没有真正执行。如果缓存 key 的过期时间设置的是一天那这个旧值就能活一天。所以成熟的系统里缓存删除不能只做一次而是要把删除动作当成一个可靠消息来对待。轻量做法是把待删除的 key 放进一个本地内存队列后台线程不断消费执行删除失败就重试连续失败超过阈值就告警。单机部署时这样够用多机部署时最好发给消息队列由专门消费者负责删除避免每台机器重复执行导致 Redis 压力过大。重试队列里的任务要带上操作时间戳因为删除动作可能会晚于别人的新写入到达。如果 key 已经被新请求重新设置过了你拿着旧任务去删会把新值误删掉。这个细节不处理重试机制反而会变成新的脏数据制造机。3.2 订阅 MySQL binlog把同步缓存的活交给异步任务延迟双删和删除重试都属于“事后补偿”根本问题在于业务代码里手动管理删除动作总有考虑不到的分支。更工程化的方案是订阅 MySQL binlog把数据库变更事件作为同步缓存的触发器。实现上一般用 Canal 或 Maxwell 这类组件伪装成 MySQL 从库去拉取 binlog解析出每一行的 insert、update、delete 事件再把事件投递到消息队列下游消费者根据事件执行 Redis key 的删除或更新。这个方案最大的好处是把“写数据库”和“清缓存”彻底解耦了。业务代码只要保证 MySQL 提交成功binlog 事件就一定会产生。哪怕应用进程挂了Canal 的消费位点还能接着往下走。坏处是要多维护一套数据同步链路而且从 binlog 产生到下游消费会有一段延迟可能几百毫秒甚至几秒。所以它适合对最终一致要求比较高的系统比如订单、库存数据但并不适合那种要求“更新后 10 毫秒内读不到旧值”的场景。不过对于 99% 的互联网业务秒级收敛已经够好了。3.3 分布式锁与读写串行化牺牲一点性能换一致性缓存回填时的并发读是制造旧值的一个高发场景。多个线程同时发现缓存没命中同时去查 MySQL查出来可能都是同一个旧值其中一个线程最后回填就会覆盖掉别的线程已经写入的新值。对这个问题的根治办法是给同一个 key 的回填逻辑加分布式锁。拿到锁的线程去查库回填没拿到锁的线程等一下再读缓存让并发回填变成串行执行。但分布式锁本身也有很多坑。锁的粒度必须按业务 key 来分不能用一把全局锁锁住所有缓存否则 Redis 性能会瞬间被拖垮。锁的过期时间也要合理比如设置 200ms 到 1s如果回填逻辑执行时间超过锁的过期时间锁提前释放其他线程会拿到锁重新查询等于没锁。还有 Redis 主从切换时锁丢失的问题锁写入主节点后还没同步到从节点主节点挂了另一个线程就能拿到同一把锁。所以真要拿分布式锁来保缓存一致性优先考虑 Redlock 这类更严谨的实现而不是随手写一个 SETNX 就当完事了。3.4 消息队列加最终一致性适合写多读少的场景有些业务天然能接受短时间不一致。比如用户修改昵称你可以先更新 MySQL再发一条消息给消费者消费者去重建 Redis 缓存。这个方案走的是“主动发消息”的路线实现起来比 binlog 订阅简单但对业务代码有入侵每一处写操作都要显式发送消息。用消息队列做最终一致要重点处理两个问题消息丢失和重复消费。普通消息队列并不保证不丢消息如果这一步丢了缓存就永远不会更新所以最好用 RocketMQ 的事务消息或者 Kafka 配合幂等消费来兜底。消费者拿到消息后执行删除时同样要判断 key 当前的值是什么时候写入的不能盲目删除新值。这里我会建议把“更新时间”或“版本号”带在消息体里消费者比对之后再决定删不删。这套方案在写少读多的场景下非常好用更新频率低消息量小消费者压力也不大。4. 真实环境最容易翻车的几个点4.1 多缓存实例部署时别把广播删除给忘了很多人本地拿单机 Redis 联调怎么测都是好的一上生产就发现缓存里的数据忽新忽旧。排查半天才发现生产环境 Redis 是分片集群同一个 key 通过哈希分布到不同节点。你执行 del 的时候只删了客户端当前连接到的节点其他节点上的旧值还活得好好的。如果系统里还用到多级缓存本地 JVM 缓存和 Redis 同时存在删除动作没有覆盖到所有层级也会出现用户请求被负载均衡打到不同节点后一会儿读到新值、一会儿读到旧值的情况。正确的做法是删除缓存时要么通过一致性哈希找到所有可能承载 key 的节点逐个删除要么在架构层面给缓存引入版本号用版本号判断失效而不是单纯依赖 del 命令。这个问题在面试里也经常被考察很多人提到延迟双删就以为答完了其实多实例广播删除才是线上事故的高发细节。4.2 主从复制延迟和过期时间共同决定脏数据存活窗口MySQL 主从复制有延迟Redis 主从复制也有延迟而这两层延迟叠加在一起会把一致性窗口拉得很大。假设你更新了 MySQL 主库删除了 Redis 主节点的缓存但读请求打到了 Redis 从节点从节点还没来得及同步删除命令读到的是旧值。更常见的是读请求打到了 MySQL 从库从库的 binlog 还没应用完查出来就是旧数据然后这个旧数据又被回填到缓存。想要压缩这类窗口可以给刚更新过的 key 打一个版本标记强制这轮读请求走主库也可以把缓存过期时间调短一点让旧数据在一个过期周期内自我纠正。MySQL 主从复制的基础配置并不复杂主库在 my.cnf 里开启 log-bin 并设置 server-id创建复制账号从库执行 CHANGE MASTER TO 指向主库然后 START SLAVE。但配置简单不代表延迟可控主从延迟超过 1 秒时缓存里的旧值就会存活超过 1 秒。所以关键业务数据要么强制读主库要么接受秒级延迟次要业务则完全可以用短过期时间来兜底。4.3 雪崩、穿透、击穿会怎样放大不一致缓存雪崩是大面积 key 同时过期穿透是查询了不存在的 key击穿是单个热点 key 过期后大量线程同时打向数据库。三个问题看起来和一致性是两码事但它们会间接放大不一致窗口。举个简单例子大量缓存 key 同时失效瞬间请求全部打到 MySQL数据库负载升高查询变慢缓存回填也跟着变慢。原本几十毫秒内能收敛的不一致可能被拉长到几秒钟。如果热点 key 击穿多个线程同时回填同一个 key没有锁控制后回填的线程拿到旧值就会把别人填好的新值覆盖掉。方案其实也简单设置缓存过期时间时要加随机偏移比如在基础过期时间上增加 10% 的随机数避免整片 key 同时失效回填热点 key 时用互斥锁或 SingleFlight 机制只让一个线程承担查询回填任务。缓存稳定性上来了一致性窗口也会被压缩得小得多。4.4 一致性核对别等用户投诉要主动跑对账再完善的同步方案也扛不住极端情况网络分区、Redis 持久化丢失、代码漏删。所以生产环境必须要有一道对账兜底。最常见的做法是每天或每小时扫描一批业务主键从 MySQL 取出最新值和 Redis 里的值做 diff发现不一致就告警并自动修复。对账任务不能让数据库和 Redis 都瞬间被扫垮key 要分片处理每次只扫一部分对比时优先用批量接口比如 MGET 一次拿几百个 key不要写循环一次查一个。对账任务检测到的不一致数量就是你缓存同步链路健康度最直观的指标。如果某天手机突然收到大量不一致告警说明删除重试或者 binlog 消费链路出了问题。这时候介入修复比等用户投诉进来再排查要舒服得多。5. 一次线上“缓存和库对不上”的排查实录5.1 现象用户看到余额刷新很慢我曾经接手过一个余额系统的问题。现象是用户在某渠道页面上看到的余额一会儿旧一会儿新MySQL 里扣减已经成功了但 Redis 里查出来还是扣减前的旧值而且这个旧值持续了几十分钟。当时线上用的是 Cache Aside 加延迟双删缓存 key 没有设置过期时间删除缓存的动作由业务代码同步执行。从现象看应该不是简单的网络问题更像是有旧值被反复回填或者删除命令根本没生效。5.2 排查步骤从 Redis 到 binlog 按链路一层层理我第一步是打开 Redis Desktop Manager找到对应 key确认 TTL 显示为 -1。这个发现很关键key 不会自动过期所以只要脏数据进去了就没有机制能把它清走。第二步看应用日志搜索删除缓存的日志记录发现第一次删除之前确实有一个读请求进入到了“查 MySQL”的阶段而且它的回填动作发生在删除命令之后。也就是说并发读把旧值重新写回了 Redis。第三步看延迟双删的配置发现第二次删除的延时被写死成 200ms但该接口的读回填链路 P99 其实到了 600ms200ms 完全盖不住回填窗口。第四步查 binlog确认这段时间里 MySQL 主从也有复制延迟部分读请求打到从库拿到的是延迟同步的旧值。多因素叠加在一起最终导致了持续半小时的不一致。这个问题修起来不复杂给 key 加上 10 分钟过期时间把延迟双删的延时从 200ms 调到 1 秒增加删除失败后的消息重试再给回填逻辑加上分布式锁。数据很快就恢复正常了。这个案例给我的教训是排查缓存一致性时不能只看删除命令有没有执行一定要看回填动作发生的时间点和主从同步的延迟情况。顺序错了一个环节问题就定位不出来。5.3 常见问题速查表为了帮你快速定位我整理了一张日常排查表基本覆盖了最常见的几种不一致现象。现象可能原因排查重点解决方案缓存一直是旧值很久不变key 没设置过期时间删除失败未被重试看 TTL 和删除日志设置过期时间删除走重试队列缓存忽新忽旧不稳定多实例或多级缓存只删了部分节点确认删除是否广播到所有节点统一删除调用点引入版本号刚更新完立刻读到旧值MySQL 或 Redis 主从复制延迟看请求路由和主从延迟关键数据强制读主库高并发下偶发旧值并发读回填覆盖新值看回填线程时序日志加分布式锁或延长双删延时删除后缓存马上又有旧值删除命令执行时正好有并发读回填从日志时间轴看回填点延迟双删 binlog 消费清理缓存和库一直差一个字段更新 DB 后缓存删除逻辑漏执行核对业务代码分支统一在事务提交后删除提示所有排查工作都建议先把 key 的过期时间和操作日志拉出来按时间轴画一条线。一致性问题的本质是时序问题时间轴画清楚了原因基本就藏不住了。6. 我的一点个人体会做缓存治理这些年我最大的体会是MySQL 和 Redis 的数据一致性没有一劳永逸的银弹任何方案都是在性能、成本和一致性之间做妥协。小流量业务用 Cache Aside 加过期时间就够了不要为了追求理论上的绝对一致引入一堆没人维护的重型组件中大型业务再逐步考虑延迟双删、删除重试、binlog 订阅同时加一套对账任务兜底。有一点我想特别强调缓存 key 一定要设置过期时间这是最后一道保命符。哪怕所有同步手段全部失效过期时间也能把脏数据的存活范围控制在一个有限周期里。我自己踩过太多次“忘了设置 TTL 导致旧值活了好几天”的坑所以现在写任何回填缓存代码第一件事就是给 key 设置一个符合业务容忍度的过期时间。什么分布式锁、消息队列、binlog 再花哨都不如这一行过期时间在故障时能救命。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/13 15:33:49
强化学习速查表深度解析:从 Bellman 方程到蒙特卡洛与时序差分算法
2026/10/13 15:33:49
中位数插值计算原理与工程实践:从直方图到t-digest
2026/10/13 15:33:49
MySQL数据可视化:聚合建模、技术选型与性能优化实战
2026/10/13 21:36:41
企业AI本地化部署:从需求判断到落地实践的完整指南
2026/10/13 21:36:41
从CRUD到模块化:后端领域拆分的演进路径与实战方法
2026/10/13 21:36:41
开源工作流助手深度实测:从部署到任务编排核心功能解析
2026/10/13 21:36:41
从CRUD到领域拆分:后端模块化架构演进之路
2026/10/13 21:36:41
通达信缠论K线包含处理插件源码:从合并算法到分型笔的完整实现
2026/10/13 21:31:40
JCache CacheLoader:load与loadAll如何配合getAll实现批量回源?
2026/10/13 0:00:40
物联网系统集成避坑指南:从选型到验收的关键要点
2026/10/13 0:00:40
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查
2026/10/13 0:00:40
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析
2026/10/13 12:25:09
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/13 18:15:04
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/13 8:14:22
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/12 19:53:23
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/11 21:41:11
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/11 23:43:10
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)