文章目录
一般情况下,redis是作为缓存,当数据库中有数据修改时,要做到缓存和修改后的数据保持一致
一般我们在更新数据库数据时,需要同步redis中缓存的数据,所以存在不同的方案,这里我简单分为2个大类进行总结,每个大类下又有不同的方案
代码自己解决
只通过代码,不需要其他第三方组件介入
先更新数据库后删除缓存
- 更新数据库
- 删除缓存
存在问题:
- 步骤1成功,步骤2失败,那后续读取的一直都是旧缓存
- 即使步骤1和步骤2都成功,但步骤1和步骤2执行过程中,如果有并发请求:线程1步骤1------>线程2读请求------>线程1步骤2,此时线程2读取的也是旧数据
先删除缓存后更新数据库
- 删除缓存
- 更新数据库
存在问题:
请求1清除缓存------>请求2读请求,没有缓存,读DB并写入redis------>请求1更新数据库
当请求1执行清除缓存后,还未进行update操作,此时请求2进行查询到了旧数据并写入了redis,后续读取一直都是旧数据
注意:无论是先更新数据库还是后更新数据库,有的会把更新缓存(不是删除)也作为一种解决方案,但生产中真正使用时,都是删除缓存
为什么是删除而不是更新?因为
更新缓存成本更高,而且可能产生并发问题。删除缓存后,下次读取时会自动回填
延迟双删
- 删除缓存
- 更新数据库
- 延迟N秒,再删除缓存(延迟的时间>写入redis的时间)
延迟的原因:保证请求2的旧数据已经完全写到redis中了,这样就可以把旧数据完全删除;如果延迟时间小于写入redis的时间,可能请求2的旧数据还没有写入redis,还是会造成数据不一致
java
// 伪代码如下
public void updateData(String key,Object data){
// 第一次删除缓存
redisTemplate.delete(key);
// 更新数据库
database.update(data);
// 第二次删除缓存(延迟,保证并发读请求把旧数据完全写入redis)
Thread.sleep(500);
redisTemplate.delete(key);
}
优点:实现简单,能有效解决大部分并发问题
缺点:
- 延迟时间内仍可能存在短暂不一致
- 第二次删除缓存要设置一定的延迟时间(Thread.sleep),所有的修改数据都要延迟,
牺牲系统性能
读写锁/分布式锁
在极端高并发场景下,可以使用分布式锁保证同一时刻只有一个线程在操作某个key的缓存(操作缓存时,写操作加锁,读操作不加锁)
优点:保证强一致性,防止缓存击穿
缺点:引入锁降低了并发性能,适用于并发极高且一致性要求严格的场景
java
public Data readDataWithLock(String key) {
// 先查缓存
Data data = redisTemplate.get(key);
if (data != null) return data;
// 缓存未命中,加锁后查数据库
String lockKey = "lock:" + key;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(5, TimeUnit.SECONDS);
// 双重检查,防止其他线程已经回填了缓存
data = redisTemplate.get(key);
if (data != null) return data;
// 查数据库并回填缓存
data = database.query(key);
// 因为写回redis的代码在锁中,所以操作这个key的只有一个线程
redisTemplate.set(key, data);
return data;
} finally {
lock.unlock();
}
}
接入其他组件
消息队列MQ
将缓存删除操作封装成消息发送到MQ,由消费者异步执行删除。即使删除失败,也可以通过MQ的重试机制保证最终删除成功。
优点:解耦、可靠,通过MQ的重试机制保证最终一致性
缺点:引入MQ增加了系统复杂度,且数据存在一定延迟(不一致)
订阅Binlog同步(Canal方案)
阿里巴巴开源的Canal组件可以监听MySQL的binlog变更,然后将变更推送给Redis进行同步
MySQL binlog → Canal → MQ → 消费者 → 更新Redis
优点:
- 与业务代码完全解耦,不需要在业务代码中写缓存更新逻辑
- 不入侵原有业务代码
- 可靠性高,binlog是MySQL原生机制
缺点:
- 引入Canal和MQ增加了系统复杂度
- 存在一定的同步延迟(秒级)
总结:没有完美的方案,只能根据业务场景选择
只要享受了缓存的高速度,就注定无法保证数据实时一致性,只能尽可能降低数据不一致的概率,并保证最终一致性