在现代互联网应用中,Redis 缓存几乎是性能优化的标配。但在使用过程中,一个绕不过去的问题就是:
如何保证 Redis 缓存与数据库之间的数据一致性?
特别是在高并发场景下,读写操作错位可能导致缓存中出现脏数据,影响业务正确性。
问题背景
我们通常使用 Cache Aside 模式(旁路缓存):
读:先查缓存,没有再查数据库并写入缓存
写:更新数据库,再删除缓存
这个写入逻辑看似合理,但却存在很多坑。一不小心就会导致“缓存脏读”或“数据回滚”的问题。
方案一:先更新数据库,再删除缓存
这是很多开发者最初采用的方式。
🧠 原理:
updateDB(key, newValue)
deleteCache(key)
逻辑非常简单:先改数据库,再删缓存,期待下次读取会重新从数据库加载正确数据。
🖼️ 图解:
用户请求更新数据
|
[1] 更新数据库 (新数据写入)
|
[2] 删除 Redis 缓存
❌ 存在问题:
并发场景:
[1] updateDB(key, newValue) ✅ 更新成功
[2] deleteCache(key) ❌ 失败,缓存未清除
随后:
[3] 用户查询 getCache(key) → 命中旧缓存 ❌
结果是:
数据库中是新值
缓存中是旧值,长时间存在
系统返回的是错误的数据
方案二:先删除缓存,再更新数据库
🧠 原理:
deleteCache(key)
updateDB(key, newValue)
🖼️ 图解:
用户请求更新数据
|
[1] 删除 Redis 缓存
|
[2] 更新数据库
❌ 存在问题:
并发场景:
Time ---------->
A: deleteCache(new) -------->
B: getCache(old) ------------>
B: writeCache(old) ------------>
A: updateDb() ---------------->
线程 A 删除缓存, 并在之后更新数据库;
线程 B 在 A 删除缓存之前读取了旧缓存,随后把旧值写回缓存;
最终缓存中是 旧数据,数据库是新数据 → ❌ 数据不一致!
方案三:延迟双删(延迟兜底)
延迟双删(Delayed Double Delete)是对Cache Aside模式的增强,通过两次删除缓存操作来减少不一致时间窗口。
实现步骤
1. 第一次删除:在更新数据库前,先删除缓存
2. 更新数据库:执行实际的数据库更新操作
3. 延迟第二次删除:在数据库更新完成后,延迟一段时间再次删除缓存
public void updateData(Data newData) {
// 第一次删除缓存
cache.delete(newData.getId());
// 更新数据库
database.update(newData);
// 延迟第二次删除
executor.schedule(() -> {
cache.delete(newData.getId());
}, 500, TimeUnit.MILLISECONDS); // 延迟500ms
}
为什么需要延迟
延迟的目的是为了处理以下场景:
1. 在第一次删除后、数据库更新完成前,可能有请求读取了旧数据并重新填充缓存
2. 数据库主从复制延迟可能导致从库读取到旧数据
通过延迟第二次删除,可以清除这些潜在的不一致情况。
延迟时间如何确定
延迟时间应考虑:
- 数据库主从复制延迟时间(通常100-500ms)
- 业务对一致性的要求程度
- 系统负载情况
延迟双删的优化与变种
异步重试机制
当第二次删除失败时,可以采用异步重试机制确保最终一致性:
public void deleteWithRetry(String key, int maxRetries) {
int retries = 0;
while (retries < maxRetries) {
try {
cache.delete(key);
break;
} catch (Exception e) {
retries++;
if (retries >= maxRetries) {
// 记录失败日志或放入死信队列
log.error("Failed to delete cache after {} retries", maxRetries);
break;
}
Thread.sleep(100 * retries); // 指数退避
}
}
}
方案四:分布式锁(强一致)
🧠 原理:
lock(key)
deleteCache(key)
updateDB(key)
unlock(key)
🖼️ 图解:
用户请求更新数据
|
[1] 获取分布式锁
|
[2] 删除缓存
|
[3] 更新数据库
|
[4] 释放锁
✅ 优点:
写操作串行化,强一致;
不会发生并发导致的数据回滚。
❌ 缺点:
实现复杂;
性能开销大,需保证锁系统高可用。
方案五:监听 Binlog 回刷缓存(如使用 Canal)
🧠 原理:
MySQL 更新数据
↓
产生 Binlog
↓
Canal 监听变更事件
↓
主动删除或刷新缓存
🖼️ 图解:
数据库更新
|
[1] 生成 Binlog
|
[2] Canal 监听 Binlog
|
[3] 删除/刷新 Redis 缓存
✅ 优点:
非侵入式,一致性强;
精准捕获变化,自动驱动缓存刷新。
❌ 缺点:
运维成本高;
延迟取决于 Canal 拉取速度。
各方案对比总结
方案 | 是否一致 | 并发安全 | 实现复杂度 | 备注 |
---|---|---|---|---|
先更新 DB 再删缓存 | ❌ 否 | 否 | 简单 | 常见误区,不能用 |
先删缓存再更新 DB | ⚠️ 部分 | 一定程度 | 简单 | 可配合延迟双删使用 |
延迟双删 | ✅ 最终一致 | 较高 | 简单 | 推荐大部分场景 |
分布式锁 | ✅ 强一致 | 是 | 中等 | 对性能影响较大 |
Binlog + Canal 回刷缓存 | ✅ 强一致 | 是 | 高 | 推荐核心数据系统使用 |
总结
Redis 缓存作为提升系统性能的利器,也带来了“一致性”的挑战。掌握各种一致性方案,能让你在面对不同业务需求时游刃有余。
🔑 核心思想是:避免缓存与数据库数据错位时被读取或误写回缓存。