只要项目里同时用了 MySQL 和 Redis,数据一致性问题就绕不开。这两天在梳理一个订单系统的缓存方案,顺手把这块老生常谈但又特别容易踩坑的内容整理成一篇完整的实践笔记。MySQL 负责持久化数据,Redis 扛读流量,两者之间的同步策略、失效时机、异常补偿,每一步都藏着细节。无论你是刚接触缓存的新手,还是在维护高并发系统的老手,这篇内容应该都能给你一些可落地的参考。
我尽量把原理解释得通俗一点,配合我实际踩过的坑和最终采用的方案来写。全文不贴大段代码,重点讲清楚思路和关键配置,因为一致性问题的核心从来不是代码写不出来,而是什么时候该用哪种策略、遇到极端情况怎么兜底。
1. 问题本质:为什么缓存和数据库天生就“不一致”
1.1 根源在于两个存储系统之间没有事务
MySQL 和 Redis 是两个独立的存储系统,各自管理自己的数据。应用代码里对它们进行操作时,没有跨系统的分布式事务来保证原子性——也就是说,“写 MySQL 成功”和“写 Redis 成功”是两件独立的事,任何一个成功、另一个失败,都会造成两边数据不一致。
我用一个最常见的生活例子来解释:想象你在前台登记访客信息(MySQL),同时给访客发一张临时门禁卡(Redis)。登记成功了但门禁卡没发出去,访客进不了门;门禁卡发出去了但登记丢失,访客能进门但系统里没有记录。这两个动作之间没有“要么都成功、要么都失败”的机制,就是你每次都会遇到的问题。
回到技术场景,典型的双写不一致有这几种表现:
- 更新数据库成功了,但删除 Redis 缓存失败,导致旧数据继续被读取;
- 先删除了 Redis 缓存,但数据库还没来得及更新,此时一个请求把旧数据写回了缓存;
- 并发场景下,线程 A 和线程 B 分别读写,执行顺序交错导致缓存中最后存入了旧值。
这些问题的本质,都是因为缓存和数据库的更新不是原子的。而 Redis 的读写性能远高于 MySQL,导致应用中大量读请求打到 Redis 上。一旦缓存里的数据和 MySQL 里的真实数据不一致,用户就会看到过期或者错误的信息——比如订单状态还是“待支付”,但实际上已经支付成功了。
1.2 一致性等级:别一上来就追求强一致
很多刚接触缓存的同学,一听到“数据一致性”就想到强一致,恨不得 MySQL 和 Redis 之间的数据实时完全同步。但实际业务中,强一致往往意味着巨大的性能和架构代价,大多数场景根本不需要。
一致性按照容忍程度可以分几个层级:
- 强一致:数据更新后,任何时刻任何读操作都能读到最新值。这个在缓存+数据库架构下几乎不可能做到,除非用分布式事务或者放弃缓存,直接读数据库;
- 最终一致:允许短暂的不一致窗口,但在一定时间后,所有副本最终会达到一致状态。这是绝大多数缓存方案的容忍底线;
- 弱一致:不保证什么时候一致,甚至允许一段时间内读不到更新后的值。比如一些统计类的非关键数据,可以接受这种程度。
我在做方案的时候首先会问业务方一个问题:这个数据多久不更新用户可以接受?如果答案是“几秒甚至一分钟内看不到最新数据没问题”,那就没必要为了强一致引入复杂的锁机制。反过来,如果涉及金钱、库存、订单状态这类核心数据,那就要用更严格的策略,甚至直接绕过缓存、读主库。
这里给别人一个实操建议:先把业务数据分级,核心支付/库存数据可以采用“缓存不落关键路径”的策略,或者只用 Redis 做限流等辅助功能,保证一致性优先;非核心的展示类数据(比如用户昵称、商品详情页的非价格部分)用缓存,接受最终一致。盲目追求所有数据强一致,最后系统复杂度会爆炸。
1.3 过期时间:兜底的最后一道防线
无论采用什么同步策略,给缓存设置合理的过期时间(TTL)永远是必须的。很多一致性问题之所以最终没有酿成大事故,就是因为 key 过期后缓存强制失效,下一次读请求直接回源数据库,拿到了最新值。
你可以把过期时间理解成一个“自我修复机制”——即使某次更新缓存失败了,缓存最多也只会多存活一段时间,而不是永远保持脏数据。这个思维非常关键,它决定了问题的严重程度从“永久错误”降级为“暂时错误”。
那 TTL 设置多长合适?一般建议根据业务容忍度来定:
- 核心数据:30秒到5分钟,尽量短,减少脏数据存活窗口;
- 非核心展示数据:10分钟到24小时,减少数据库回源压力;
- 基本不变的数据(比如配置项、省份列表):可以设置很长,甚至1天以上。
我个人习惯是:所有缓存 key 都带 TTL,并且 TTL 的时长必须小于业务可容忍的不一致窗口。这样即使你的同步逻辑有 bug,脏数据也不会永久存在,系统具备自愈能力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存读写策略:选对姿势是解决一致性的前提
2.1 Cache Aside:最经典也最实用的模式
先讨论最常用的缓存模式。Cache Aside(旁路缓存)策略是业界应用最广的方案,它的核心思想非常直接:
- 读请求:先读 Redis,命中则直接返回;未命中则查询 MySQL,把结果写入 Redis,再返回;
- 写请求:先更新 MySQL,再删除 Redis 中对应的 key(而不是更新缓存)。
这个策略的逻辑是:让缓存只负责读,写操作全部走数据库,更新成功后删除缓存让下一次读去回源。很多人在写操作时习惯性地去“更新缓存”,而不是“删除缓存”,这是最常见的认知误区。
为什么删除缓存比更新缓存更安全?原因有两个。第一,更新缓存需要额外的读操作来获取新的数据值,但某些场景下业务并不一定需要马上读这个 key(比如某个写多读少的字段),更新动作就白做了。第二,更新缓存存在并发竞争风险——线程 A 和线程 B 同时更新同一个缓存,后更新的可能会把旧值覆盖到缓存中,而删除缓存则没有这个覆盖问题,下一次读自然会拿最新数据。
但 Cache Aside 也有个天然的短板:它不能保证强一致。举个并发场景:线程 A 读缓存未命中,回源数据库读到旧值;此时线程 B 更新数据库成功,删除了 Redis key;然后线程 A 把刚才读到的旧值写回 Redis。最终缓存里存的还是旧数据,直到 TTL 过期。
这个时序问题非常经典,几乎每个做缓存的人都会遇到。解决它需要用延迟双删或分布式锁,这个我放在下一部分详细讲。
2.2 Read Through / Write Through / Write Behind:什么时候值得用
除了 Cache Aside,还有几种常见的缓存策略,它们对一致性的要求不同,适合的场景也不同:
- Read Through(读穿透):应用只和缓存交互,缓存未命中时由缓存组件自己负责加载数据库数据。这种模式对应用透明,但实现复杂度转移到缓存层,需要专门开发配套组件;
- Write Through(写穿透):应用只写缓存,由缓存组件同步写入数据库。好处是缓存和数据能保持较强一致,缺点是每次写都要等数据库落盘,写性能被拖慢;
- Write Behind(异步写回):应用只写缓存,缓存异步批量写回数据库。写入性能非常高,但如果缓存宕机,未落盘的数据就丢了,一致性风险最大。
我在实际项目中很少在主业务链路使用后面三种,因为它们的实现依赖定制化的缓存中间件,市面上开箱即用的方案并不多。如果你用的是 Spring Cache 这类框架,它默认采用的就是 Cache Aside 变体,可控性和可维护性都更好。
2.3 策略选型:我的判断标准
围绕“MySQL + Redis 双存储”这个架构,选型时我一般按这几个维度来判断:
- 读多写少且对一致性要求不极端 → Cache Aside + TTL,优先选择;
- 写多读少(如计数类、操作日志类)→ 直接写 MySQL,用 Redis 做异步聚合或展示,不要实时同步;
- 对一致性和性能都有要求(如库存、秒杀)→ Cache Aside + 分布式锁控制并发,必要时主库限流,不用 Redis 做超卖控制;
- 可以容忍最终一致、但要求吞吐量大(如评论数、浏览数)→ 异步写回策略,定期同步到 MySQL。
记住一条原则:Redis 不是数据库的替代品,它只是一个加速层。越核心的数据,越要往数据库侧靠;越边缘的数据,越可以放心放到 Redis 里。这个思路能帮你省掉很多不必要的复杂度。
3. 双写一致性实战:从删除缓存到延迟双删和锁
3.1 先更新数据库,再删除缓存:最基础的方案
先更新数据库再删除缓存,是 Cache Aside 写路径的标准做法。它的执行顺序是:
- 应用更新 MySQL 中的数据;
- 删除 Redis 中对应的缓存 key;
- 后续读请求未命中缓存,回源 MySQL 获取最新数据并重建缓存。
这个方案比“先删缓存再更新数据库”好在哪里?我们来看一下后者的问题:假设线程 A 先删除缓存,线程 B 接着读缓存未命中,回源查询到旧数据并写回缓存,然后线程 A 才更新数据库。结果就是缓存中存了旧值,而数据库已经是新值——这是最糟糕的结果。
反过来,先更新数据库再删除缓存,即使删除操作失败,缓存中存的是旧值,数据库是新值,最多只是在 TTL 过期前读到旧数据,属于可接受的最终一致范畴。所以从概率上,这个顺序更优。
但这个方案并不是绝对安全的。它有一个隐患:更新数据库和删除缓存之间不是原子操作。如果删除缓存时 Redis 恰好报错,或者网络抖动,缓存就会继续保留旧值。这时需要用重试机制来兜底,我后面会详细说明。
3.2 延迟双删:解决并发覆盖问题的实用技巧
前面提到过 Cache Aside 的并发问题:读线程回源拿到旧值,恰好在写线程更新数据库并删除缓存之后把旧值写回缓存,造成缓存污染。解决这个问题有一个非常实用的土办法——延迟双删。
延迟双删的核心思路是:先删除一次缓存,然后更新数据库,隔一小段时间后再删除一次缓存。为什么第二次删除能解决问题?因为第一次删除保证读线程不会直接命中旧缓存;更新数据库后,并发读线程可能把旧值写回缓存;等待一小段时间后,第二次删除会把这个旧值清掉。
延迟时间设置多少合适?这里给出一个经验值:延迟时间应大于一次读请求从回源数据库到写回缓存的总耗时。一般建议 500ms 到 1 秒。如果业务对一致性要求高,可以适当延长到 2 秒,但延迟过久会让新值长时间不被读到,影响用户体验。
需要说明的是,延迟双删并不能做到绝对严格的一致,但它能把脏数据窗口压缩到极小,并且实现成本很低,不依赖额外组件。对于我们大多数业务场景,这个方案已经足够。
3.3 加分布式锁:当业务核心数据不容许一丝马虎
如果你的业务是资金、库存这类数据,延迟双删的“最终一致”窗口依然不可接受,那就需要引入分布式锁,把对同一个 key 的读写操作串行化。
具体思路是:更新数据库前,先获取一个分布式锁(一般用 Redis SETNX 实现),锁的粒度可以是“商品ID”或者“订单ID”。抢到锁的线程才能执行“更新 MySQL → 删除 Redis”的操作,其他线程等待或者直接返回。读请求也尝试获取锁,如果获取不到说明正在写,则直接读数据库。
这个方案能有效消除并发覆盖,但会带来性能损耗。分布式锁本质上是把并行读写变成了串行,压测时你会发现 QPS 下降很明显。所以我的建议是:只对核心热点数据加锁,比如有限的几个热门商品的库存;对于海量普通的读请求,不加锁,用缓存扛流量,容忍最终一致。
3.4 实操中的几个细节:事务边界和补偿
在实际写代码时,有几个容易忽略的点,我单独列出来:
- Mysql 更新和 Redis 删除需要在事务边界内吗? 我一般不把 Redis 删除放进 MySQL 事务里,因为 Redis 操作失败会导致 MySQL 事务回滚,反而把数据库的可用性拖下水。正确做法是:MySQL 事务提交成功后,再执行删除缓存;删除失败则记录日志,走异步补偿任务;
- 删除缓存失败后怎么补偿? 最简单的方式是启动一个定时任务扫描操作日志表,把未删除成功的 key 重新删除一遍。这个方案虽然“土”,但非常稳定,不依赖额外组件;
- 不用把删除操作和请求线程绑定。有时候可以异步删除,比如更新完数据库后把 key 发到消息队列,由消费者统一删除,这样能降低同步删除的延迟影响。
这些细节如果处理不好,即便你的主流程写得再对,也会在极端情况下出现脏缓存。慢就是快,多花一点时间把补偿机制做完善,后续运维能省很多心。
4. 终极方案:订阅 binlog 用最终一致性兜底
4.1 同步删除 vs 异步补偿:各有各的适用场景
前面说的方案,无论是同步删除缓存还是延迟双删,本质上都是应用代码里主动控制缓存失效的时机。这种方式有一个共同弱点:如果应用自身出现故障(比如进程崩溃、代码有 bug),删除缓存的动作就永远执行不了,脏数据只能等 TTL 过期。
这时候就体现出“异步补偿”的价值。异步补偿的思路是:业务操作走正常流程,更新数据库;另外有一套独立的机制去监听数据库的数据变化,然后触发缓存删除或更新。这样即使主流程挂了,补偿机制仍然会执行,一致性保障更可靠。
实现异步补偿有两条路径:一是用消息队列,应用更新数据库后发送一条“删除缓存”的消息,消费者去处理;二是订阅 MySQL 的 binlog,用 Canal 这类中间件解析 binlog,识别到数据变化后自动触发缓存清理。
消息队列的方案虽然简单,但它还是依赖应用主动发送消息——如果应用在发送消息前挂了,消息根本不会产生。而 binlog 订阅方案直接从数据库日志层面感知变化,更彻底。
4.2 Canal 的原理与搭建要点
Canal 是阿里巴巴开源的一个基于 binlog 解析的中间件,它模拟 MySQL 主从复制的协议,伪装成从节点向主库发送同步请求,主库把 binlog 推给 Canal,Canal 解析后以 JSON 等格式输出给下游消费者。
整体架构大致是这样:应用更新 MySQL → MySQL 生成 binlog → Canal 解析 binlog → 推送到 MQ → 消费者消费消息并删除 Redis 缓存。
这个方案的优势在于:
- 与业务代码解耦,缓存同步逻辑不侵入业务方法;
- 感知数据变化的实时性非常高,几乎达到秒级;
- 能统一处理多张表的变更,不用每张表重复实现一套删除逻辑。
搭建时需要注意:Canal 需要连接 MySQL 的一个账号,这个账号必须具备 REPLICATION SLAVE、REPLICATION CLIENT 权限;binlog 格式必须配置为 ROW 模式,因为 statement 模式解析出来的 SQL 并不包含具体行数据;服务端要开启 binlog 并设置合理的过期时间,避免大事务导致 Canal 消费滞后又清理 binlog。
如果你只是维护中小型系统,不想引入额外组件,Canal 方案会显得“重”一些。但一旦你的业务表和缓存 key 数量成百上千,手动管理缓存失效越来越吃力时,Canal 这套架构的收益就会非常明显。
4.3 消费端幂等:别让你的补偿任务重复执行
引入 MQ 异步删除后,消息可能被重复消费,或者同一行数据在短时间内被多次更新,产生多条删除消息。虽然删除缓存是可重复操作(删一个不存在的 key 不会报错),但重复消费会浪费资源和时间,所以消费端最好是做幂等处理。
实际处理时我会在消息体里带一个唯一键,比如“表名 + 主键 ID + 更新时间”。消费者收到消息后先查 Redis:如果当前缓存里的更新时间已经晚于消息里的更新时间,说明消息过期了,直接丢弃;否则才执行删除。这样可以避免旧消息把新缓存清掉的问题。
还有一个点容易被忽略:如果同一个 key 同时被多个消费线程处理,删除的顺序可能存在先后问题。这时候可以利用 Redis 的 SETNX 做简单的互斥,保证同一时刻只有一个线程在处理同一个 key。对于绝大多数业务来说,删除缓存是幂等且无副作用的操作,这个粒度已经够用。
4.4 我的最终选择:主流程同步删 + binlog 异步兜底
这个方案我用了很久,也推荐给别人,总结下来效果最好:它的核心是“双保险”。
主流程仍然采用先更新数据库再删除缓存,这样大多数请求能快速生效;同时利用 Canal 监听 binlog,发现数据变更后再次删除缓存。两个路径只要有一个成功,缓存就能被清掉。如果两条都失败,缓存还有 TTL 兜底,最长也能在过期后自愈。
有人可能会问:这样不是增加了架构复杂度吗?我的回答是:对于核心业务数据,这点复杂度是值得的。而且 Canal + MQ 这套组件一旦搭好,后续新表接入的成本很低,只需要配置监听规则即可。它把一个被动找 bug 的问题,转变成了主动预防的问题。
5. 缓存异常三大件:穿透、击穿、雪崩以及排查实录
5.1 缓存穿透:查询一个不存在的数据
先讲最烦人的一种情况:缓存穿透。当查询某个 key 时,Redis 里没有,MySQL 里也没有,请求就会直接打到数据库。如果这个请求是恶意的(比如用一个不存在的用户 ID 反复查询),数据库扛不住压力就会出问题。更麻烦的是,这个 key 由于在数据库里不存在,永远不会被写回缓存,所以每一次查询都会穿透。
解决穿透最有效的方法是布隆过滤器:启动时把数据库里的主键全部加载到布隆过滤器里,查询前先判断 key 是否存在,不存在直接返回,不查数据库。另一个办法是缓存空值:如果数据库返回 null,也把这个 key 缓存起来,TTL 设置短一点(比如 30 秒),这样短时间内的重复请求不会打到数据库。
缓存空值需要注意一点:如果数据库真的插入了新数据,缓存中的空值可能挡路。所以插入数据时要主动删除这个空值缓存,或者依赖 TTL 自然过期。我一般两种方法结合使用,布隆过滤器挡掉大量恶意请求,缓存空值兜底保护业务查询。
5.2 缓存击穿:热点 key 瞬间失效
缓存击穿和穿透的区别在于:穿透是查不存在的 key,击穿是查一个热点 key,但这个 key 在瞬间失效了,导致大量并发请求同时回源数据库,数据库瞬间压力飙升。
比如一个热门商品详情页的缓存设置了 30 分钟,0 点一过缓存过期,恰好这个时刻有 1 万用户同时访问这个商品,数据库就会被 1 万个查询请求瞬间打爆。
解决击穿的方法是互斥锁:在缓存失效后,让第一个请求先获取分布式锁并回源数据库,其他请求等待锁释放后直接从缓存读取。有效防止并发回源。另一个辅助方法是逻辑过期:不给缓存设置 TTL,而是在 value 里保存一个过期时间字段,读取时判断是否过期,过期则异步更新缓存。这样即使在更新期间,用户也能读到旧数据,不会直接打穿数据库。
5.3 缓存雪崩:大量 key 同一时间失效
雪崩和击穿的区别在于范围:击穿是单个热点 key 失效,雪崩是大量 key 在同一时间段集体失效。比如缓存中所有商品数据都设置了 30 分钟过期,缓存重建的瞬间所有请求同时回源,数据库直接承受全量流量。
应对雪崩有几个常用手段:
- 给 TTL 加随机值,比如基础 30 分钟 + 0 到 5 分钟的随机偏移,避免集体失效;
- 采用多级缓存,本地缓存 + Redis 双层防护,Redis 挂了本地还能扛一小段时间;
- 限流降级,数据库入口层加闸门,超出阈值的请求直接返回默认值或排队等待。
5.4 一次真实的脏数据排查过程
去年年中我维护的一个订单查询接口出现了偶发性数据错乱,用户反馈订单状态显示错误,但过几分钟又恢复正常。当时第一反应是缓存更新逻辑有 bug,于是打开了应用日志。
排查后发现:订单更新时,主线程先更新了 MySQL,然后删除了 Redis key;但删除操作因为 Redis 连接池获取连接超时失败了。异常被捕获后,代码只是记录了一行 WARN 日志,没有做重试。之后的三分钟内,所有查询都从 Redis 读到了旧值,直到 TTL 过期。
这是典型的“同步删除失败不补偿”导致的问题。虽然代码里更新数据库和删除缓存的顺序是对的,但健壮性不足。修复措施就是我在 4.4 节说的双保险:主流程把删除失败的 key 写入本地重试队列,同时引入 Canal 作为异步兜底。另外给重试队列设置了一个最大重试次数,避免死循环。
那次经历给我的教训是:一致性方案不能只看着正常流程,要盯住异常分支。数据库操作成功、缓存删除失败,这个问题在日志里经常被忽略,却实际影响着用户看到的数据,值得给予重视。
5.5 常见问题速查表
| 问题类型 | 典型表现 | 根本原因 | 推荐解决方案 |
|---|---|---|---|
| 缓存穿透 | 数据库 QPS 异常升高 | 查询不存在的数据 | 布隆过滤器、缓存空值 |
| 缓存击穿 | 单个热点 key 失效后数据库抖一下 | 热点 key 过期瞬间并发回源 | 互斥锁、逻辑过期 |
| 缓存雪崩 | 数据库瞬间被打爆 | 大量 key 同时过期 | TTL 随机化、多级缓存、限流 |
| 脏数据 | 查询结果和数据库不一致 | 删除缓存失败无补偿 | 重试队列、binlog 订阅兜底 |
| 并发覆盖 | 缓存中存了旧值 | 读写线程交错执行 | 延迟双删、分布式锁 |
6. 高频面试与团队协作中的一致性考点
6.1 常见面试题:一句话说清方案背后的原理
MySQL 和 Redis 数据一致性是后端面试的高频考点。面试官一般不会问“你知道 Cache Aside 吗”,而是喜欢问“你们项目中怎么保证缓存和数据库一致”。要回答好这个问题,不能只背结论,需要讲清楚权衡过程。
我建议的回答逻辑是:
- 先定义业务场景:读多写少、允许最终一致;
- 说明选择 Cache Aside 的原因,以及 TTL 兜底;
- 补充并发隐患(读线程写回旧值),引出延迟双删;
- 再说明极端场景下用分布式锁或 binlog 订阅兜底;
- 最后强调异常处理:删除失败怎么办、怎么保证补偿可靠。
这个回答逻辑体现了从简单到复杂、从业务需求到技术方案的思考路径,比单纯背书要好得多。面试官真正想通过这个问题了解的,是你有没有亲自面对过真实业务、有没有在多个方案之间做过取舍。
6.2 回到工作中:别为了“一致性”神话架构
最后再说点接地气的。很多项目在初期根本没有必要上 Canal、MQ 这类重量级组件,一张订单表加几个热点 key,Cache Aside + 延迟双删 + 兜底日志就足够了。架构的价值在于匹配业务规模,而不是越复杂越好。
我通常会给团队一个建议:先保证主流程正确缓存删除,再考虑加补偿机制;只有在监控里频繁出现缓存延迟问题或者业务形态确实复杂到手动管理不了时,才把 Canal 和 MQ 体系引入进来。大多数时候,慢查询优化、索引设计、减少无效穿透,比上再多的中间件都更有意义。
按我个人习惯,拿到任何一个缓存需求,先画一张数据流图,标出哪些环节可能失败、失败后用户看到的结果是什么、系统怎么自愈。所有方案和代码都围绕这三步反复推敲,不断补强。把这些问题想明白,再动手实现时就是水到渠成的事情了。
