Redis双写一致性

问题:先更新库还是先删缓存?

1、双写一致性,是 Redis 作为缓存时最核心的面试考点之一,本质是解决「MySQL 数据库」和「Redis 缓存」之间的数据同步问题,确保两者的数据始终匹配。

1)读操作:读操作时因为对redis和mysql都是没有影响的,所以这个动作本身不会导致出错; 数据不一致的根源一定是「写操作」的并发处理不当。

2)写操作:而因为写操作究其目的我们需要写缓存和数据库;而又不能直接更新于缓存上;而是删除缓存,然后其他线程发现缓存中没有数据的时候,在读数据库的时候同步更新给缓存。

2、这就导致了一个问题:

假设我们要把数据库和缓存中的数据10更新为20

1)先更数据库:如果线程1先更数据库,缓存还没被删除的时候,线程2就会读到缓存中的脏数据10;这就错误了! (而后线程1才重建缓存为20。)

复制代码
初始状态:MySQL=10,Redis=10(要更新为20)
1. 线程1:更新MySQL为20(此时Redis还是10)
2. 线程2:读取Redis,直接拿到旧值10(脏数据)
3. 线程1:删除Redis缓存(但线程2已经拿到脏数据了)
4. 后续线程:读Redis空 → 读MySQL=20 → 写回Redis=20(但线程2已经出错)

2)先删缓存:如果线程1先删除缓存,线程2发现缓存中没数据,就会去数据库中读到旧数据(10)并重建给缓存;这就错误了!(而后线程1才去将更新数据库为20并重建缓存)

复制代码
初始状态:MySQL=10,Redis=10(要更新为20)
1. 线程1:删除Redis缓存(此时Redis空,MySQL还是10)
2. 线程2:读Redis空 → 读MySQL=10 → 写回Redis=10(脏数据)
3. 线程1:更新MySQL为20(此时Redis=10,MySQL=20,数据不一致)
4. 后续线程:读Redis=10(脏数据),直到缓存过期/被再次删除
讨论:

1、写操作不能直接更新缓存(比如直接set key 20),原因是什么?

==》如果高并发下多个线程同时更新缓存,会出现 "覆盖问题"(比如线程 A 和 B 都要更 20,但 A 先更、B 后更,看似没问题;但如果是 "库存扣减" 这类累加操作,直接更缓存会导致库存计算错误)。

所以行业最佳实践是:写操作只更新数据库,然后删除缓存;后续读操作发现缓存空了,会自动从数据库读新值并写回缓存(这个过程叫「缓存重建」)。

2、总结:

无论是先删缓存还是先更新数据库,都会导致某个线程读到脏数据;本质是「读写操作的并发时序问题」。即 只要读和写是两个独立的操作,且没有加锁 / 限流等控制,就可能出现时序错乱。 这也是为什么我们需要「延迟双删、读写锁、MQ/Canal 异步」这些解决方案; 而接下来我就会为大家介绍这些个方案; 没有银弹,只看业务场景取舍(强一致用锁,最终一致用异步)。

解决方案

方案 1:延迟双删(解决基础并发问题)

这是最基础的优化方案,针对 "先删缓存→更数据库" 的缺陷做补充:

1、执行流程:

先删除 Redis 中对应的缓存 Key;

更新 MySQL 数据库的数据;

延迟一小段时间(比如 500ms),再次删除该缓存 Key;

2、核心原理:

延迟删除的目的是 "覆盖" 并发场景下,其他线程在步骤 1-2 之间写入的脏数据;

延迟时间要大于 "读请求从查库到写缓存" 的耗时(一般 500ms~1s 足够);

3、举例:

Redis = 10 MySQL = 10 目标:改成 20 执行

时序:

线程 1:删除 Redis 缓存 → Redis 空了

线程 2:查询缓存,没命中 → 去 MySQL 查旧值 10,并重建redis为10

线程 1:更新 MySQL 为 20

关键来了:线程 1:等待 500ms(保证线程 2 的逻辑已经全部执行完)

线程 1:再次 del Redis(key),

延迟是为了 "等脏缓存写完,我再一锅端删掉"。

优缺点:

✅ 实现简单,无额外组件依赖;

❌ 只能降低脏数据概率,无法 100% 保证强一致;适合「并发量不高、能接受短暂不一致」的场景。

方案 2:Redisson 读写锁(强一致性场景)

1、原理:Redisson 读写锁就是通过严格的锁机制,让读写操作互斥,从而从根源上保证缓存和数据库的强一致性。

2、锁的介绍

利用 Redisson 的RReadWriteLock(读写锁),区分 "读操作" 和 "写操作",保证「读写互斥、写写互斥、读读共享」:

读锁(ReadLock):共享锁,多个线程可以同时加读锁查数据,不阻塞;

写锁(WriteLock):排他锁,加写锁后,阻塞所有读 / 写操作,直到写锁释放;

3、 读操作和写操作的过程

读操作:线程1要读数据的时候,需要获取读锁,从而完成缓存有,则读;缓存没有,则"读数据库并重建缓存"的整个操作;(读的时候不能写)

写操作:线程2要写数据的时候,需要获取写锁,从而完成"更新数据库,删除缓存"整个操作;

从而当我们要写数据前和写数据后,缓存和数据库永远都是一致的;

优点**:保证了数据的**强一致性,适合库存、交易金额等对数据准确性要求极高的场景。

缺点是:性能低(写锁会阻塞所有操作);有点为了强一致性而牺牲高并发的意味了 。适合「写少读多、一致性要求极高」的场景(比如库存、订单金额)。

java 复制代码
// 读操作(查数据):加读锁,保证读的时候不会有写操作修改数据
public Item getItemById(Integer id) {
    RReadWriteLock rwLock = redissonClient.getReadWriteLock("ITEM_LOCK_" + id);
    RLock readLock = rwLock.readLock();
    try {
        readLock.lock(); // 加读锁
        // 1. 先查缓存
        Item item = redisTemplate.opsForValue().get("item:" + id);
        if (item != null) return item;
        // 2. 缓存空则查库,写回缓存
        item = itemMapper.selectById(id);
        redisTemplate.opsForValue().set("item:" + id, item, 30, TimeUnit.MINUTES);
        return item;
    } finally {
        readLock.unlock(); // 释放读锁
    }
}

// 写操作(改数据):加写锁,保证写的时候没有读操作查旧数据
public void updateItem(Item item) {
    RReadWriteLock rwLock = redissonClient.getReadWriteLock("ITEM_LOCK_" + id);
    RLock writeLock = rwLock.writeLock();
    try {
        writeLock.lock(); // 加写锁(阻塞所有读/写)
        // 1. 更新数据库
        itemMapper.updateById(item);
        // 2. 删除缓存(避免脏数据)
        redisTemplate.delete("item:" + item.getId());
    } finally {
        writeLock.unlock(); // 释放写锁
    }
}

方案 3:异步通知(最终一致性场景)

如果业务能接受「短暂的数据不一致」(比如商品详情、文章内容),优先用这种高性能方案,核心是 "异步更新缓存":

子方案 3.1:基于 MQ 的异步通知

流程:更新数据库 → 发送 "缓存删除" 消息到 MQ → 消费 MQ 消息 → 删除 / 更新缓存;

核心:利用 MQ 的可靠性,保证缓存最终会被更新,业务代码无阻塞;

它的运行流程可以分为两大步骤:

1、写数据时:业务服务(item-service)负责写库并发送消息

java 复制代码
用户发起修改请求 → item-service 接收到请求
  → 1.1 先将数据写入 MySQL 数据库(确保数据持久化成功)
  → 1.2 向 MQ 发送一条「缓存更新/删除」的消息
  → 立即返回给用户"操作成功"

关键特点:业务服务不直接操作缓存,发送消息后就立即返回,性能非常高,用户无感知延迟

2、异步更新缓存:缓存服务(cache-service)负责消费消息并更新缓存

bash 复制代码
cache-service 持续监听 MQ
  → 2.1 一旦收到「缓存更新/删除」的消息
  → 2.2 执行缓存更新/删除操作(通常是删除缓存,让后续读请求自动重建)
  • 关键特点:缓存更新是异步、后台执行的,不影响主业务流程。

基于 MQ 的异步通知,就是用 "先保证数据库正确,再用 MQ 异步修正缓存" 的方式,换取了极致的性能和系统解耦,同时接受数据在短时间内的不一致,最终达到一致状态。

子方案 3.2:基于 Canal 的异步同步

一、原理:Canal 是一个基于 MySQL 主从同步机制开发的中间件,它的核心是伪装成 MySQL 的一个从节点,实时监听并解析 MySQL 的二进制日志(binlog),然后将数据变更事件发送给下游的缓存服务(如 Redis),从而实现数据库和缓存的异步同步,保证最终一致性。

二、它和 MQ 异步通知的核心区别是:

  • MQ 方案需要业务代码主动发送消息。

  • Canal 方案对业务代码完全无侵入,无需任何修改,通过监听数据库日志自动完成。

三、工作过程

  1. 业务系统正常写库

    用户发起修改请求 → item-service 接收到请求

    → 1. 将数据写入 MySQL 数据库

    MySQL 会把这次修改操作记录到自己的二进制日志(binlog)里。

  2. Canal 监听并解析 binlog Canal

    伪装成 MySQL 的从节点,持续向主节点发送 dump 请求

    → 实时监听并拉取 MySQL 的 binlog

    → 解析 binlog,提取出数据变更的具体内容(比如哪张表、哪个 ID、更新了什么值)

  3. Canal 通知缓存服务更新

    Canal 解析到数据变更后

    →将变更事件发送给缓存服务(cache-service)
    这里的 "发送" 可以是直接调用接口,也可以是通过 MQ 来解耦。 缓存服务收到通知后,就知道哪些数据需要更新了。

  4. 缓存服务更新

    Redis cache-service 收到变更通知

    → 2.3 执行缓存更新/删除操作(通常是删除缓存,让后续读请求自动重建)

四、优缺点

优点: 对业务完全无侵入 可靠性极高:基于 MySQL 主从同步的协议,只要数据库的 binlog 不丢,Canal 就能保证数据不丢。

缺点: 部署复杂度高:需要额外部署和维护 Canal 服务,对运维能力有一定要求。 有一定延迟:从数据库变更到缓存更新,中间有解析和传输的时间,通常在毫秒级别。

Canal 非常适合大型、复杂、对业务代码无侵入性要求高的系统

讨论:

1、用户发起写请求,会先更新数据库,然后发送 MQ 消息 → 立即返回用户。缓存更新是在另一个线程 / 服务里异步完成的,主业务线程完全不阻塞。 这就区别于传统的更新数据库然后马上重建缓存; 用户发起读请求,先去查找缓存;使用mq不可避免的,可能缓存中查找到的是旧值(脏数据) 这正是 "最终一致性" 的含义:短暂不一致,但最终会一致。

2、mq异步通知解决方案和延迟双删相比有什么优势,延迟双删的缺点也是得让系统能接受短暂不一致才行,这个基于mq的异步通知也是啊,难道是并发性有区别?

===》

1、在高并发写场景下,MQ 方案的性能优势是碾压性的。

1)延迟双删:写操作的主业务线程需要等待两次删除缓存的操作(尤其是第二次延迟删除),这会增加写操作的响应时间,对高并发写场景不够友好。

2)MQ 异步通知:写操作的主业务线程只需要完成 "写库 + 发消息",这两步都是非常快的 IO 操作,几乎不增加延迟。缓存更新的开销完全转嫁到了异步线程,主业务的并发能力得到了极大提升。
2、系统解耦性

1)延迟双删:业务代码和缓存操作紧耦合,所有逻辑都在一个服务里完成。

2)MQ 异步通知:业务服务(item-service)和缓存服务(cache-service)通过 MQ 解耦,各自独立演化。比如,你可以单独扩容缓存服务,或者更换缓存技术,都不会影响业务服务。

拓展

最终一致性

最终一致性是分布式系统中的一个核心概念,它指的是: 不要求数据在任何时刻都完全一致,但会保证在经过一段短暂的时间后,所有副本的数据都会达到一致的状态。 简单来说,就是允许数据在短时间内存在不一致,但最终会被修正。 典型场景:商品详情页、文章内容、用户资料等。这些场景对数据实时性要求不高,用户能接受 "刷新一下就看到最新内容" 的体验。

总结:

1、双写一致性的核心是解决「Redis 缓存」和「MySQL 数据库」的数据同步问题,避免脏数据;

强一致性场景(库存 / 交易)用「Redisson 读写锁」,最终一致性场景(资讯 / 商品)用「MQ/Canal 异步通知」;

延迟双删是基础优化,能降低脏数据概率,但无法替代上述核心方案。

2、本文介绍了 Redis 作为缓存时双写一致性问题;讨论了写操作时是先更新库还是先删缓存的问题?(交代了行业最佳实践是:写操作只更新数据库,然后删除缓存而不直接于缓存上更新的原因)。并揭示了无论哪个原因都会让某个线程读到脏数据。从而提出了「延迟双删、读写锁、MQ/Canal 异步」这些解决方案;并交代了强一致性场景(库存 / 交易)用「Redisson 读写锁」,最终一致性场景(资讯 / 商品)用「MQ/Canal 异步通知」;

相关推荐
怣502 小时前
[特殊字符] MySQL数据表操作完全指南:增删改查的艺术
数据库·mysql·adb
安然无虞3 小时前
「MongoDB数据库」初见
数据库·mysql·mongodb
一起养小猫3 小时前
Flutter for OpenHarmony 实战:番茄钟应用完整开发指南
开发语言·jvm·数据库·flutter·信息可视化·harmonyos
Mr_Xuhhh3 小时前
MySQL视图详解:虚拟表的创建、使用与实战
数据库·mysql
AI_56783 小时前
MySQL索引优化全景指南:从慢查询诊断到智能调优
数据库·mysql
老虎06273 小时前
Redis入门,配置,常见面试题总结
数据库·redis·缓存
一起养小猫3 小时前
Flutter for OpenHarmony 实战:数据持久化方案深度解析
网络·jvm·数据库·flutter·游戏·harmonyos
codeRichLife3 小时前
TimescaleDB保存100万条设备采集数据的两种存储方案对比分析
数据库
J&Lu3 小时前
[DDD大营销-Redis]
数据库·redis·缓存