缓存更新并发问题——高并发写入时如何保证数据一致性
问题的本质
缓存更新并发问题指的是在高并发场景下,多个线程/进程同时执行缓存和数据库的更新操作,导致数据不一致、脏数据、丢失更新等问题。
核心矛盾:缓存是高性能的临时存储,数据库是可靠持久化存储。两者之间的数据同步在并发条件下必然面临一致性挑战。
典型并发问题场景
场景一:先更新数据库,再删除缓存
时间 | 线程 A | 线程 B | 数据库 | 缓存
T1 | 写数据库 v1 → | | v1 | (旧)
T2 | | 读缓存(未命中) | v1 | (旧)
T3 | | 读数据库 v0(旧值) | **v1** | (旧)
T4 | 删除缓存 ← | | v1 | 无
T5 | | 写缓存 v0(旧值) | v1 | **v0 ← 脏数据**
结果:缓存保留了旧值 v0,数据库是新的 v1,不一致!
场景二:先删除缓存,再更新数据库
时间 | 线程 A | 线程 B | 数据库 | 缓存
T1 | 删除缓存 ← | | v0 | 无
T2 | | 读数据库 v0(未命中) | v0 | 无
T3 | | 写缓存 v0 | v0 | v0
T4 | 写数据库 v1 → | | **v1** | **v0 ← 脏数据**
结果:同样的脏数据问题。
经典解决方案
方案一:延迟双删(推荐)
先删缓存,再写数据库,再延迟删缓存:
public void updateUser(User user) {
redis.delete("user:" + user.getId()); // 第一次删除
userDao.update(user); // 更新数据库
// 延迟 500ms 再删一次
scheduledExecutor.schedule(() -> {
redis.delete("user:" + user.getId()); // 第二次删除
}, 500, TimeUnit.MILLISECONDS);
}
优点:实现简单,业界广泛使用
缺点:第二次删除是异步的,短暂不一致;延迟时间不好确定
方案二:应用层队列 + 同步写入
将对同一个 key 的写操作串行化:
public CompletableFuture<Void> updateUser(User user) {
CompletableFuture<Void> future = new CompletableFuture<>();
queue.enqueue("user:" + user.getId(), () -> {
try {
userDao.update(user);
redis.delete("user:" + user.getId());
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
return future;
}
优点:严格串行,不会出现并发问题
缺点:牺牲吞吐量,队列本身可能成为瓶颈
方案三:乐观锁(版本号)
每次写缓存时带上版本号,拒绝旧的版本:
// 数据模型带版本号
class User {
Long id;
String name;
Long version; // 每次更新递增
}
public void updateUser(User user) {
// 先读当前版本
Long currentVersion = getCurrentVersion(user.getId());
if (user.getVersion() < currentVersion) {
throw new StaleDataException("数据已过期,请刷新");
}
user.setVersion(currentVersion + 1);
userDao.update(user);
// 删除缓存,下次读取自动加载最新
redis.delete("user:" + user.getId());
}
优点:能检测到并发冲突
缺点:增加版本管理复杂度
方案四:Binlog 监听(最终一致性)
不通过业务代码更新缓存,而是通过监听数据库 binlog 来同步:
业务代码 → 更新数据库
Canal 监听 MySQL binlog → 推送到 MQ
消费者 → 根据 binlog 解析变更 → 更新/删除缓存
优点:业务代码无需关心缓存,逻辑解耦
缺点:引入 Canal、Kafka 等组件,架构复杂
业界常用组合
| 业务场景 | 推荐方案 |
|---|---|
| 中小项目、一致性要求不高 | 先删缓存 → 写 DB → 延迟双删 |
| 高并发、短时间不一致可接受 | 先更新 DB → 再删缓存 + 异步兜底 |
| 严格一致、允许牺牲性能 | 应用层队列串行化 |
| 大规模、数据流转复杂 | Canal binlog 监听 + MQ 异步同步 |
| 面试最佳答案 | “延迟双删 + 设置合理过期时间兜底” |
不能忽视的兜底策略
无论采用哪种方案,一个带过期时间的缓存是最后的安全网:
// 即使出现了脏数据,TTL 到期后也会自动消失
redis.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
面试要点
- 能清楚说出”先删缓存再写 DB”和”先写 DB 再删缓存”各有缺陷
- 延迟双删是最常见的实战方案
- 能分析出”缓存更新本质上做不到强一致性,只能追求最终一致性”
- 知道 binlog 监听方案适配了大厂场景
- 加分点:提及 “Cache-Aside + 延迟双删 + 兜底 TTL” 的组合策略
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END


暂无评论内容