1. 先搞清楚问题到底出在哪
MySQL 和 Redis 是现在后端项目里最经典的组合。MySQL 负责持久化存储,Redis 负责扛高并发读。但一旦把两个存储系统放在一起,就会面临一个绕不开的问题:数据不一致。
先给还没踩过这个坑的同学还原一下场景。你的业务通常是先写 MySQL,再写 Redis;或者先写 Redis,再异步同步到 MySQL。不管哪种顺序,只要两步操作不是原子的,中间就可能出问题。
最简单的例子:用户更新了个人信息,你执行了 update user set name = '张三' where id = 1,MySQL 已经改成功了。如果这个时候 Redis 里的 key 还没有更新,还是旧名字,用户查询的时候就会读到旧数据。如果 Redis 的 key 正好过期了,那还好,下次查询会回源 MySQL。但如果 key 没过期,用户会一直读到旧数据,直到 key 过期。
反过来也一样。如果先更新 Redis,再写 MySQL,MySQL 写失败了,Redis 里已经是最新数据,MySQL 还是旧的。缓存重启之后,数据就变成旧的了。Redis 和 MySQL 永久不一致。
很多人觉得,我给 Redis 的 key 设置一个短过期时间不就行了?比如 5 分钟过期。这种做法在一定程度上缓解了问题,但本质上是接受了“最长 5 分钟内可能读到旧数据”的现实。对于某些业务,比如用户余额、库存、订单状态,5 分钟读到旧数据就是事故。而且过期时间本身也会引入热点 key 集中失效的问题,属于拆东墙补西墙。
所以要解决数据一致性,核心目标不是让两步写操作变得原子化——这在分布式中几乎不可能。目标是控制不一致的窗口,让不一致的时间尽可能短,或者让不一致的数据在业务上不可见。
先理解技术方案之前,还要搞清楚一个概念。MySQL 和 Redis 的一致性问题通常分为两类:
- 缓存与数据库的双写一致:数据库改了,缓存没改,或者缓存改了,数据库没改。
- 最终一致性:在一定时间窗口内,缓存和数据库可能不一致,但经过一段时间的补偿,最终会一致。
双写一致是理想态,代价高;最终一致是现实态,大多数业务能接受。这篇文章说的方案,主要围绕如何尽可能接近双写一致,以及在做不到的时候把最终一致的窗口压缩到可控范围。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存策略怎么选:Cache Aside 还是 Write Through
聊一致性之前,先确定缓存读写策略。不同的策略,一致性处理的复杂度完全不同。很多新手上来就写“先更新数据库,再删除缓存”,其实这背后是 Cache Aside 模式。但很多人并没有意识到底层还有别的选择。
2.1 Cache Aside 模式:最普遍也最难用对
Cache Aside 的逻辑是:
- 读:先读缓存,缓存 miss 则读数据库,然后把数据写回缓存,返回给调用方。
- 写:先更新数据库,然后直接删除缓存,而不是更新缓存。
为什么要删缓存而不是更新缓存?因为更新缓存是“写”操作,成本高,并且如果并发的写操作顺序错乱,缓存里可能被写入旧值。删除缓存则只需要等下一次读的时候回源数据库,天然拿到了最新值。
这个模式里关键点是什么?是先更新数据库,再删除缓存的顺序。
有一种经典的说法是:先删缓存,再更新数据库。这个顺序在并发下会出问题。线程 A 删除缓存,准备更新数据库;线程 B 读缓存 miss,回源数据库读到旧值,写回缓存;线程 A 更新数据库。最终缓存里是旧值,数据库是新值,缓存和数据库不一致。而且这个不一致可能持续很长时间,直到缓存过期。所以先删缓存这个顺序基本被否掉了。
先更新数据库,再删除缓存,也并不是绝对安全。极端并发场景下,线程 A 更新数据库后,删除缓存之前,线程 B 读了缓存 miss,回源数据库读到了新值,写回缓存,然后线程 A 删除缓存。这种情况最终是好的,因为缓存被删掉了,下次读会再次回源。但如果线程 C 在线程 A 删除缓存之后读缓存 miss,回源数据库,把缓存写回,而线程 A 更新数据库还没提交事务,C 读到的是旧值,写回缓存。这种情况就会不一致,窗口非常短,但存在。
在实际业务中,Cache Aside 的“先更新库,再删缓存”是可用性最高的方案。那个并发 bug 的窗口极小,而且依赖数据库隔离级别和事务提交时机。后面我们会讲用延迟双删来进一步缩小这个窗口。
2.2 为什么不用 Write Through / Write Back
Write Through 的逻辑是:写请求先写缓存,由缓存同步写数据库。这个模式在单体缓存系统里很常见,但 MySQL + Redis 这种组合里比较少见,因为 Redis 本身不具备自动落库到 MySQL 的能力,需要业务代码自己实现,等于把缓存当成主存储,MySQL 变成从存储。
Write Back 就更危险了,写缓存后直接返回,后台异步批量写数据库。特点是性能最好,但一旦缓存宕机,未落库的数据就丢了。像秒杀场景的扣减库存,有人会这么干,但必须配合完善的对账和兜底机制,不然就是数据事故。
总结一下:在 MySQL + Redis 组合下,绝大多数项目都应该用 Cache Aside。它代码简单,理解成本低,容错性也不错。把主要精力放在“如何保证缓存删除成功”,而不是换一个更复杂的策略。
2.3 读多写少 vs 写多读少,策略要有区分
并不是所有数据都适合用 Redis 缓存。我见过很多项目,不管什么数据都往 Redis 塞,最后一致性问题和缓存穿透问题一起来。
读多写少的数据,比如商品详情、用户资料、配置信息,适合用 Cache Aside,配合合理的过期时间,一致性窗口很小。
写多读少的数据,比如库存扣减、点赞数、访问量,更适合先写入 Redis,再异步批量同步到 MySQL。这种场景对实时一致性要求不高,但对写入吞吐要求极高。Redis 的 INCR 命令就是为这种场景设计的。
写多读多的数据,比如订单状态,这种数据本来就不该放 Redis。因为每一次状态变更都需要同步,一致性窗口和并发事务都很复杂,直接用 MySQL 更稳妥。强行上缓存,遇到的问题比解决的问题多。
3. 延迟双删:最实用的缓存一致性补偿方案
Cache Aside 模式下,并发窗口导致的缓存不一致,可以通过延迟双删来补偿。这也是目前中小团队最常用的方案,实现成本低,见效明显。
3.1 延迟双删的核心逻辑
延迟双删的流程:
- 更新数据库。
- 删除缓存。
- 等几百毫秒(比如 500ms),再次删除缓存。
第二次删除的目的,是为了删除掉在第一次删除之后、数据库事务提交之前,被并发请求重新写回缓存的旧值。
代码示意如下:
java复制public void updateData(String key, Object newValue) {
// 1. 更新数据库
updateDB(newValue);
// 2. 第一次删除缓存
redisTemplate.delete(key);
// 3. 延迟 500ms 后第二次删除
executorService.schedule(() -> redisTemplate.delete(key), 500, TimeUnit.MILLISECONDS);
}
3.2 延迟时间怎么定:为什么要 500ms
延迟时间不是随便拍的。主要考虑两个因素:
- 数据库主从同步延迟:如果业务读写分离,写完主库后,读操作可能落到从库。从库同步主库 binlog 需要时间。如果延迟时间太短,第二次删除执行时,从库可能还没同步完,后续请求回源从库读到旧值,又写回缓存。
- 应用自身线程调度时间:第一次删除缓存后,并发请求回源数据库写缓存需要时间。这个时间通常很短,但也要留出余量。
建议延迟时间设置为:数据库主从同步最大耗时 + 200~300ms 余量。如果主从延迟通常 200ms,那么延迟时间设 500ms 比较稳妥。如果主从延迟经常超过 1s,那延迟双删的效果就会大打折扣,需要考虑其他方案。
我自己在实践中的做法是:通过监控确认主从延迟的 P99 值,然后取这个值的两倍作为延迟时间。上限不超过 2s,否则对业务影响太大——写操作要等 2 秒才能完成,绝大多数接口都扛不住。
3.3 延迟双删的痛点:第二次删除失败怎么办
延迟双删不是银弹。最大的问题是:如果第二次删除缓存时 Redis 报错,或者应用在删除前重启了,缓存中可能残留旧值。
解决思路有两个方向:
第一,用消息队列做异步重试。第二次删除的请求发送到消息队列,消费者负责删除缓存。删除失败就重试,直到成功。这个方案延迟比较长,但可靠性高。
第二,用 Redis 的 key 过期兜底。给缓存设置一个合理的过期时间,比如 30 分钟。即使删除失败,最多 30 分钟后也会重新回源数据库。这是最保险的兜底方案,也是为什么我强烈建议所有缓存 key 都设置过期时间的原因。
3.4 延迟双删的升级:版本号方案
如果不满足于“延迟”,想要更精确地控制一致性,可以给缓存数据增加版本号。
每次更新数据库时,版本号加 1。更新完数据库后,把版本号写入 Redis。读取缓存时,需要校验缓存中的版本号和 MySQL 中的版本号(或者某个时间戳)是否一致。不一致就回源。
这个方案比延迟双删更强,因为它是通过版本号判断,而不是通过时间延迟。但实现复杂度也更高,需要改动查询逻辑,而且每次读请求都要多一次版本号校验。对于强一致要求但并发量不是特别高的场景,可以考虑。
4. 更稳的做法:订阅 binlog 来同步缓存
延迟双删保证了绝大部分场景的一致性,但它有一个天然缺陷:依赖业务代码主动删除缓存。如果代码漏删、删错 key、删除失败,或者缓存和数据之间的关联关系比较复杂,就会失控。
更稳妥的方案是把“同步缓存”这件事从业务代码里剥离出来,做成异步解耦的组件。目前最成熟的方案是订阅 MySQL binlog 来同步缓存。
4.1 核心架构:Canal + MQ + 消费者
整个链路的流程:
- 业务代码正常写 MySQL,不感知缓存的存在。
- MySQL 主库把变更写入 binlog。
- Canal 伪装成从库,拉取 binlog,解析出数据变更事件。
- Canal 把变更事件发送到消息队列(RocketMQ / Kafka / RabbitMQ)。
- 消费者接收到变更事件,根据业务规则删除对应的 Redis key,或者更新 Redis key。
这个方案把缓存同步的动作从“同步调用”变成了“异步解耦”。业务代码只关心数据库操作,不需要关心 Redis。Redis 的更新完全由 binlog 驱动。
4.2 为什么 binlog 订阅比双写可靠
对比一下“业务代码先写库再删缓存”和“binlog 订阅删缓存”:
| 对比维度 | 业务代码双写 | binlog 订阅 |
|---|---|---|
| 业务侵入性 | 每次写操作都要额外处理 Redis | 业务代码无感知 |
| 失败概率 | 依赖于开发人员是否遗漏 | 通过消息队列保证最终执行 |
| 扩展性 | 每增加一个查询维度都要改代码 | 可以订阅同一张表做多维度处理 |
| 实时性 | 同步,实时性最好 | 异步,通常几百毫秒内 |
| 部署复杂度 | 低 | 高,需要额外部署 Canal、MQ |
对于中小项目,双写方案够用,成本低。但如果你在面对核心交易链路、数据一致性要求很高的场景,binlog 订阅是值得投入的。它虽然增加了系统复杂度,但把一致性保障从“人肉保证”升级为“机制保证”。
4.3 消费者侧如何处理变更事件
消费者拿到 binlog 事件后,最安全的动作是删除对应的 Redis key,而不是更新 key。原因和 Cache Aside 一致:删除 key 只需要知道主键 ID,不需要知道整行数据的字段内容,也不存在并发写缓存导致覆盖新值的问题。
删除 key 的逻辑非常简单:
java复制public void onMessage(String tableName, Long primaryKey) {
String cacheKey = buildCacheKey(tableName, primaryKey);
redisTemplate.delete(cacheKey);
}
这个逻辑的好处是,哪怕 binlog 事件发送重复了,删除一个不存在的 key 也没有副作用。Redis 的 DEL 命令本身是幂等的。
4.4 binlog 订阅方案的真实坑
这个方案虽然可靠,但也不是零成本。我踩过几个坑:
第一个是 Canal 的高可用。Canal 本身是单点服务,如果它挂了,binlog 就没人消费了,缓存也就不会被清理。一定要做 Canal 集群部署,并且监控 Canal 的消费延迟。
第二个是消息积压。如果某一瞬间 binlog 事件大量涌入,MQ 里积压了很多变更事件,Redis key 的删除就会延迟。这个延迟会导致缓存和数据库不一致的时间变长。需要监控 MQ 的消费长度,并做好告警。
第三个是 binlog 事件里的字段与缓存 key 的映射关系。有些缓存 key 不是直接用主键拼的,比如根据 userId 查用户订单列表的缓存 key,可能包含了多个查询条件。这种情况下,binlog 事件里只有订单表的主键,没法直接推断出该删除哪些缓存 key。需要额外维护一张“缓存 key 规则表”,或者在业务里定义好清缓存的路由策略。这个设计要在架构早期就规划好,不然后面改起来很痛苦。
5. 别指望强一致:不同业务场景的一致性等级
聊完了具体技术方案,还得回到业务层面。很多团队纠结“MySQL 和 Redis 到底怎么保持强一致”,其实方向就错了。在分布式系统里,跨存储的强一致要么性能差到没法用,要么实现成本高到没法维护。
正确的思路是:按业务场景区分一致性等级,选择合适的方案,做好降级和兜底。
5.1 一致性等级怎么划分
简单把业务场景按一致性要求分几个档位:
| 一致性要求 | 典型业务 | 推荐方案 | 兜底策略 |
|---|---|---|---|
| 强一致 | 余额、库存、订单状态 | 不用缓存,或加分布式锁 | 数据库事务 |
| 最终一致(秒级) | 商品详情、用户资料 | Cache Aside + 延迟双删 | 缓存过期时间 |
| 最终一致(分钟级) | 报表、统计、排行榜 | 异步刷新缓存 | 定时任务刷新 |
| 弱一致 | 热度值、访问量 | 先写 Redis,异步批量落库 | 定期对账 |
看到强一致那一栏了吗?我强烈建议:余额、库存、订单状态这类核心数据,不要放 Redis。或者即使放了 Redis,也不能把它当成唯一数据源,只能作为热数据加速,底层必须以 MySQL 为准。
很多故障,比如超卖、订单金额错误,本质上就是因为业务团队试图在 Redis 和 MySQL 之间保持强一致,但方案没设计好,最终不一致导致数据错误。
5.2 实时性 vs 一致性的取舍
有些场景,业务方要求“实时性”和“一致性”都要。比如用户下单后,页面立刻显示最新订单状态。这种情况不可能靠 Redis 解决,因为订单状态的变更频率不高,直接查 MySQL 也完全扛得住。如果硬要用 Redis,反而要处理状态一致性的问题,得不偿失。
我的原则是:只有当数据库查询确实是性能瓶颈时,才引入缓存。引入缓存前,先问三个问题:
- 这个数据的读频率是多少?如果 QPS 不到 1000,MySQL 完全扛得住,没必要上缓存。
- 如果缓存失效,回源数据库的并发会不会打垮数据库?如果会,需要做缓存预热和降级方案。
- 数据允许最多多久的不一致?1 秒、10 秒还是 1 分钟?这个答案直接决定技术方案的选择。
5.3 先写 Redis 还是先写 MySQL
很多人在“先写 Redis 还是先写 MySQL”这个问题上纠结。实际上,取决于业务对数据可靠性的要求。
如果底层数据必须是可靠的,那一定是先写 MySQL。因为 MySQL 有事务,有 binlog,有完备的数据恢复能力。Redis 的持久化机制(RDB/AOF)虽然也能恢复,但在极端情况下会丢数据。所以核心业务数据,以 MySQL 为准,Redis 永远是加速层。
如果业务只关心短期数据,比如实时在线人数、限流计数、验证码,那可以直接写 Redis,不落 MySQL,或者延迟批量落库。这种场景一致性要求极低,甚至不需要一致性。
6. 缓存一致性故障排查实录
再优秀的方案,在实际运行中也会出问题。分享几个我处理过的真实故障,以及排查的思路。
6.1 故障一:更新数据库成功,缓存删除失败
现象:某个配置项在后台修改后,前端一直显示旧值。
排查过程:
第一步,检查 Redis 里这个 key 是否存在。发现 key 还在,而且存储的是旧值。
第二步,检查业务代码,发现更新配置的代码里确实有删除 Redis key 的逻辑,但包裹在 try-catch 里,异常被吞了。
第三步,查看日志,发现删除 Redis key 时抛出了连接超时异常。Redis 客户端连接池满了,删除操作排队超时。
根因:Redis 连接池配置太小,在高并发下连接被其他慢查询占满,删除操作的连接获取超时。
解决方案:
- 调整 Redis 连接池参数,增加最大连接数和等待时间。
- 删除缓存的操作增加重试机制。
- 删除缓存失败时,记录日志并发送告警,而不是静默吞掉异常。
经验:删除缓存这种操作,看起来简单,但失败后的影响是在一段时间后才暴露的。排查非常痛苦。建议所有涉及缓存删除的代码,都必须有日志、有监控、有告警。
6.2 故障二:并发场景下缓存被写入旧值
现象:用户修改头像后,部分用户看到新头像,部分用户还是旧头像,持续了几分钟才恢复。
排查过程:
第一步,确认缓存 key 的过期时间。发现这个 key 设置了 30 分钟过期。
第二步,检查代码逻辑。发现这个接口用了先删缓存、再更新数据库的顺序,正好踩了并发下缓存回源的坑。
具体流程是:线程 A 删除缓存,尚未更新数据库;线程 B 读缓存 miss,回源数据库读到旧值,写回缓存;线程 A 更新数据库。此时缓存里是旧值,数据库是新值,直到缓存过期才恢复。
根因:使用了错误的操作顺序,且缓存过期时间太长,导致不一致时间过长。
解决方案:
- 调整为“先更新数据库,再删除缓存”的顺序。
- 增加延迟双删,补偿并发窗口。
- 把缓存过期时间从 30 分钟降到 5 分钟,缩短兜底时间。
经验:“先删缓存、再更新库”这个顺序错得非常隐蔽,因为单线程测试下永远发现不了问题。一定要用高并发压测才能暴露。代码 review 时要特别关注处理缓存和数据库的操作顺序。
6.3 故障三:Redis 主从切换导致缓存数据回退
现象:某次 Redis 主从切换后,部分缓存 key 的数据回退到旧版本。
排查过程:
第一步,检查 Redis 日志,确认发生了主从切换。
第二步,检查主从切换前的数据同步状况。发现主节点上有部分较新的写操作没有及时同步到从节点,主节点宕机后,从节点被提升为主节点,数据从切换时间点开始回退。
第三步,确认业务数据。发现业务写操作是先更新 MySQL,再更新 Redis。MySQL 里的数据是最新的,但 Redis 里有一部分 key 的数据是旧的。
根因:Redis 主从切换造成的数据回退,本质上是 Redis 持久化和主从复制的延迟窗口。这个窗口内,Redis 中的数据可能不是最新状态。如果业务把 Redis 当作强一致存储,就会出问题。
解决方案:
- 业务层做修正:以 MySQL 的数据为准,对 Redis 中的 key 执行一次全量刷新,或通过版本号淘汰旧数据。
- 对关键 key 设置短过期时间,尽快回源修正。
- 评估是否真的需要 Redis 作为这些数据的存储。
经验:Redis 主从切换导致的数据回退,比缓存删除失败更隐蔽。最有效的规避手段就是:永远不要把 Redis 当成唯一数据源。MySQL 里有底,Redis 出了问题也能纠正。
6.4 故障排查怎么做:一个通用的排查框架
如果你也遇到了缓存不一致的问题,按下面的顺序排查:
- 先看监控:Redis key 是否命中、命中后返回的值是什么、这个 key 最近一次写入缓存的时间。
- 确认旧值来源:是从数据库读出来的,还是从缓存写入时就是旧值。
- 复现并发场景:用压测工具模拟并发读写,看是否能稳定复现不一致。
- 检查操作顺序:读代码,确认读写缓存和数据库的顺序是“先库后缓存”,还是“先缓存后库”。
- 检查异常路径:删除缓存失败是否被捕获忽略、异步任务是否失败重试。
- 确认基础设施:Redis 是否有主从切换、网络是否有抖动、连接池是否耗尽。
这套流程下来,大多数问题都能定位到具体环节。
7. 建设缓存体系:一致性之外还需要关注的事
一致性只是缓存体系里的一个环节。实际运维中,缓存穿透、缓存击穿、缓存雪崩,每一个都值得单独讲。这些话题和一致性经常一起出现,处理不好,同样会导致系统故障。
7.1 缓存穿透:查询不存在的数据导致请求全部打到 MySQL
缓存穿透是指查询一个必然不存在的数据,缓存里没有,数据库里也没有。这类请求会漏过缓存,直接打到数据库。如果是恶意攻击或者系统 bug,大量这样的请求会在瞬间打垮数据库。
解决思路:
- 缓存空值。把查不到的数据也缓存起来,设置一个较短的过期时间,比如 3~5 分钟。
- 布隆过滤器。把所有可能存在的 key 提前放入布隆过滤器,过滤掉不存在的请求。
- 参数校验。对明显不合法或超出范围的参数,直接返回错误,不查数据库。
7.2 缓存击穿:热点 key 失效瞬间的并发冲击
某个热点 key 在某个时刻过期,大量请求同时回源数据库。如果这个 key 是热门数据,比如秒杀商品的库存、微博热搜,请求量可能在瞬间打垮数据库。
解决思路:
- 互斥锁。回源数据库时加锁,只有一个请求能查数据库并重建缓存,其他请求等待。
- 逻辑过期。不设置物理过期时间,而是把数据的过期时间作为 value 的一部分存储,后台异步检查并刷新缓存。
7.3 缓存雪崩:大量 key 同时失效
缓存雪崩和击穿的区别在于范围。击穿是单个热点 key 失效,雪崩是大面积 key 在同一时间段失效。常见原因是所有 key 都设置了同一时刻的过期时间,或者 Redis 服务宕机。
解决思路:
- 过期时间加随机值。比如基础过期时间 30 分钟,再加上 0~5 分钟的随机值,打散过期时间。
- 缓存永不失效,由后台任务主动刷新。
- Redis 高可用,使用哨兵或集群模式,避免单点故障。
7.4 一致性方案与这三大问题的关系
这里说一个很容易被忽略的点:一致性方案如果设计不当,会加剧缓存穿透、击穿和雪崩。
比如延迟双删中的“删除缓存”,在你的写操作执行后,缓存会被删除一次。如果某个 key 被高频更新,缓存会被反复删除,每次都触发一次回源。如果一个热点 key 在秒杀期间被频繁更新,删除缓存的频率会很高,每次删除都会引起一波回源请求。如果回源逻辑没有做好防护,就可能变成缓存击穿。
所以,设计缓存一致性方案时,一定要同步考虑缓存崩溃场景下的降级逻辑。比如更新数据库成功后,如果删除缓存失败,是选择重试删除,还是接受短时间的不一致并等过期?如果选择重试删除,重试的并发会不会引发新的问题?这些细节都要在方案设计时想清楚。
8. 最后的实践建议
这篇文章聊了很多方案,从 Cache Aside 到延迟双删,再到 binlog 订阅,还有缓存体系相关的三个经典问题。最后结合个人经验,给几条实践层面的建议。
第一个建议,新建项目时,先不要上 Redis。确认 MySQL 的查询确实扛不住,再考虑缓存。很多人项目还没上线就把 Redis 接上了,提前引入了数据一致性这个大坑。等业务量上去了再上,完全来得及。
第二个建议,如果已经上了 Redis,优先用 Cache Aside + 延迟双删。这个方案代码量少,理解成本低,能满足绝大多数业务的一致性要求。binlog 订阅虽然是更优解,但它带来的部署和运维成本是实打实的,小团队不一定扛得住。
第三个建议,所有缓存 key 都设置过期时间,这是最重要的兜底。哪怕你的方案设计得再完善,总会有意外情况。过期时间是最后一道防线。没有过期时间的缓存 key,就是在裸奔。
第四个建议,把缓存操作和业务代码解耦。如果你发现一段业务代码里既要写 MySQL 又要写 Redis,而且逻辑很复杂,那大概率是设计出了问题。考虑引入消息队列或者 binlog 订阅,把缓存同步这层逻辑独立出去,让业务代码只关注业务本身。
MySQL 和 Redis 的数据一致性没有一劳永逸的解决方案。它的核心是在一致性、性能、代码复杂度之间找到平衡点。想清楚自己业务的真实需求,选择一个可落地的方案,做好监控和兜底,这才是解决这个问题的最优方式。
