MySQL + Redis 如何保证数据一致性?6 种方案一次讲透
在实际项目中,我们经常会使用:
MySQL + Redis
这样的组合。
MySQL 负责持久化数据,Redis 负责扛住大量查询请求。
最典型的查询流程就是:
请求
↓
查询 Redis
↓
命中?
/ \
是 否
| |
返回 查询 MySQL
↓
回写 Redis
↓
返回
看起来非常简单。
但只要系统同时存在读请求和更新请求,一个经典问题马上就来了:
MySQL 和 Redis 的数据不一致怎么办?
比如商品价格原来是:
10
现在后台把价格修改成:
11
理想状态当然是:
MySQL = 11
Redis = 11
但问题在于:
更新 MySQL 和更新 Redis 是两个独立操作。
于是就可能出现:
MySQL = 11
Redis = 10
甚至:
MySQL = 10
Redis = 11
如果再把高并发考虑进来,事情就更麻烦了。
这篇文章,我们就把 MySQL + Redis 常见的 6 种更新方案一次讲清楚:
1. 先写 MySQL,再写 Redis
2. 先写 Redis,再写 MySQL
3. 先删除 Redis,再写 MySQL
4. 先删除 Redis,再写 MySQL,再删除 Redis
5. 先写 MySQL,再删除 Redis
6. 先写 MySQL,通过 Binlog 异步更新 Redis
最后再回答一个真正重要的问题:
生产环境到底应该选哪一种?
一、先搞明白:为什么 MySQL 和 Redis 会不一致?
很多人第一次接触这个问题,会想:
不就是更新两份数据吗?都更新一下不就行了?
问题恰恰就出在:
更新两份数据
这件事情上。
假设我们的系统里有:
MySQL
Redis
现在需要把商品价格:
10 → 11
那么至少要执行两个操作:
UPDATE MySQL
更新/删除 Redis
关键问题来了:
这两个操作没办法天然组成一个原子操作。
也就是说,不可能简单保证:
要么两个都成功
要么两个都失败
可能出现:
MySQL 成功
Redis 失败
也可能:
Redis 成功
MySQL 失败
甚至两个操作都成功了,在高并发情况下,因为执行顺序发生交错,最终结果依然可能是错的。
所以 MySQL + Redis 一致性问题,本质上其实是在解决:
两个独立存储系统之间的数据同步问题。
二、方案一:先写 MySQL,再写 Redis
这是最容易想到的方案。
更新数据时:
更新 MySQL
↓
更新 Redis
比如原来的数据是:
MySQL = 10
Redis = 10
请求 A 要把数据修改成 11:
请求 A
MySQL:10 → 11
Redis:10 → 11
正常情况下当然没有问题。
但是一旦出现并发,就不一定了。
假设现在有两个请求:
请求 A:10 → 11
请求 B:11 → 12
正常顺序应该是:
A 更新 MySQL = 11
A 更新 Redis = 11
B 更新 MySQL = 12
B 更新 Redis = 12
最终:
MySQL = 12
Redis = 12
没有问题。
但如果请求 A 更新完 MySQL 之后突然卡住:
时间 ↓
请求 A 请求 B
MySQL = 11
|
| 卡住
|
MySQL = 12
Redis = 12
|
| 恢复
↓
Redis = 11
最后就变成:
MySQL = 12
Redis = 11
出问题了。
这里最关键的一点是:
数据库的更新顺序,不一定等于 Redis 的更新顺序。
两个并发请求发生交错之后,旧请求反而可能最后覆盖 Redis。
所以:
先写 MySQL
再写 Redis
虽然简单,但是在并发环境下存在明显的数据覆盖问题。
因此一般不推荐把 Redis 当成需要和数据库同步更新的第二份数据源。
三、方案二:先写 Redis,再写 MySQL
那反过来呢?
更新 Redis
↓
更新 MySQL
看起来似乎也可以。
例如:
Redis:10 → 11
MySQL:10 → 11
但这个方案的问题其实更加明显。
假设:
Redis 更新成功
变成:
Redis = 11
然后更新 MySQL 时:
MySQL 挂了
或者:
SQL 执行失败
事务回滚
数据库连接异常
那么最后:
Redis = 11
MySQL = 10
这就非常危险。
因为在绝大多数系统里:
MySQL 才是真正的数据源,Redis 只是缓存。
现在缓存里面出现了一份数据库根本不存在的数据,相当于:
缓存自己创造了一份数据
这显然不合理。
有人可能会说:
那 MySQL 更新失败,我再把 Redis 改回去不就好了?
于是流程变成:
更新 Redis
↓
更新 MySQL
↓
失败
↓
回滚 Redis
新的问题马上又来了:
Redis 回滚也失败怎么办?
再重试?
重试也失败怎么办?
继续补偿?
系统复杂度一下就上来了。
所以:
先 Redis,后 MySQL,一般是最不推荐的方案之一。
核心原因非常简单:
不要让缓存跑到真正的数据源前面。
四、为什么很多系统选择“删除缓存”,而不是“更新缓存”?
讲第三种方案之前,我们先解决一个非常重要的问题。
前面两种方案都是:
更新 MySQL
+
更新 Redis
但真实项目中更常见的做法其实不是:
更新 Redis
而是:
删除 Redis
为什么?
假设商品信息在 MySQL 里是:
商品ID:1001
价格:100
库存:200
名称:iPhone
状态:上架
Redis 中可能缓存的是多个字段经过业务计算之后的结果。
每次数据库发生变化,都主动重新计算 Redis:
更新 DB
↓
重新组装缓存
↓
更新 Redis
会让更新链路变复杂。
另一种思路就简单很多:
更新 DB
↓
删除 Redis
下一次有人查询:
查询 Redis
↓
没有
↓
查询 MySQL
↓
重新写 Redis
这就是非常经典的:
Cache Aside Pattern
也就是旁路缓存模式。
它的核心思想是:
数据库负责保存真实数据,缓存只是数据库数据的副本。
缓存不对了怎么办?
最简单的方式不是努力把它改正确,而是:
直接删掉
下一次查询的时候重新从数据库构建。
这也是理解后面几种方案的关键。
五、方案三:先删除 Redis,再写 MySQL
既然更新缓存容易出现问题,那我们删除缓存。
流程变成:
删除 Redis
↓
更新 MySQL
乍一看很合理。
比如:
Redis = 10
MySQL = 10
请求 A 修改数据:
删除 Redis
↓
MySQL:10 → 11
更新结束以后:
Redis = null
MySQL = 11
下一次查询:
查询 Redis
↓
缓存未命中
↓
查询 MySQL = 11
↓
回写 Redis = 11
最终:
MySQL = 11
Redis = 11
非常完美。
但是并发一来,又出问题了。
假设:
请求 A:更新请求
请求 B:查询请求
发生下面这种情况:
时间 ↓
请求 A 请求 B
删除 Redis
|
| 卡住
|
查询 Redis
↓
没有数据
↓
查询 MySQL
↓
得到旧数据 10
↓
Redis = 10
|
| 恢复
↓
MySQL = 11
最后:
MySQL = 11
Redis = 10
又不一致了。
问题出现在哪里?
因为:
删除缓存
和:
更新数据库
之间存在一个时间窗口。
在这个窗口里,读请求发现:
缓存没了
于是去查询数据库。
但是数据库此时:
还没有更新
所以读到了旧值。
然后又把旧值写回 Redis。
最终就产生了脏缓存。
因此:
先删除缓存,再更新数据库,也不是一个理想方案。
六、方案四:延迟双删
既然:
删除 Redis
↓
更新 MySQL
会因为中间插入一个查询请求而产生脏数据,那怎么办?
很自然会想到:
那更新完数据库以后,我再删一次缓存。
于是出现了经典的:
延迟双删
流程:
第一次删除 Redis
↓
更新 MySQL
↓
等待一段时间
↓
第二次删除 Redis
为什么第二次删除有用?
还是刚才那个场景:
请求 A:更新
请求 B:查询
可能发生:
A 删除 Redis
↓
B 查询 Redis:没有
↓
B 查询 MySQL:10
↓
A 更新 MySQL:11
↓
B 回写 Redis:10
↓
A 再删除 Redis
最后:
MySQL = 11
Redis = null
下一次查询:
Redis miss
↓
MySQL = 11
↓
Redis = 11
问题解决。
所以第二次删除的目的其实非常明确:
清理更新数据库期间,其他请求可能重新写入的旧缓存。
七、为什么“sleep 500ms 再删除”并不优雅?
网上讲延迟双删的时候,经常可以看到这样的方案:
删除 Redis
↓
更新 MySQL
↓
sleep 500ms
↓
删除 Redis
核心思想是:
等一会,让之前可能正在执行的读请求完成缓存回写,然后我再删掉它。
逻辑上说得通。
问题是:
为什么是 500ms?
如果查询耗时:
100ms
500ms 足够。
如果碰到:
慢 SQL
网络抖动
GC
线程调度
数据库负载升高
导致查询耗时:
800ms
那 500ms 就不够。
如果为了安全改成:
2 秒
又意味着业务线程白白等待 2 秒。
所以这种:
Thread.sleep(500);
最大的问题就是:
这个时间很难精确评估,而且直接阻塞业务线程。
因此更工程化的方式应该是:
更新 MySQL
↓
发送删除缓存消息
↓
MQ
↓
异步消费
↓
删除 Redis
甚至可以让同一个业务 Key:
user:1001
进入同一个有序队列,进行:
串行删除
这样既不会阻塞业务线程,又能够降低并发乱序带来的问题。
如果删除 Redis 失败:
第一次失败
↓
重试
↓
第二次失败
↓
重试
↓
超过阈值
↓
告警 / 补偿
这样就比简单:
sleep 500ms
靠谱很多。
八、方案五:先写 MySQL,再删除 Redis
终于来到实际项目里非常常见,也是比较推荐的一种方案:
更新 MySQL
↓
删除 Redis
注意它和第三种方案只是顺序反过来了。
第三种:
删除 Redis
↓
更新 MySQL
现在变成:
更新 MySQL
↓
删除 Redis
为什么这个小小的顺序变化会更好?
我们来分析。
假设:
MySQL = 10
Redis = 10
请求 A 要修改成:
11
正常流程:
MySQL = 11
↓
删除 Redis
最终:
MySQL = 11
Redis = null
请求 B 再查询:
Redis miss
↓
查询 MySQL
↓
得到 11
↓
Redis = 11
没有问题。
九、这种方案就 100% 不会不一致吗?
不是。
这一点非常重要。
很多文章会直接告诉你:
先更新数据库,再删除缓存
然后结束。
但它实际上依然存在极端并发问题。
假设缓存:
刚好过期
然后发生:
请求 B:查询
请求 A:更新
一种极端时序是:
时间 ↓
请求 B 请求 A
查询 Redis
↓
缓存刚好失效
↓
查询 MySQL
↓
得到旧值 10
更新 MySQL = 11
↓
删除 Redis
↓
回写 Redis = 10
最终:
MySQL = 11
Redis = 10
还是不一致。
但是注意:
要出现这个问题,需要同时满足几个比较苛刻的条件。
首先:
缓存恰好失效
其次:
读请求查询到了旧数据
而且这个读请求从:
查询数据库
到:
写入 Redis
整个过程还必须比:
更新数据库
+
删除 Redis
更慢。
换句话说,需要形成:
读旧数据
↓
更新 DB
↓
删除缓存
↓
旧数据最后才写进 Redis
这个非常特殊的时序。
现实情况下通常:
数据库查询
比:
数据库更新
更快。
因此虽然理论上存在这种可能,但实际出现概率通常很低。
这就是为什么:
先更新 MySQL,再删除 Redis,是 Cache Aside 模式下非常常用的工程实践。
它不是数学意义上的强一致。
而是:
用相对简单的方案,把不一致出现的概率压到足够低。
这其实就是工程设计非常重要的思想。
十、删除 Redis 失败怎么办?
真正上线之后,还有一个不能忽略的问题:
MySQL 更新成功
↓
Redis 删除失败
这时候:
MySQL = 11
Redis = 10
旧缓存仍然存在。
所以:
更新 DB
↓
删除 Redis
并不代表事情结束了。
还需要考虑:
删除失败重试。
最简单可以:
删除 Redis
↓
失败
↓
重试 1
↓
失败
↓
重试 2
↓
失败
↓
记录日志 + 告警
更完善一些可以使用:
消息队列
例如:
业务请求
|
v
更新 MySQL
|
v
发送缓存删除消息
|
v
Kafka / RocketMQ
|
v
消费者删除 Redis
|
+----成功----> END
|
+----失败----> 重试
这样缓存删除失败之后,可以借助消息系统提供的重试能力继续处理。
当然,引入 MQ 以后系统复杂度也会上升。
所以还是那句话:
一致性做到什么程度,最终取决于业务要求。
十一、方案六:MySQL Binlog + 异步更新 Redis
如果我们的目标不是:
尽可能实时
而是:
最终一定一致
那么还有一种非常经典的方案:
监听 MySQL Binlog。
流程:
业务请求
|
v
更新 MySQL
|
v
产生 Binlog
|
v
Binlog 订阅程序
|
v
消息队列
|
v
消费者
|
v
更新 / 删除 Redis
比如:
MySQL:10 → 11
数据库事务提交以后,会产生对应的 Binlog。
我们可以通过类似:
Canal
这样的组件订阅 Binlog。
然后:
Canal
↓
Kafka
↓
Consumer
↓
Redis
最终把 Redis 更新成:
11
这种方案最大的好处是:
缓存同步从业务代码里解耦出来了。
业务代码只需要:
更新 MySQL
至于:
Redis
Elasticsearch
数据仓库
其他下游系统
都可以消费数据库变更事件进行同步。
整个架构就变成:
MySQL
|
Binlog
|
Canal
|
Kafka
/ | \
/ | \
v v v
Redis ES 数据仓库
这其实已经不仅仅是:
缓存一致性
而是:
数据变更事件驱动
了。
十二、Binlog 方案有什么缺点?
没有免费的午餐。
Binlog 方案最大的特点就是:
异步
既然是异步,就意味着:
MySQL 更新成功之后,Redis 不可能在同一个瞬间立即更新。
比如:
T1:MySQL = 11
T2:Binlog 被消费
T3:Redis = 11
那么:
T1 ~ T3
之间就存在一个短暂的不一致窗口。
如果这时候有人查询:
Redis
可能还是:
10
所以这种方案保证的是:
最终一致性
而不是:
强一致性。
它特别适合:
异地数据同步
搜索索引同步
数据仓库
数据汇总
缓存异步刷新
跨系统数据分发
但如果是:
秒杀库存
抢购资格
账户余额
这种对实时正确性要求非常高的数据,就不能简单依赖一个异步缓存同步方案解决核心一致性问题。
十三、6 种方案放在一起比较
现在我们把前面的内容全部放在一起。
| 方案 | 推荐程度 | 主要问题 |
|---|---|---|
| 先 Redis,再 MySQL | ❌ 不推荐 | DB 失败后缓存产生脏数据 |
| 先 MySQL,再 Redis | ⚠️ 不推荐 | 并发更新可能导致旧值覆盖新值 |
| 先删 Redis,再 MySQL | ❌ 不推荐 | 更新 DB 期间读请求可能回写旧缓存 |
| 删除 Redis → MySQL → 再删 Redis | ⚠️ 可用 | 实现复杂,需要处理延迟和重试 |
| MySQL → 删除 Redis | ✅ 推荐 | 极端情况下仍可能短暂不一致 |
| MySQL → Binlog → Redis | ✅ 最终一致性 | 存在异步延迟 |
如果只记住其中最重要的两个方案,可以记:
实时性优先:
MySQL → 删除 Redis
最终一致性:
MySQL → Binlog → MQ → Redis
十四、为什么“先写 MySQL,再删除 Redis”是常见推荐方案?
现在重新看这个方案:
更新 MySQL
↓
删除 Redis
它背后其实有三个非常重要的设计思想。
1. MySQL 是唯一可信数据源
始终坚持:
MySQL = Source of Truth
Redis 只是:
缓存副本
因此永远应该优先保证:
MySQL 正确
而不是:
Redis 正确
2. 缓存错了就删,不要费劲修改
不要:
DB 改成什么
Redis 就跟着改成什么
而是:
DB 更新
↓
Redis 删除
让下一次查询重新构建缓存。
这样系统会简单很多。
3. 接受极小概率的短暂不一致
很多系统根本不需要:
100% 强一致
比如:
用户昵称
文章详情
商品介绍
文章点赞数
排行榜
用户主页
晚几十毫秒或者几百毫秒看到新数据,大多数时候没有什么影响。
如果为了理论上的绝对一致,引入:
分布式事务
复杂锁
多阶段提交
同步等待
反而可能导致:
吞吐下降
延迟升高
系统复杂
故障点增加
所以工程设计真正需要考虑的是:
业务能容忍什么程度的不一致?
而不是一看到 Redis + MySQL,就条件反射地追求强一致。
十五、那什么业务不能这么干?
有些数据就不能简单接受:
最终一致
比如:
库存
余额
优惠券数量
秒杀资格
支付状态
账户资产
假设库存:
只剩 1 件
Redis 显示:
库存 = 1
MySQL 实际已经:
库存 = 0
这时候再放一个请求进来,就可能造成:
超卖
所以这类场景里,一个非常重要的原则是:
不要把普通缓存的一致性方案,当成核心交易数据的一致性方案。
Redis 可以参与:
限流
预扣
资格判断
热点保护
但最终的库存扣减、账户余额等核心状态,需要另外设计完整的一致性机制。
这已经属于:
分布式事务
库存模型
幂等
状态机
消息事务
补偿机制
等更大的话题了。
十六、缓存到底应该设置过期时间吗?
这里还有一个很有争议的问题:
既然我们已经通过删除缓存保证一致性,还需要 TTL 吗?
从一致性的角度来看:
TTL
不应该成为主要保障。
因为假设:
MySQL = 11
Redis = 10
TTL = 30分钟
你不能说:
没事,30 分钟以后它自己就好了。
如果业务要求数据尽快一致,那么:
30分钟错误数据
显然不能接受。
所以:
TTL 更适合作为兜底,而不是主要一致性方案。
正常链路应该是:
更新 DB
↓
主动删除缓存
↓
失败重试
TTL 的作用则更像:
即使所有补偿机制都出问题
↓
错误缓存也不会永久存在
因此在实际系统里,我通常会把:
主动删除
+
失败重试
+
合理 TTL
组合使用。
它们解决的是不同层次的问题。
十七、如果让我设计生产环境方案
如果是普通互联网业务,例如:
文章详情
用户资料
商品信息
配置数据
排行榜信息
我会优先考虑:
更新请求
|
v
更新 MySQL
|
v
删除 Redis
|
+-----+-----+
| |
成功 失败
| |
v v
END 重试
|
多次失败
|
v
MQ / 告警
查询仍然走标准 Cache Aside:
查询请求
|
v
查询 Redis
|
+-----+-----+
| |
命中 未命中
| |
v v
返回 查询 MySQL
|
v
回写 Redis
|
v
返回
这套方案:
实现简单
性能不错
实时性高
维护成本低
对于绝大多数普通缓存场景已经足够。
十八、如果数据同步链路比较复杂呢?
假设系统慢慢变大。
一次 MySQL 数据变化,需要同步:
Redis
Elasticsearch
推荐系统
数据仓库
风控系统
统计系统
如果全部写进业务代码:
updateMysql();
updateRedis();
updateElasticSearch();
sendRecommendEvent();
sendRiskEvent();
sendStatisticsEvent();
业务代码会越来越恐怖。
这时候就可以考虑:
MySQL
|
Binlog
|
Canal
|
Kafka
|
+------------+------------+------------+
| | | |
Redis ES 风控系统 数据仓库
这也是 Binlog + MQ 真正有价值的地方:
它解决的不只是 Redis 缓存,而是数据库变更如何可靠地分发给多个下游系统。
十九、从这个问题还能学到什么?
MySQL + Redis 一致性,其实是一个非常经典的系统设计问题。
它表面上问的是:
先更新谁?
后更新谁?
但真正往下挖,会发现它涉及很多后端核心知识:
缓存模式
并发
原子性
最终一致性
消息队列
Binlog
失败重试
幂等
补偿机制
分布式系统
其中最值得记住的不是某一个固定答案,而是下面几个原则。
第一:数据库通常才是真正的数据源
MySQL = Source of Truth
Redis = Cache
不要反过来。
第二:缓存更新优先考虑“失效”,而不是“同步修改”
也就是:
更新 DB
↓
删除 Cache
而不是:
更新 DB
↓
更新 Cache
第三:不存在免费的强一致性
只要数据跨越:
MySQL
+
Redis
两个独立系统,就必须面对失败、并发和网络问题。
一致性越强,通常意味着:
更高复杂度
更高延迟
更低吞吐
第四:先问业务允许多大的不一致
普通文章详情:
短暂不一致
通常可以接受
账户余额:
短暂不一致
可能都不能接受
技术方案必须服务于业务要求。
第五:失败永远要考虑重试和补偿
千万不要只写:
更新 MySQL
↓
删除 Redis
然后认为万事大吉。
真正的生产系统还要继续问:
删除失败怎么办?
重试失败怎么办?
消息重复怎么办?
消息丢失怎么办?
服务重启怎么办?
如何发现长期不一致?
这才是从“会写代码”走向“会设计系统”的关键一步。
二十、最后总结
MySQL + Redis 的一致性问题,可以浓缩成一句话:
不要试图把 MySQL 和 Redis 当成两个同等重要的数据源,而应该明确 MySQL 是真实数据,Redis 是可以被删除和重建的缓存副本。
因此,对于普通高并发缓存场景,比较推荐:
更新 MySQL
↓
删除 Redis
也就是:
Cache Aside
同时配合:
删除失败重试
+
异常告警
+
合理 TTL 兜底
如果系统需要更加完善的最终一致性,可以进一步升级:
MySQL
↓
Binlog
↓
Canal
↓
Kafka
↓
Redis
实现异步的数据同步。
所以最终可以把方案选择简单记成:
普通高并发业务
↓
MySQL → 删除 Redis
↓
实时性更好
复杂数据同步场景
↓
MySQL → Binlog → MQ → Redis
↓
最终一致性更好
至于:
先 Redis → MySQL
MySQL → 直接更新 Redis
删除 Redis → MySQL
这些方案为什么不够好,现在再回头看,原因就非常清楚了。
其实缓存一致性真正难的,从来都不是记住:
先删还是后删
而是理解:
为什么会产生并发乱序?
为什么会出现脏数据?
哪个系统才是真正的数据源?
失败之后如何恢复?
业务到底需要强一致还是最终一致?
当你能从这几个角度分析问题的时候,面对的不管是:
MySQL + Redis
还是:
MySQL + Elasticsearch
MySQL + Kafka
Redis + DB
数据库 + 搜索引擎
本质上都可以沿着同一套思路继续分析。
因为你真正掌握的,已经不只是:
“Redis 缓存到底应该怎么更新?”
而是:
“分布式系统中,两份数据到底应该如何保持一致?”