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 的副本,不是源数据
└── 必须以数据库为准
相关推荐
Java面试题总结6 小时前
MongoDB(70)如何使用副本集进行备份?
数据库·mongodb
荒川之神6 小时前
Oracle LEVEL 函数练习(HR 模式 employees 表)
数据库·oracle
TDengine (老段)7 小时前
TDengine IDMP 工业数据建模 —— 元素与数据查询
大数据·数据库·人工智能·物联网·时序数据库·tdengine·涛思数据
蜡台7 小时前
Mysql 安装与配置
数据库·mysql
zs宝来了7 小时前
Redis 网络模型:IO 多路复用与 ae 事件循环
redis·epoll·事件循环·io多路复用·网络模型
lajidecrd7 小时前
Ubuntu24安装PostgreSQL和PgVector
数据库·postgresql
羊小猪~~7 小时前
Redis学习笔记(数据类型、持久化、事件、管道、发布订阅等)
开发语言·数据库·c++·redis·后端·学习·缓存
福娃筱欢7 小时前
Oracle迁移KES提示ERROR: type “geometry“ does not exist
数据库·oracle
mldlds7 小时前
使用 Qt 插件和 SQLCipher 实现 SQLite 数据库加密与解密
数据库·qt·sqlite
大空大地20267 小时前
Entity Framework
数据库