【Redis】双写一致性:延迟双删 / 读写锁 / 异步通知 / Canal,一文全解

在后端系统架构中,为了提升读请求性能、减轻数据库压力,我们通常会引入 Redis 等分布式缓存,将热点数据缓存起来,形成「数据库(MySQL)+ 缓存(Redis)」的双层存储架构。

双写一致性 ,就是指在这种架构下,当数据发生变更时,如何保证 数据库中的数据缓存中的数据 保持一致,避免出现"缓存存旧值、数据库存新值 "或"缓存有值、数据库无值"的脏数据问题,确保业务查询结果的准确性。

前言:MySQL与Redis双写一致性解决方案

保证MySQL和Redis双写一致性,核心是根据业务对"一致性/性能"的要求选择方案,我从核心方案、进阶方案、兜底方案三个维度,结合优缺点和底层逻辑给您讲清楚:

可以根据业务场景选择下述缓存一致性方案:

  • 缓存双删:如果公司现有消息队列中间件,可以考虑使用该方案,反之则不需要考虑。
  • 先写数据库再删缓存:这种方案从实时性以及技术实现复杂度来说都比较不错,推荐大家使用这种方案。
  • Binlog 异步更新缓存:如果希望实现最终一致性以及数据多中心模式,该方案无疑是最合适的。
一、核心方案:延迟双删(最常用,适配80%场景)

延迟双删其实是对「先删缓存再更新数据库」方案的优化和兜底,而「先更新数据库再删缓存」是另一条独立的主流路径

1. 核心流程

写操作:先删缓存 → 更新数据库 → 延迟N秒(如1-5秒)再删一次缓存

读操作:缓存命中直接返回 → 缓存未命中 → 查数据库 → 回写缓存 → 返回数据

2. 各步骤优缺点
步骤 优点 缺点
先删缓存 避免更新DB后,旧缓存被读请求回填 极端并发下,仍可能出现脏数据
更新数据库 保证主数据(DB)准确性 单步操作,无额外缺点
延迟删第二次缓存 解决并发场景下的脏数据问题 增加少量延迟,需控制延迟时长
读未命中查DB回写缓存 保证缓存最终有数据,提升读性能 首次读/缓存失效时,有DB查询开销

不管是「先操作 Redis(缓存)再操作数据库」,还是「先操作数据库再操作 Redis」,本质上因为两个操作无法原子化执行,且高并发下读 / 写请求的执行顺序不可控,所以必然存在脏数据的可能。

我用两个最典型的并发时序场景,就能说清原因:

场景 1:先删除缓存,再操作数据库(左侧时序)
  1. 线程 1(写请求):执行第 1 步 → 删除缓存(缓存变为空)
  2. 线程 2(读请求) :执行第 2 步 → 查询缓存未命中,去数据库查到旧值 20
  3. 线程 2(读请求) :执行第 3 步 → 把旧值 20 写入缓存
  4. 线程 1(写请求) :执行第 4 步 → 更新数据库为 v=20(这里图里初始 DB 是 20,实际业务里是从旧值更新为新值,核心逻辑不变)

结果:数据库是新值,缓存里被写入了旧值 → 出现脏数据。

场景 2:先操作数据库,再删除缓存(右侧时序)
  1. 线程 1(读请求) :执行第 1 步 → 查询缓存未命中,去数据库查到旧值 20
  2. 线程 2(写请求) :执行第 2 步 → 更新数据库为 v=20(实际业务是从旧值更新为新值)
  3. 线程 2(写请求):执行第 3 步 → 删除缓存(缓存变为空)
  4. 线程 1(读请求) :执行第 4 步 → 把刚才查到的旧值 20 写入缓存

结果:数据库是新值,缓存里被写入了旧值 → 同样出现脏数据。

不管先操作哪一方,问题的根源都是:

  1. 操作非原子性:缓存和 DB 的操作是两步独立操作,中间有时间窗口;
  2. 并发时序不可控:高并发下,读请求可能卡在 "查 DB 拿到旧值" 和 "回写缓存" 之间,写请求刚好完成了更新,导致旧值被写回缓存;
  3. 缓存回写机制:读请求缓存未命中时,会自动查 DB 并回写缓存,这个 "回写" 动作是脏数据产生的最后一环。

这也是为什么单纯的 "先操作 A 再操作 B" 解决不了问题,需要延迟双删、加锁、异步通知等方案兜底的核心原因。

3. 删两次/延迟删除
  • 为什么要删两次缓存?
    解决"读请求在写请求更新DB前,已查到旧值并准备回写缓存"的极端场景:
    例:① 读请求缓存失效 → ② 读DB拿到旧值 → ③ 写请求删缓存 → ④ 写请求更DB → ⑤ 读请求把旧值写入缓存 → ⑥ 延迟删缓存,清空这个旧值。
    第二次删除是为了兜底,清空并发场景下被误写入的旧缓存。
  • 为什么要延迟删除?
    延迟时长需覆盖"读请求查DB+回写缓存"的耗时(一般1-5秒),确保能删掉"被并发读请求回填的旧缓存",如果立刻删第二次,可能读请求还没完成回写,删了也没用。
二、进阶方案:互斥锁/读写锁(强一致性场景)

互斥锁:强一致、性能低

读多写少,一般采用缓存

1. 互斥锁(分布式锁,如Redisson RLock)
  • 流程

    • 写操作加排他锁 → 删缓存 → 更新DB → 释放锁;
    • 读操作加共享锁 → 缓存未命中时,加排他锁查DB回写缓存。
  • 优点:严格保证数据强一致,无脏数据;

  • 缺点:加锁会阻塞请求,降低并发性能,可能出现死锁(需设置锁超时)。

2. 读写锁(Redisson ReadWriteLock)
  • 共享锁(读锁):多个读请求可同时加锁,不阻塞,保证读性能;
  • 排他锁(写锁):写请求加锁后,阻塞所有读/写请求,保证写操作原子性;
  • 适用场景:读多写少、对一致性要求极高的场景(如库存、优惠券);
  • 缺点:写请求会阻塞读请求,高并发写场景性能下降。
3.代码实现

核心说明

  • 锁粒度 :读写锁使用同一个 key ITEM_READ_WRITE_LOCK,保证读写互斥、读读共享。
  • 写锁逻辑 :写操作加排他锁,期间所有读 / 写请求都会阻塞,保证数据更新的原子性,更新后删除缓存触发下一次读请求重建缓存。
  • 读锁逻辑 :读操作加共享锁,多个读请求可并发执行;写锁释放前,读锁会等待,避免读到脏数据。
  • 兜底保障finally 块中释放锁,确保即使业务异常也不会导致锁泄漏。

排他锁(写锁)

java 复制代码
public void updateById(Integer id) {
// 获取读写锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
// 获取写锁
RLock writeLock = readWriteLock.writeLock();

try {
    // 加写锁
    writeLock.lock();
    System.out.println("writeLock...");

    // 1. 更新业务数据(示例:模拟更新商品信息)
    Item item = new Item(id, "华为手机", "华为手机", 5299.00);
    try {
        // 模拟业务处理耗时
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    // 2. 删除缓存
    redisTemplate.delete("item:" + id);
} finally {
    // 释放写锁
    writeLock.unlock();
}
}

共享锁(读锁)

java 复制代码
public Item getById(Integer id) {
    // 获取读写锁
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
    // 获取读锁
    RLock readLock = readWriteLock.readLock();

    try {
        // 加读锁
        readLock.lock();
        System.out.println("readLock...");

        // 1. 先查询缓存
        Item item = (Item) redisTemplate.opsForValue().get("item:" + id);
        if (item != null) {
            // 缓存命中,直接返回
            return item;
        }

        // 2. 缓存未命中,查询业务数据(示例:模拟从数据库查询)
        item = new Item(id, "华为手机", "华为手机", 5999.00);

        // 3. 写入缓存
        redisTemplate.opsForValue().set("item:" + id, item);

        // 4. 返回数据
        return item;
    } finally {
        // 释放读锁
        readLock.unlock();
    }
}
三、高级方案:异步通知(解耦+高可用)
1. MQ异步通知方案
  • 流程:更新DB → 发送"删除缓存"消息到MQ → 消费端监听消息 → 删除缓存;
  • 核心优势 :利用MQ的持久化+重试机制,保证缓存最终被删除,且业务线程无需等待缓存操作,接口响应快;
  • 缺点:有代码侵入(需业务主动发消息),消息延迟可能导致短暂不一致。
2. Canal(阿里)异步同步方案
  • 原理:Canal伪装成MySQL从库,读取MySQL的Binlog(数据变更日志) → 解析Binlog → 异步更新/删除Redis缓存;
  • 优点:零业务代码侵入,Binlog天然有序,能保证更新顺序,一致性更高;
  • 缺点:部署维护成本高,Binlog解析有轻微延迟(毫秒级),不适合极致强一致场景。
四、补充方案
  1. 定时任务兜底:对核心数据,定时从DB全量同步到Redis,修正可能的不一致(适合数据变更频率低的场景);
  2. TTL兜底:给缓存设置合理过期时间,即使出现脏数据,也会自动过期,保证最终一致;
  3. 全量更新(不推荐):写操作直接更新DB+更新缓存,优点是简单,缺点是性能差(很多更新无意义)、并发下易出现数据覆盖。
五、总结
  • 性能优先、允许短暂不一致:选延迟双删(核心)+ MQ/Canal兜底;
  • 强一致性优先、读多写少:选Redisson读写锁;
  • 不想侵入业务代码、追求极致解耦:选Canal方案;
  • 中小规模业务:延迟双删即可满足需求,无需过度设计。

关键点回顾

  1. 延迟双删是基础方案,两次删除的核心是解决"并发读回填旧缓存"问题,延迟时长需覆盖读请求回写缓存的耗时;
  2. 互斥锁/读写锁保证强一致,但牺牲性能,适配库存、优惠券等核心场景;
  3. MQ/Canal是异步解耦方案,利用MQ可靠性 /Canal零侵入特性,保证最终一致性。
相关推荐
6+h2 小时前
【Java】JDK、JRE、JVM三者最通俗的讲解
java·jvm·python
tsyjjOvO2 小时前
代理模式详解:静态代理、JDK 动态代理、CGLIB 动态代理
java·开发语言·代理模式
小指纹2 小时前
2026牛客寒假算法基础集训营1
算法·macos·cocoa
西柚小萌新2 小时前
【数据库】--PostgreSQL 详细安装教程
数据库·postgresql
数据知道2 小时前
MongoDB 读写关注设置:一致性与性能的黄金平衡法则
数据库·mongodb
郭逍遥2 小时前
[Godot] 沃罗诺伊图生成算法
算法·c#·游戏引擎·godot
HwJack202 小时前
HarmonyOS ArkUI列表性能优化实战:懒加载与缓存的艺术
缓存·性能优化·harmonyos
IT枫斗者2 小时前
CentOS 7 一键部署 K8s 1.23 + Rancher 2.7 完整指南
java·linux·spring boot·后端·kubernetes·centos·rancher