上一篇我们深入讲解了Redis事务与锁机制,搞定了单Redis场景下的原子操作和并发冲突。但在实际生产中,Redis作为缓存层、MySQL作为持久化存储层的双库架构极为常见,缓存与数据库的数据不一致就成了高频痛点:要么读到老数据,要么缓存脏数据,轻则影响业务体验,重则导致数据错乱。

很多同学简单认为“更新完数据库再删缓存就行”,却忽略了并发场景、网络延迟、服务宕机带来的一致性风险。本篇就从一致性问题根源、经典方案对比、生产级落地实现、异常兜底处理四个维度,彻底讲透Redis与MySQL双写一致性的全套解决方案,让缓存和数据库数据始终保持同步。

核心前提:强一致性成本极高,缓存架构追求最终一致性即可,在可控时间内让数据达成一致,兼顾性能与准确性。

一、为什么会出现双写不一致?

Redis缓存的核心设计是读多写少,读流程遵循:读缓存→缓存未命中→读数据库→写入缓存,本身不会出现不一致;问题全出在写操作,一旦写库和更删缓存的顺序、原子性出问题,就会产生脏数据。

典型不一致场景

  • 并发更新冲突:线程A更新数据库,线程B后更新数据库但先删除缓存,导致A的旧数据写入缓存,形成脏数据
  • 读写并发冲突:线程A更新数据库后,还没来得及删缓存,线程B读取到旧缓存数据
  • 操作失败:数据库更新成功,缓存删除/更新失败(网络波动、Redis宕机)
  • 延时问题:主从同步延迟、消息队列延迟,导致缓存与数据库数据错位

解决一致性问题的核心思路:让缓存失效优先于数据写入,或保证缓存操作的最终执行,杜绝旧数据覆盖新数据的情况。


二、经典双写一致性方案对比

市面上主流的一致性方案各有优劣,我们按复杂度、一致性、性能逐一拆解,明确生产选型。

方案执行流程优点缺点适用场景
先更缓存,再更数据库更新Redis→更新MySQL实现简单极易不一致:Redis更新成功,MySQL更新失败,缓存永久脏数据严禁使用,无业务适配性
先更数据库,再更缓存更新MySQL→更新Redis数据实时性高并发写场景下,后更新的库先更缓存,旧数据覆盖新数据写请求极少、无并发的单线程场景
先更数据库,再删缓存更新MySQL→删除Redis实现简单、并发冲突概率低、性能高极端读写并发会出现脏数据,需兜底绝大多数业务首选,通用缓存场景
延时双删更库→删缓存→延时→再删缓存解决读写并发冲突,一致性更高需控制延时时间,依赖延时组件读多写少、对一致性要求较高的业务
Canal监听binlog更新MySQL→Canal监听binlog→异步删更缓存业务解耦、一致性强、无侵入部署复杂、有一定延时大数据量、高并发、强最终一致性需求

避坑结论:禁止使用“先更缓存再更库”,普通业务优先选用先更库再删缓存+延时双删,高并发复杂业务选用Canal异步同步方案。


三、生产级方案落地:先更新数据库,再删除缓存

这是性价比最高、落地最简单的方案,核心逻辑是:不更新缓存,只删除缓存,让下一次读请求主动加载最新数据到缓存,从根源避免旧数据覆盖问题。

1. 核心执行流程

  1. 客户端发起更新请求,先成功更新MySQL数据库
  2. 数据库更新完成后,立即删除Redis对应的缓存Key
  3. 后续读请求发现缓存未命中,查询MySQL最新数据,重新写入Redis

2. 代码实操(Java版)

/**
 * 更新用户信息 + 缓存删除
 */
@Transactional(rollbackFor = Exception.class)
public boolean updateUserInfo(UserInfo userInfo) {
    // 1. 优先更新数据库
    boolean updateSuccess = userMapper.updateById(userInfo) > 0;
    if (updateSuccess) {
        // 2. 数据库更新成功,删除对应缓存
        String cacheKey = "user:info:" + userInfo.getUserId();
        try {
            redisTemplate.delete(cacheKey);
        } catch (Exception e) {
            // 缓存删除失败,打印日志+告警,后续通过定时任务兜底
            log.error("缓存删除失败,key:{}", cacheKey, e);
            // 可将失败Key存入MQ/数据库,异步重试
            asyncRetryDelete(cacheKey);
        }
    }
    return updateSuccess;
}

/**
 * 读请求:缓存+数据库双读
 */
public UserInfo getUserInfo(Long userId) {
    String cacheKey = "user:info:" + userId;
    // 查缓存
    UserInfo cacheUser = redisTemplate.opsForValue().get(cacheKey);
    if (cacheUser != null) {
        return cacheUser;
    }
    // 缓存未命中,查数据库
    UserInfo dbUser = userMapper.selectById(userId);
    if (dbUser != null) {
        // 写入缓存,设置过期时间
        redisTemplate.opsForValue().set(cacheKey, dbUser, 2, TimeUnit.HOURS);
    }
    return dbUser;
}

3. 极端场景兜底:延时双删

针对读写并发冲突(更新数据库后,读请求先加载旧数据到缓存,再执行删缓存),在基础方案上增加延时双删

  1. 更新MySQL数据库
  2. 立即删除Redis缓存
  3. 延时500ms~1s(覆盖数据库主从同步、网络延迟时间)
  4. 再次删除Redis缓存,彻底清理脏数据

延时可通过线程池、MQ延时队列、定时任务实现,避免阻塞主线程。


四、高并发强一致方案:Canal监听binlog

对于秒杀、商品详情等高并发场景,业务代码耦合缓存操作会影响性能,推荐使用Canal异步同步方案,实现业务与缓存同步解耦。

1. 核心原理

  • Canal伪装成MySQL从节点,监听MySQL的binlog日志
  • 解析binlog获取数据变更记录,发送到消息队列(Kafka/RocketMQ)
  • 消费队列消息,异步删除/更新Redis缓存,不影响主线程业务

2. 方案优势

  • 无业务侵入:业务代码只关注数据库操作,无需管缓存
  • 高一致性:基于binlog有序消费,数据同步精准
  • 高可用:支持异步重试、失败告警,不阻塞业务流程

3. 落地要点

  • 开启MySQL binlog,设置为row模式(记录数据变更详情)
  • 部署Canal服务,配置监听的库表
  • 消费消息时做幂等处理,避免重复执行
  • 同步失败重试,死信队列兜底告警

五、缓存一致性兜底保障措施

无论选用哪种方案,都需要兜底机制,应对网络波动、服务宕机、组件故障等异常情况:

1. 缓存过期时间兜底

所有缓存必须设置合理过期时间(如1~2小时),即便出现脏数据,也会在过期后自动失效,保证最终一致性。

2. 异步重试机制

缓存删除/更新失败时,将失败Key存入MQ或数据库,启动异步任务重试,直到执行成功。

3. 定时任务校验

定时扫描热点数据,对比Redis与MySQL数据,发现不一致立即修复缓存。

4. 分布式锁控制并发

对同一Key的读写操作,加分布式锁串行执行,彻底杜绝并发冲突。


六、方案选型总结

  • 普通业务(后台管理、用户中心):选用先更库再删缓存,简单高效,满足90%场景
  • 读多写少、一致性要求高:选用先更库再删缓存+延时双删
  • 高并发、大数据量(电商、秒杀):选用Canal binlog异步同步

核心口诀:写请求不更缓存只删除,读请求主动加载新数据,异常情况靠重试,过期时间做兜底,最终一致稳无忧。


结语与下篇预告

Redis与MySQL双写一致性没有银弹,核心是在性能、复杂度、一致性之间做取舍,选用适合业务的方案并做好兜底,就能彻底杜绝脏数据问题。

发表回复