Redis 缓存与数据库一致性
通俗讲解:缓存和数据库,如何保持一致?
问题引入:为什么会出现不一致?
一、缓存读写模式
1. 先写缓存,还是先写数据库?
问题:更新数据时,先更新缓存还是先更新数据库?
2. 旁路缓存模式(Cache-Aside)
二、保证一致性的方案
方案一:延迟双删
解决方案:延迟双删
代码实现:
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();
}
});
}方案二:分布式锁
代码实现:
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();
}
}方案三:订阅 Binlog(Canal)
代码实现:
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 订阅 | 最终一致 | 高 | 高 | 大型分布式 |
| 直接更新缓存 | 可能不一致 | 高 | 低 | 不推荐 |
四、实际项目推荐方案
五、一句话总结
缓存一致性核心原则:
- 写操作:先写数据库,再删缓存
- 删除缓存失败:使用延迟双删兜底
- 并发场景:使用分布式锁保证原子性
- 大型系统:使用 Canal 订阅 Binlog 异步同步
记住:缓存是用来"加速"的,不是用来"持久化"的!
六、常见面试题
Q1:为什么是删除缓存,而不是更新缓存?
Q2:延迟双删的延迟时间怎么设置?
经验值:500ms - 1s
考虑因素:
- 线程B 读取数据库的时间(通常几百毫秒)
- 系统负载
- 业务对一致性的要求
简单理解:等待线程B把脏数据写入缓存后,再删除
Q3:可以只用缓存,不走数据库吗?
不能!缓存的特点:
- 内存存储,可能丢失
- 数据是 DB 的副本,不是源数据
- 必须以数据库为准