通俗讲解:缓存和数据库,如何保持一致?
问题引入:为什么会出现不一致?
┌─────────────────────────────────────────────────────────────┐
│ 缓存和数据库 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 正常情况: │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 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 的副本,不是源数据
└── 必须以数据库为准