大家好,我是直奔標杆,今天和大家聊聊MySQL与Redis数据一致性的那些事儿------先给大家泼盆冷水:强一致性根本做不到,除非你不用缓存。
只要用了缓存,数据库和缓存之间就必然存在不一致的窗口期。我们能做的,不是消灭这个窗口,而是尽量缩短它,并且做好兜底措施,避免线上事故。这也是我踩过不少坑后总结的核心经验,分享给大家,一起避坑、共同进步~
一、为什么会出现数据不一致?
先从最简单的场景说起,大家一看就懂:
-
用户A修改数据,成功更新了MySQL数据库;
-
数据库更新完成,但还没来得及更新Redis缓存;
-
用户B发起查询,直接命中Redis缓存,拿到的是未更新的旧数据。
这就是最基础的不一致场景,而更棘手的,是并发情况下的不一致,也是线上最容易出问题的地方,给大家拆解一下:
假设有两个线程,线程1负责"更新数据库→删除缓存",线程2负责"查询缓存→查数据库→写缓存",两种时序,两种结果:
正常时序(无问题):
-
线程1 更新数据库(写入新值);
-
线程2 查询缓存(未命中);
-
线程2 查询数据库(拿到最新值);
-
线程1 删除缓存;
-
线程2 写入缓存(新值)。
异常时序(出现不一致):
-
线程2 查询缓存(未命中);
-
线程2 查询数据库(拿到旧值);
-
线程1 更新数据库(写入新值);
-
线程1 删除缓存;
-
线程2 写入缓存(旧值)。
这种并发导致的不一致,是实际开发中最难处理的,也是我今天重点和大家分享的痛点。
二、常用解决方案(附代码实战)
结合我多年的开发经验,整理了3种最常用的方案,从简单到复杂,适配不同业务场景,大家可以按需选择。
1. Cache Aside(缓存旁路模式)------最常用,性价比最高
这是日常开发中用得最多的方案,逻辑简单、易实现,核心原则:读先查缓存,写先更数据库再删缓存。
直接上代码(Java示例),大家可以直接参考复用:
java
// 读操作:先缓存后数据库
public User getUser(Long id) {
// 1. 先查Redis缓存
User user = redis.get("user:" + id);
if (user != null) {
return user;
}
// 2. 缓存未命中,查MySQL数据库
user = db.selectById(id);
if (user != null) {
// 3. 将数据库查询结果写入缓存,设置过期时间
redis.setex("user:" + id, 3600, user);
}
return user;
}
// 写操作:先更数据库,再删缓存
public void updateUser(User user) {
// 1. 优先更新MySQL数据库
db.update(user);
// 2. 再删除Redis对应缓存(关键:不是更新缓存)
redis.del("user:" + user.getId());
}
这里有个关键问题,也是很多新手容易踩的坑:为什么是删除缓存,而不是直接更新缓存?
给大家举个并发场景的反例就懂了:
线程1要把数据更改为A,线程2要把数据更改为B,时序如下:
-
线程1 更新数据库为A;
-
线程2 更新数据库为B;
-
线程2 更新缓存为B;
-
线程1 更新缓存为A。
最终结果:数据库是B(正确值),缓存是A(旧值),直接出现不一致。而删除缓存就不会有这个问题------不管谁先删,最终缓存都是空的,下次查询会从数据库加载最新值,从根源上避免了这种并发问题。
2. 延迟双删------针对并发问题,加一层保险
如果业务中写操作比较频繁,单纯的Cache Aside还是会有并发不一致的风险,这时候就可以加上"延迟双删",相当于给缓存删除加了一层兜底,核心逻辑:先删缓存→更数据库→延迟一段时间再删一次缓存。
代码优化如下:
java
public void updateUser(User user) {
Long id = user.getId();
// 1. 第一次删除缓存:让并发读请求去数据库拿最新数据
redis.del("user:" + id);
// 2. 更新MySQL数据库
db.update(user);
// 3. 延迟一段时间,再删一次缓存(清理更新期间可能写入的旧缓存)
Thread.sleep(500); // 也可以用消息队列实现延迟,更优雅
redis.del("user:" + id);
}
这里重点说下延迟时间怎么定:通常设置为业务读请求的耗时 + 几百毫秒的buffer。比如读请求耗时200ms,延迟时间设500ms就足够,既能覆盖读请求的耗时,也能应对网络抖动等异常情况。
3. binlog同步------更可靠,但架构更重
如果业务对数据一致性要求很高(比如金融、交易场景),前面两种方案就不够了,这时候可以用"binlog同步"方案。核心逻辑:监听MySQL的binlog日志,当数据发生变更时,自动触发Redis缓存删除/更新。
常用的中间件有Canal、Maxwell,这里用Canal举个示例:
java
// Canal监听binlog示例
@CanalEventListener
public void onEvent(CanalEntry entry) {
// 只监听user表的变更
if (entry.getTableName().equals("user")) {
Long userId = entry.getColumn("id");
// 当发生更新、删除操作时,删除对应缓存
if (entry.getEventType() == UPDATE || entry.getEventType() == DELETE) {
redis.del("user:" + userId);
}
}
}
优点:
-
业务代码解耦,不用在业务逻辑中关心缓存更新;
-
可靠性高,MySQL的binlog日志不会丢失,缓存更新有保障。
缺点:
-
架构变复杂,需要额外部署Canal等中间件,增加运维成本;
-
存在轻微延迟(通常毫秒到秒级),无法做到实时一致。
三、实际项目怎么选?(经验总结)
结合我过往的项目经验,给大家整理了不同场景的选型建议,直奔標杆建议大家按需选择,不用盲目追求复杂方案:
-
大部分场景(读多写少):Cache Aside就够了。比如用户信息、商品详情、文章内容等,加上合理的缓存过期时间,短暂的不一致业务上完全能接受,性价比最高。
-
写操作频繁(如库存、积分):Cache Aside + 延迟双删。通过延迟双删,降低并发场景下的不一致概率,兼顾性能和一致性。
-
一致性要求高(金融、交易):binlog同步(Canal/Maxwell)。或者干脆不用缓存,直接读数据库,确保强一致性。
-
所有场景必做:给缓存设置过期时间兜底。不管用什么方案,就算前面的逻辑出问题(比如缓存删除失败),缓存过期后也能自动从数据库加载最新值,恢复一致性。
四、踩过的坑&解决方案(重点避坑)
前面说的都是理论和方案,接下来和大家分享几个我实际踩过的线上坑,以及对应的解决方案,希望大家能避开这些雷区。
坑1:缓存击穿导致的不一致
场景:热点数据缓存过期瞬间,大量请求同时打到数据库,同时去回写缓存。这时候如果正好有更新操作,很容易导致缓存写入旧值,出现不一致。
解决方案:用分布式锁保证只有一个请求去回写缓存,避免并发回写。代码优化如下:
java
public User getUser(Long id) {
User user = redis.get("user:" + id);
if (user != null) return user;
// 加分布式锁,确保只有一个请求去查询数据库、回写缓存
String lockKey = "lock:user:" + id;
if (redis.setnx(lockKey, "1", 10)) { // 锁过期时间10s,避免死锁
try {
// 双重检查:防止拿到锁后,缓存已经被其他请求写入
user = redis.get("user:" + id);
if (user != null) return user;
// 查数据库、回写缓存
user = db.selectById(id);
redis.setex("user:" + id, 3600, user);
} finally {
// 最终释放锁
redis.del(lockKey);
}
} else {
// 没拿到锁,延迟50ms重试
Thread.sleep(50);
return getUser(id);
}
return user;
}
坑2:删除缓存失败,导致不一致
场景:更新数据库后,删除缓存时遇到网络抖动、Redis连接超时等问题,缓存删除失败,缓存里还是旧数据,一直不更新。
解决方案:删除失败时,发送到消息队列,异步重试,确保缓存最终能删除。代码示例:
java
public void updateUser(User user) {
Long id = user.getId();
// 先更新数据库
db.update(user);
try {
// 尝试删除缓存
redis.del("user:" + id);
} catch (Exception e) {
// 删除失败,发送到消息队列,异步重试
mq.send("cache-delete-retry", "user:" + id);
log.error("删除缓存失败,已发送重试消息", e);
}
}
坑3:读写分离架构下的不一致
场景:数据库用主从架构,写操作走主库,读操作走从库。主从同步有延迟,可能出现"更新主库→删除缓存→读从库(旧值)→回写缓存(旧值)"的情况,导致不一致。
解决方案(二选一):
-
关键业务(如交易、支付)强制读主库,牺牲一点性能,保证数据一致;
-
延迟双删的时间设置长一点,覆盖主从同步的延迟(比如主从延迟1s,延迟时间设2s)。
五、一次真实线上事故复盘(干货分享)
最后和大家分享一次我去年遇到的线上事故,复盘一下问题原因和修复方案,希望大家能引以为戒。
事故场景:
电商系统,商品详情页用了Redis缓存。某天运营反馈:修改了商品价格后,页面上还是老价格,过了好几分钟才显示新价格,影响用户下单。
问题排查:
-
运营在后台修改价格,成功更新了MySQL数据库;
-
代码逻辑是"更新数据库后删除缓存";
-
删除缓存时,Redis出现连接超时(当时Redis集群有轻微抖动);
-
代码只捕获了异常,打了日志,没有做任何重试或兜底处理;
-
缓存里的旧价格一直存在,直到30分钟后缓存过期,才加载新价格。
问题代码(反面示例):
java
public void updatePrice(Long productId, BigDecimal price) {
db.updatePrice(productId, price);
try {
redis.del("product:" + productId);
} catch (Exception e) {
log.error("删除缓存失败", e); // 只打日志,无任何兜底
}
}
修复方案(正面示例):
java
public void updatePrice(Long productId, BigDecimal price) {
db.updatePrice(productId, price);
// 重试机制:最多重试3次
int retry = 0;
while (retry < 3) {
try {
redis.del("product:" + productId);
return; // 删除成功,直接返回
} catch (Exception e) {
retry++;
if (retry >= 3) {
// 3次重试失败,发送到消息队列异步处理
mq.send("cache-invalidate", "product:" + productId);
log.error("删缓存失败,已发送MQ兜底", e);
}
// 每次重试间隔100ms
Thread.sleep(100);
}
}
}
// 消息队列消费者,兜底删除缓存
@MQListener("cache-invalidate")
public void onCacheInvalidate(String key) {
redis.del(key);
}
额外兜底:将缓存过期时间从30分钟改成5分钟。就算前面的重试、MQ兜底都失败了,最多5分钟,缓存也会自动过期,加载最新价格,将影响降到最低。
事故教训:删除缓存失败,绝对不能只打个日志就完事,一定要有重试+MQ兜底机制,这是保证数据一致性的最后一道防线。
六、总结(核心要点)
直奔標杆给大家整理了核心总结,建议收藏,方便后续开发参考:
| 解决方案 | 复杂度 | 一致性 | 适用场景 |
|---|---|---|---|
| Cache Aside | 低 | 最终一致 | 大部分读多写少场景 |
| 延迟双删 | 中 | 最终一致(更优) | 写操作频繁场景 |
| binlog同步 | 高 | 最终一致(可靠) | 一致性要求高的场景(金融、交易) |
| 不用缓存 | - | 强一致 | 金融核心交易场景 |
最后记住3个核心原则,能避开80%的不一致问题:
-
更新数据库后,删缓存,而不是更新缓存;
-
无论用什么方案,一定要给缓存设置过期时间,做好兜底;
-
分布式系统中,接受最终一致就好,别盲目追求强一致(成本太高,得不偿失)。
以上就是我关于MySQL与Redis数据一致性的全部经验分享,希望能帮到大家。如果大家有其他踩坑经历或者更好的解决方案,欢迎在评论区留言交流,我们一起学习、一起进步,直奔技术标杆~