Redis 是如何实现与数据库的一致性呢?

通俗讲解:缓存和数据库,如何保持一致?

问题引入:为什么会出现不一致?

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    缓存和数据库                              │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   正常情况:                                                 │
│   ┌─────────────┐         ┌─────────────┐                   │
│   │    Redis    │  ←──→  │   MySQL     │                   │
│   │   (缓存)    │  数据一致  │   (数据库)  │                   │
│   └─────────────┘         └─────────────┘                   │
│                                                              │
│   问题情况:                                                 │
│   ┌─────────────┐         ┌─────────────┐                   │
│   │    Redis    │         │   MySQL     │                   │
│   │   缓存:    │   ←?→  │   数据库:   │                   │
│   │   小明      │         │   小红      │                   │
│   │   年龄:18  │         │   年龄:20  │                   │
│   └─────────────┘         └─────────────┘                   │
│           ↓                                                    │
│     数据不一致!                                              │
│                                                              │
└─────────────────────────────────────────────────────────────┘

一、缓存读写模式

1. 先写缓存,还是先写数据库?

复制代码
问题:更新数据时,先更新缓存还是先更新数据库?

┌─────────────────────────────────────────────────────────────┐
│                   方案一:先写缓存                           │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   步骤:                                                     │
│   1. 更新 Redis                                              │
│   2. 更新 MySQL                                             │
│                                                              │
│   问题场景:                                                 │
│   T1: 客户端A 更新缓存(name=A)                            │
│   T2: 客户端B 更新缓存(name=B)                            │
│   T3: 客户端B 更新数据库(name=B)                         │
│   T4: 客户端A 更新数据库(name=A)                         │
│                                                              │
│   结果:                                                     │
│   Redis: A    MySQL: B   → 不一致!                         │
│                                                              │
│   原因:并发时执行顺序不确定                                  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                   方案二:先写数据库                           │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   步骤:                                                     │
│   1. 更新 MySQL                                             │
│   2. 更新 Redis                                             │
│                                                              │
│   问题场景:                                                 │
│   T1: 客户端A 更新数据库(age=20)                          │
│   T2: 客户端A 删除缓存                                       │
│   T3: 客户端B 读取缓存(命中旧数据 age=18)                 │
│   T4: 客户端B 写入缓存(age=18)                            │
│   T5: 客户端A 写入新缓存(age=20)                         │
│                                                              │
│   结果:                                                     │
│   短暂不一致,但最终会一致                                    │
│                                                              │
│   推荐:先写数据库,再删缓存                                  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

2. 旁路缓存模式(Cache-Aside)

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   旁路缓存模式                               │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   读操作:                                                   │
│   ┌──────────┐    ┌──────────┐    ┌──────────┐           │
│   │  请求    │───→│ 查缓存   │───→│ 查数据库 │           │
│   └──────────┘    └──────────┘    └──────────┘           │
│                         │                  │                │
│                    命中 ↓             未命中 ↓                │
│                    ┌────┐              ┌─────┐              │
│                    │返回│              │写入 │              │
│                    │数据│              │缓存 │              │
│                    └────┘              └─────┘              │
│                                                              │
│   写操作:                                                   │
│   ┌──────────┐    ┌──────────┐    ┌──────────┐           │
│   │  请求    │───→│ 更新数据库│───→│ 删除缓存 │           │
│   └──────────┘    └──────────┘    └──────────┘           │
│                         │                  │                │
│                    ┌────┴────┐          ┌───┴───┐         │
│                    │ 更新    │          │ 异步  │         │
│                    │ 成功   │          │ 删除  │         │
│                    └────────┘          └───────┘         │
│                                                              │
└─────────────────────────────────────────────────────────────┘

二、保证一致性的方案

方案一:延迟双删

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   延迟双删                                  │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   问题:先删缓存,再更新数据库,仍可能短暂不一致               │
│                                                              │
│   ┌─────────────────────────────────────────────────────┐  │
│   │  原因分析:                                          │  │
│   │                                                      │  │
│   │  T1: 线程A 删除缓存                                  │  │
│   │  T2: 线程B 读取缓存(未命中)                        │  │
│   │  T3: 线程B 读取数据库(旧数据 age=18)              │  │
│   │  T4: 线程A 更新数据库(age=20)                     │  │
│   │  T5: 线程B 写入缓存(age=18)← 脏数据!            │  │
│   │                                                      │  │
│   │  结果:缓存是 18,数据库是 20                      │  │
│   └─────────────────────────────────────────────────────┘  │
│                                                              │
│   解决方案:延迟双删                                         │
│                                                              │
│   ┌─────────────────────────────────────────────────────┐  │
│   │                                                      │  │
│   │  1. 删除缓存                                        │  │
│   │  2. 更新数据库                                      │  │
│   │  3. 延迟一段时间(如 500ms)                        │  │
│   │  4. 再次删除缓存                                    │  │
│   │                                                      │  │
│   └─────────────────────────────────────────────────────┘  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

代码实现:

java 复制代码
public void updateUser(User user) {
    // 1. 删除缓存
    redis.del("user:" + user.getId());
    
    // 2. 更新数据库
    userMapper.update(user);
    
    // 3. 延迟双删(等待线程B把脏数据写入缓存后再删除)
    threadPool.execute(() -> {
        try {
            Thread.sleep(500);
            redis.del("user:" + user.getId());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

流程图:

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   延迟双删流程                               │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   客户端                                                     │
│     │                                                       │
│     ├── 1. 删除 Redis 缓存                                   │
│     │                                                       │
│     ├── 2. 更新 MySQL 数据库                                 │
│     │                                                       │
│     ├── 3. 等待 500ms                                       │
│     │          ↑                                            │
│     │          │                                            │
│     │    线程B 写入缓存(会被删除)                          │
│     │                                                       │
│     └── 4. 再次删除 Redis 缓存                               │
│                    │                                         │
│                    ↓                                         │
│              数据一致                                        │
│                                                              │
└─────────────────────────────────────────────────────────────┘

方案二:分布式锁

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   分布式锁方案                               │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   思路:同一时间只允许一个线程更新数据                         │
│                                                              │
│   ┌─────────────────────────────────────────────────────┐  │
│   │                                                      │  │
│   │  获取锁                                              │  │
│   │    │                                                │  │
│   │    ├── 成功 → 更新数据库 → 删除缓存 → 释放锁        │  │
│   │    │                                                │  │
│   │    └── 失败 → 等待 → 重试                          │  │
│   │                                                      │  │
│   └─────────────────────────────────────────────────────┘  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

代码实现:

java 复制代码
public void updateUserWithLock(User user) {
    String lockKey = "lock:user:" + user.getId();
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 获取锁(等待10秒,持有30秒)
        if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
            try {
                // 1. 更新数据库
                userMapper.update(user);
                
                // 2. 删除缓存
                redis.del("user:" + user.getId());
            } finally {
                lock.unlock();
            }
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

流程图:

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   分布式锁流程                               │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   线程A                        线程B                         │
│     │                            │                           │
│     ├── 获取锁(成功) ────────→ │                           │
│     │                            │ 等待锁...                │
│     ├── 更新数据库                │                          │
│     ├── 删除缓存                  │                          │
│     ├── 释放锁 ─────────────────→│ 获取锁(成功)            │
│     │                            ├── 更新数据库              │
│     │                            ├── 删除缓存               │
│     │                            ├── 释放锁                 │
│     │                            │                          │
│     └──────── 数据一致 ──────────┘                          │
│                                                              │
└─────────────────────────────────────────────────────────────┘

方案三:订阅 Binlog(Canal)

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   Binlog 订阅方案                           │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   原理:                                                     │
│   MySQL 写入数据 → 记录 Binlog → Canal 读取 → 更新缓存      │
│                                                              │
│   ┌──────────┐    ┌──────────┐    ┌──────────┐           │
│   │  MySQL   │───→│  Canal   │───→│  Redis   │           │
│   │          │ Binlog│        │    │          │           │
│   └──────────┘    └──────────┘    └──────────┘           │
│                                                              │
│   优点:                                                     │
│   ├── 应用层无需关心缓存更新                                  │
│   ├── 异步处理,不影响主流程                                 │
│   └── 最终一致性                                            │
│                                                              │
└─────────────────────────────────────────────────────────────┘

流程图:

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   Binlog 同步流程                           │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   应用服务                      Canal                       │
│     │                            │                          │
│     ├── 更新 MySQL ──────────────→                          │
│     │                            │ 监听 Binlog              │
│     │                            │                          │
│     │                            ├── 解析变更                │
│     │                            │                          │
│     │                            ├── 删除/更新 Redis ──────→│
│     │                            │                          │
│     └──────── 数据一致 ◀──────────┘                          │
│                                                              │
└─────────────────────────────────────────────────────────────┘

代码实现:

java 复制代码
@CanalMessageListener(topic = "mysql.product")
public void handleProductChange(CanalEntry.Entry entry) {
    CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
    
    for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
        if (rowChange.getEventType() == CanalEntry.EventType.UPDATE) {
            // 获取更新后的 id
            Long id = null;
            for (Column column : rowData.getAfterColumnsList()) {
                if ("id".equals(column.getName())) {
                    id = Long.parseLong(column.getValue());
                }
            }
            if (id != null) {
                // 删除缓存,触发重新加载
                redis.del("product:" + id);
            }
        }
    }
}

三、一致性强度对比

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   一致性方案对比                             │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   方案          │  一致性   │  性能  │  复杂度 │  适用场景   │
│   ──────────────┼───────────┼────────┼─────────┼───────────  │
│   延迟双删       │  最终一致 │  高   │   低   │ 大多数场景  │
│   分布式锁       │  强一致   │  低   │   中   │ 并发较高    │
│   Binlog 订阅    │  最终一致 │  高   │   高   │ 大型分布式  │
│   直接更新缓存    │  可能不一致│  高   │   低   │ 不推荐      │
│                                                              │
└─────────────────────────────────────────────────────────────┘

四、实际项目推荐方案

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   推荐方案                                   │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   普通项目:                                                 │
│   ┌─────────────────────────────────────────────────────┐  │
│   │                                                      │  │
│   │  先写数据库 → 删除缓存 → 延迟 500ms 再删除缓存       │  │
│   │                                                      │  │
│   │  优点:简单、性能好、大多数场景足够                    │  │
│   │                                                      │  │
│   └─────────────────────────────────────────────────────┘  │
│                                                              │
│   高并发项目:                                               │
│   ┌─────────────────────────────────────────────────────┐  │
│   │                                                      │  │
│   │  分布式锁:获取锁 → 写数据库 → 删除缓存 → 释放锁     │  │
│   │                                                      │  │
│   │  优点:保证强一致性                                  │  │
│   │                                                      │  │
│   └─────────────────────────────────────────────────────┘  │
│                                                              │
│   大型分布式项目:                                           │
│   ┌─────────────────────────────────────────────────────┐  │
│   │                                                      │  │
│   │  Canal 订阅 Binlog → 异步更新缓存                    │  │
│   │                                                      │  │
│   │  优点:解耦、扩展性好                                │  │
│   │                                                      │  │
│   └─────────────────────────────────────────────────────┘  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

五、一句话总结

复制代码
缓存一致性核心原则:

1. 写操作:先写数据库,再删缓存
2. 删除缓存失败:使用延迟双删兜底
3. 并发场景:使用分布式锁保证原子性
4. 大型系统:使用 Canal 订阅 Binlog 异步同步

记住:缓存是用来"加速"的,不是用来"持久化"的!

Q1:为什么是删除缓存,而不是更新缓存?

复制代码
更新缓存的问题:
┌─────────────────────────────────────────────────────────────┐
│                                                              │
│   T1: 线程A 更新缓存(name=A)                              │
│   T2: 线程B 更新缓存(name=B)                              │
│   T3: 线程B 更新数据库(name=B)                           │
│   T4: 线程A 更新数据库(name=A)                           │
│                                                              │
│   结果:缓存=A,数据库=B → 不一致!                         │
│                                                              │
│   删除缓存的问题:                                           │
│   T1: 线程A 删除缓存                                        │
│   T2: 线程B 读取(未命中)                                   │
│   T3: 线程B 读取数据库                                       │
│   T4: 线程B 写入缓存                                        │
│   T5: 线程A 更新数据库                                      │
│                                                              │
│   结果:缓存是旧数据,但最终会被正确数据覆盖                  │
│                                                              │
│   结论:删除缓存比重更容易保证最终一致性                      │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Q2:延迟双删的延迟时间怎么设置?

复制代码
经验值:500ms - 1s

考虑因素:
├── 线程B 读取数据库的时间(通常几百毫秒)
├── 系统负载
└── 业务对一致性的要求

简单理解:等待线程B把脏数据写入缓存后,再删除

Q3:可以只用缓存,不走数据库吗?

复制代码
不能!缓存的特点:
├── 内存存储,可能丢失
├── 数据是 DB 的副本,不是源数据
└── 必须以数据库为准
相关推荐
清水白石0081 小时前
缓存的艺术:Python 高性能编程中的策略选择与全景实战
开发语言·数据库·python
AI Echoes1 小时前
对接自定义向量数据库的配置与使用
数据库·人工智能·python·langchain·prompt·agent
专注VB编程开发20年1 小时前
多线程,CS多台电脑redis扣款不出错方案
数据库·redis·缓存
l1t1 小时前
DeepSeek总结的postgres_dba诊断报告使用
数据库·dba
一个响当当的名号2 小时前
project3
数据库
嵌入式×边缘AI:打怪升级日志11 小时前
编写 Bootloader 实现烧录功能
数据库
砚边数影12 小时前
模型持久化(二):从 KingbaseES 加载模型,实现离线预测
数据库·机器学习·kingbase·模型推理·数据库平替用金仓·金仓数据库
Ama_tor13 小时前
Navicat学习01|初步应用实践
数据库·navicat
山岚的运维笔记13 小时前
SQL Server笔记 -- 第65章:迁移 第66章:表值参数
数据库·笔记·sql·microsoft·sqlserver