MySQL与Redis数据一致性实战方案(避坑指南)

大家好,我是直奔標杆,今天和大家聊聊MySQL与Redis数据一致性的那些事儿------先给大家泼盆冷水:强一致性根本做不到,除非你不用缓存

只要用了缓存,数据库和缓存之间就必然存在不一致的窗口期。我们能做的,不是消灭这个窗口,而是尽量缩短它,并且做好兜底措施,避免线上事故。这也是我踩过不少坑后总结的核心经验,分享给大家,一起避坑、共同进步~

一、为什么会出现数据不一致?

先从最简单的场景说起,大家一看就懂:

  1. 用户A修改数据,成功更新了MySQL数据库;

  2. 数据库更新完成,但还没来得及更新Redis缓存;

  3. 用户B发起查询,直接命中Redis缓存,拿到的是未更新的旧数据。

这就是最基础的不一致场景,而更棘手的,是并发情况下的不一致,也是线上最容易出问题的地方,给大家拆解一下:

假设有两个线程,线程1负责"更新数据库→删除缓存",线程2负责"查询缓存→查数据库→写缓存",两种时序,两种结果:

正常时序(无问题):

  1. 线程1 更新数据库(写入新值);

  2. 线程2 查询缓存(未命中);

  3. 线程2 查询数据库(拿到最新值);

  4. 线程1 删除缓存;

  5. 线程2 写入缓存(新值)。

异常时序(出现不一致):

  1. 线程2 查询缓存(未命中);

  2. 线程2 查询数据库(拿到旧值);

  3. 线程1 更新数据库(写入新值);

  4. 线程1 删除缓存;

  5. 线程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. 线程1 更新数据库为A;

  2. 线程2 更新数据库为B;

  3. 线程2 更新缓存为B;

  4. 线程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等中间件,增加运维成本;

  • 存在轻微延迟(通常毫秒到秒级),无法做到实时一致。

三、实际项目怎么选?(经验总结)

结合我过往的项目经验,给大家整理了不同场景的选型建议,直奔標杆建议大家按需选择,不用盲目追求复杂方案:

  1. 大部分场景(读多写少):Cache Aside就够了。比如用户信息、商品详情、文章内容等,加上合理的缓存过期时间,短暂的不一致业务上完全能接受,性价比最高。

  2. 写操作频繁(如库存、积分):Cache Aside + 延迟双删。通过延迟双删,降低并发场景下的不一致概率,兼顾性能和一致性。

  3. 一致性要求高(金融、交易):binlog同步(Canal/Maxwell)。或者干脆不用缓存,直接读数据库,确保强一致性。

  4. 所有场景必做:给缓存设置过期时间兜底。不管用什么方案,就算前面的逻辑出问题(比如缓存删除失败),缓存过期后也能自动从数据库加载最新值,恢复一致性。

四、踩过的坑&解决方案(重点避坑)

前面说的都是理论和方案,接下来和大家分享几个我实际踩过的线上坑,以及对应的解决方案,希望大家能避开这些雷区。

坑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缓存。某天运营反馈:修改了商品价格后,页面上还是老价格,过了好几分钟才显示新价格,影响用户下单。

问题排查:

  1. 运营在后台修改价格,成功更新了MySQL数据库;

  2. 代码逻辑是"更新数据库后删除缓存";

  3. 删除缓存时,Redis出现连接超时(当时Redis集群有轻微抖动);

  4. 代码只捕获了异常,打了日志,没有做任何重试或兜底处理;

  5. 缓存里的旧价格一直存在,直到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%的不一致问题:

  1. 更新数据库后,删缓存,而不是更新缓存;

  2. 无论用什么方案,一定要给缓存设置过期时间,做好兜底;

  3. 分布式系统中,接受最终一致就好,别盲目追求强一致(成本太高,得不偿失)。

以上就是我关于MySQL与Redis数据一致性的全部经验分享,希望能帮到大家。如果大家有其他踩坑经历或者更好的解决方案,欢迎在评论区留言交流,我们一起学习、一起进步,直奔技术标杆~

相关推荐
java1234_小锋1 小时前
Java进程突然挂了如何排查?
java·开发语言
乐hh1 小时前
KingbaseV8R6配置SSL
数据库·ssl
m0_690825821 小时前
检测三位随机数中重复数字的Python实现方法
jvm·数据库·python
夕除1 小时前
spring boot--04
java·spring boot
阿正呀1 小时前
Redis如何处理数据持久化与主从切换的冲突_确保选主期间的数据安全落盘.txt
jvm·数据库·python
m0_470857641 小时前
php中的foreach循环?_?PHP中foreach循环的语法结构与遍历数组对象详解.txt
jvm·数据库·python
彳亍1011 小时前
HTML5中Canvas局部刷新区域重绘的算法优化
jvm·数据库·python
拾起零碎1 小时前
U8/中途启用批次管理-批次档案无效
数据库
2301_779622411 小时前
为什么宝塔面板网站无法正常连接外部远程数据库_检查服务器安全组放行端口并开启IP授权
jvm·数据库·python