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数据一致性的全部经验分享,希望能帮到大家。如果大家有其他踩坑经历或者更好的解决方案,欢迎在评论区留言交流,我们一起学习、一起进步,直奔技术标杆~

相关推荐
葡萄成熟时 !2 小时前
JAVA 常用API学习笔记
java·笔记·学习
Lyyaoo.2 小时前
Spring Boot Validation 声明式参数校验
spring boot·后端·mysql
Rain的Java大神之路2 小时前
介绍一下分布式事务
java·分布式·后端·spring·spring cloud·架构·springcloud
oradh3 小时前
Oracle闪回技术操作总结
数据库·oracle·oracle闪回技术操作总结·oracle闪回·flashback技术
吴声子夜歌3 小时前
Java面试题——基础(一)
java·开发语言
南城以南溫暖如初1474 小时前
外卖CPS实战指南:从技术选型到运营落地全流程解析
java·spring boot·mysql·架构·vue
ltl4 小时前
RocksDB 并发 Compaction 与 Rate Limiter
数据库
傻啦嘿哟4 小时前
英雄联盟皮肤爬虫:爬取全皮肤价格与特效,做比价工具
java·c++·爬虫
码匠许师傅5 小时前
【C++ 面试真题】24. 聊聊 C++ 的时间日期处理
java·c++·面试
闻道且行之5 小时前
图片处理助手|泊松融合原理 + C++ 工程实现,seamlessClone 三模式一次讲透
数据库·c++·人工智能·opencv