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 的副本,不是源数据
└── 必须以数据库为准
相关推荐
Javatutouhouduan15 分钟前
SpringBoot整合reids:JSON序列化文件夹操作实录
java·数据库·redis·html·springboot·java编程·java程序员
QWQ___qwq1 小时前
Spring Security + MyBatis-Plus 实现自定义数据库用户认证
数据库·spring·mybatis
java1234_小锋1 小时前
Java高频面试题:Redis里什么是缓存击穿、缓存穿透、缓存雪崩?
java·redis·缓存
Filotimo_1 小时前
Java后端开发标准流程:从数据库到接口的完整实现
数据库·oracle
泯仲1 小时前
从零起步学习MySQL 第一章:初识MySQL及深入理解内部数据类型
数据库·mysql
y = xⁿ2 小时前
【从零开始学习Redis|第四篇】从底层理解缓存问题:雪崩、击穿、穿透与一致性设计
java·redis·学习·缓存
有想法的py工程师2 小时前
PostgreSQL 触发器性能评估实战(pg_stat_user_functions)
数据库·postgresql
御坂10101号2 小时前
「2>&1」是什么意思?半个世纪的 Unix 谜题
java·数据库·bash·unix
韩立学长2 小时前
基于Springboot校园志愿者服务平台77pz7812(程序、源码、数据库、调试部署方案及开发环境)系统界面展示及获取方式置于文档末尾,可供参考。
数据库·spring boot·后端
代码雕刻家2 小时前
MySQL和SQL Server注意事项
数据库·mysql