逻辑分析:数据库读写分离+缓存一致性
在 数据库读写分离 的架构下,读请求从 从库 查询,写请求更新 主库,然后再通过 主从同步 将数据同步到 从库。但同步有延迟,可能导致查询到旧数据,并错误地写入缓存,造成 缓存脏数据。
问题复现
-
请求A(写操作)
- 删除缓存
- 更新数据库(主库)
- 主从同步有延迟
-
请求B(读操作)
- 查询缓存,发现数据不存在
- 从从库读取数据(由于主从同步延迟,数据是旧的)
- 将旧数据写回缓存
-
主从同步完成,但缓存仍然是旧数据,导致后续请求仍然读取错误数据。
解决方案:延时双删策略
核心思路:
- 第一次删除缓存(避免查询到旧值)
- 写入数据库(主库)
- 等待一段时间(大于主从同步的延迟)
- 第二次删除缓存(确保从库同步完成后缓存不存旧数据)
Demo
1. 读操作(查询)
public Object read(String key) {
// 1. 先查询缓存
Object data = redisUtils.get(key);
if (data != null) {
return data; // 缓存命中,直接返回
}
// 2. 缓存未命中,从数据库(从库)查询
data = db.readFromSlave(key);
// 3. 由于主从同步延迟,我们需要对数据进行额外判断
if (data != null) {
// 4. 异步任务或延迟写入缓存,避免脏数据
final Object finalData = data;
new Thread(() -> {
try {
Thread.sleep(200); // 等待主从同步完成(时间根据业务调整)
redisUtils.set(key, finalData);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
return data;
}
2. 写操作(更新)
public void write(String key, Object data) {
// 1. 删除缓存(防止脏数据)
redisUtils.del(key);
// 2. 更新数据库(主库)
db.updateToMaster(data);
// 3. 延迟删除缓存(确保主从同步完成)
new Thread(() -> {
try {
Thread.sleep(500); // 这里的时间要大于主从同步时间
redisUtils.del(key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
- 第一次删除缓存(防止旧数据被读取)
- 写入数据库
- 等待主从同步完成
- 第二次删除缓存(防止旧数据回写缓存)
这样可以有效防止 数据库读写分离 造成的 缓存脏数据,保证 缓存与数据库的一致性。
问题是这个“Thread.sleep(500);”还需要改进。
如何控制 Thread.sleep(500)
时间?是否可以加分布式锁?
直接使用 Thread.sleep(500)
来等待主从同步完成 并不精准,因为:
- 主从同步时间不固定
- 受 数据库负载、网络带宽、事务量 等影响,实际同步时间可能大于 500ms 或更短。
- 固定等待时间可能导致性能问题
- 过长:影响写入效率。
- 过短:缓存删除得太早,仍可能出现缓存脏数据问题。
所以,理想的方法是 动态调整缓存删除的时机,保证数据正确性的同时减少性能损耗。
解决方案
可以用 分布式锁 + 异步回调机制 来更精准地控制缓存删除的时机,而不是简单的 Thread.sleep()
。
方案 1:使用分布式锁,确保主从同步完成后删除缓存
核心思路:
- 更新数据库时获取分布式锁,保证只有一个线程在操作缓存。
- 异步任务轮询主从同步状态,当数据同步完成后删除缓存,而不是等待固定时间。
实现示例(基于 Redis 分布式锁 + 轮询检测主从同步状态)
public void write(String key, Object data) {
// 1. 删除缓存(防止脏数据)
redisUtils.del(key);
// 2. 更新数据库(主库)
db.updateToMaster(data);
// 3. 使用分布式锁防止并发修改缓存
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
if (redisUtils.tryLock(lockKey, requestId, 10, TimeUnit.SECONDS)) {
// 异步任务轮询主从同步状态
new Thread(() -> {
try {
int retryCount = 0;
while (retryCount < 5) { // 最多尝试 5 次
Thread.sleep(100); // 每次间隔 100ms
// 查询从库是否同步完成
Object newData = db.readFromSlave(key);
if (newData != null && newData.equals(data)) {
redisUtils.del(key); // 4. 确保同步完成后删除缓存
break;
}
retryCount++;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redisUtils.releaseLock(lockKey, requestId); // 释放锁
}
}).start();
}
}
方案 2:让数据库提供主从同步完成的事件通知
有些数据库(如 MySQL 8.0+)支持 binlog 事件通知,可以监听主从同步完成的状态,然后再删除缓存。
不过,这种方式需要 额外的消息队列(如 Kafka、RabbitMQ) 或 监听 MySQL Binlog。
步骤
- 监听 MySQL Binlog(主从同步完成时触发事件)
- 收到同步完成事件后删除缓存
实现思路
// 监听 MySQL Binlog 日志,当主从同步完成时删除缓存
public void onReplicationComplete(String key) {
redisUtils.del(key);
}
缺点:需要额外搭建 Binlog 监听 系统,复杂度较高。
总结
方案 | 方式 | 优点 | 缺点 |
---|---|---|---|
固定等待时间 (Thread.sleep() ) | 简单延迟 | 实现简单 | 主从同步时间不固定,可能太长或太短 |
分布式锁 + 轮询查询 | 先删除缓存 -> 轮询从库状态,等同步完成再删缓存 | 精确控制缓存删除时间,无需固定 sleep() | 多次查询数据库,有一定性能消耗 |
监听 MySQL Binlog | MySQL 主从同步完成时触发删除缓存 | 事件驱动,精准控制 | 需要搭建 Binlog 监听系统,较复杂 |
推荐
- 如果你的系统支持 Binlog 监听,可以使用 方案 3(监听数据库同步事件),精准删除缓存。
- 如果不支持 Binlog,建议用 方案 2(分布式锁 + 轮询数据库),在主从同步完成后删除缓存,避免使用
Thread.sleep()
固定延迟。