如何保证缓存与数据库的双写一致性?
- 概述
- 同步策略
- 更新缓存还是删除缓存:
- 先操作数据库还是缓存:
- 案例一、先删除缓存,在更新数据库
- 案例二 先操作数据库,再删除缓存
- 延时双删策略(不推荐)
- 使用分布式锁实现双写一致性
- 使用读写锁实现双写一致性
- 使用消息队列异步通知
- 订阅Mysql的Binlog文件(可借助Canal来进行)
- 总结
概述
MySQL 和 Redis 都是常见的数据存储方案,MySQL 用于存储结构化数据,Redis 用于存储非结构化数据。在一些高并发场景下,为了提升系统的性能,我们通常会将数据存储在 Redis 缓存中,并通过 Redis 缓存来提高系统的读取速度。但是,Redis 缓存中的数据是不稳定的,可能会随时被删除或者被更新,因此需要和 MySQL 中的数据进行同步,保证数据的一致性。
但是使用过缓存的人都应该知道,在实际应用场景中,要想实时刻保证缓存和数据库中的数据一样,很难做到。 基本上都是尽可能让他们的数据在绝大部分时间内保持一致,并保证最终是一致的。
同步策略
首先介绍一下双写一致性·
:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库
四种同步策略:
想要保证缓存与数据库的双写一致,一共有4种方式,即4种同步策略:
1. 先更新缓存,再更新数据库;
2. 先更新数据库,再更新缓存;
3. 先删除缓存,再更新数据库;
4. 先更新数据库,再删除缓存。
从这4种同步策略中,我们需要作出比较的是:
- 更新缓存与删除缓存哪种方式更合适?
- 应该先操作数据库还是先操作缓存?
更新缓存还是删除缓存:
下面,我们来分析一下,应该采用更新缓存还是删除缓存的方式。
- 更新缓存
- 优点:每次数据变化都及时更新缓存,所以查询时不容易出现未命中的情况。
- 缺点:更新缓存的消耗比较大。如果数据需要经过复杂的计算再写入缓存,那么频繁的更新缓存,就会影响服务器的性能。如果是写入数据频繁的业务场景,那么可能频繁的更新缓存时,却没有业务读取该数据。
- 删除缓存
- 优点:操作简单,无论更新操作是否复杂,都是将缓存中的数据直接删除。
- 缺点:删除缓存后,下一次查询缓存会出现未命中,这时需要重新读取一次数据库。
从上面的比较来看,一般情况下,删除缓存是更优的方案。
先操作数据库还是缓存:
下面,我们再来分析一下,应该先操作数据库还是先操作缓存。
案例一、先删除缓存,在更新数据库
初始时,缓存和数据库均为10。
如上图,先删除缓存,再更新数据库,可能会出现的问题:
- 线程1删除缓存
- 线程2查询缓存未命中,查询数据库
- 写入缓存的值为10,
- 线程1再进行更新数据库,值为20
此时数据库为更新过的值20,而缓存还是旧值10,此时出现了数据库和缓存数据不一致情况。
案例二 先操作数据库,再删除缓存
如上图,先删除缓存,再更新数据库,可能会出现的问题:
- 线程1查询缓存未命中,查询数据库
- 线程2更新数据库为20,
- 线程2删除缓存
- 线程1写入缓存值为10
此时数据库为更新过的值20,而缓存还是旧值10,此时出现了数据库和缓存数据不一致情况。
经过案例一和案例二的比较,先删除缓存和先更新数据库都会出现问题。
延时双删策略(不推荐)
在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。
伪代码如下:
public void write( String key, Object data ){
redis.delKey(key);
db.updateData(data);
Thread.sleep(500);
redis.delKey(key);
}
问题:这个500毫秒怎么确定的,具体该休眠多久时间呢?
- 需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
- 当然这种策略还要考虑redis和数据库主从同步的耗时。
- 另外这种策略也会可能会有脏数据的风险,而且还会消耗不必要的性能。
在实际场景中,并不推荐延时双删策略,一方面可能会有脏数据的风险,而且还会消耗不必要的性能。
虽然先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
但是,为了确保万无一失,在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。另外在更新缓存中加入过期时间,这样就算出现了缓存和数据库不一致问题,但最终是一致的。
使用分布式锁实现双写一致性
分别在写数据和读数据加分布式锁,保证同一时间只运行一个请求更新缓存(保证读写串行化),就会不会产生并发问题了,这样就能保证redis和mysql的数据强一致性。
但是这样的话读操作和写操作都需要加锁,效率就会大大降低。其实在真实场景中放入缓存中的数据一般是读多写少,如果是读少写多,那完全可以不用缓存,直接操作数据库了。
使用读写锁实现双写一致性
在读多写少的场景下,可以使用读锁和写锁的机制。
- 共享锁:读锁readLock,加锁之后,其他线程可以共享读操作,写互斥
- 排他锁:独占锁writeLock也加写锁,加锁之后,堵塞其他线程读写操作。
使用redisson中的读写锁实现双写一致性
想要拿到共享锁或者排他锁,都需要先拿到读写锁。通过固定代码可以拿到读写锁。
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("READ_WRITE_LOCK");
随后分别拿到共享锁和排他锁。(注意两个锁需要是同一把读写锁)
RLock readLock = readWriteLock.readLock();
RLock writeLock = readWriteLock.writeLock();
读操作加入读锁(共享锁)
public void getById(Integer id){
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("READ_WRITE_LOCK");
RLock readLock = readWriteLock.readLock();
try{
readLock.lock();
System.out.println("readLock...");
Item item = (Item) redisTemplate.opsForValue().get("item"+id);
if(item != null){
return item;
}
item = new Item(id, "手机", "手机", 60.00);
redisTemplate.opsForValue().set("item"+id, item);
return item;
}finally{
readLock.unlock();
}
}
写操作加入写锁(排他锁)
public void updateById(Integer id){
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("READ_WRITE_LOCK");
RLock writeLock = readWriteLock.writeLock();
try{
writeLock.lock();
System.out.println("writeLock...");
Item item = new Item(id, "手机", "手机", 100.00);
try{
Thread.sleep(2000);
}catch(InterruptedException e){
e.printStackTrace();
}
redisTemplate.delete("item"+id);
}finally{
writeLock.unlock();
}
}
可以实现强一致性方案,虽然比分布式锁好一点,但是在高并发场景下性能也比较低。
使用消息队列异步通知
如果允许缓存中的数据在短时间内可以跟数据库数据不一致的情况下,可以使用异步通知的方案,可以保证最终一致性。
为了解决双写一致性的问题,我们可以引入消息队列,比如RabbitMQ,来异步更新Redis。将操作同一资源的请求,打到同一个队列中。
当有数据变动时,我们先操作数据库,然后通过消息队列发送消息到一个缓存更新的队列中,异步更新缓存。这种方式能够让写操作变得更加高效,并且避免了高并发下的缓存与数据库数据不一致的问题。
订阅Mysql的Binlog文件(可借助Canal来进行)
另一种更为可靠的方法是使用MySQL的binlog。我们可以使用Maxwell或者Canal等工具,实时解析binlog,然后更新Redis。
这种方案的好处是即使应用程序崩溃,也不会丢失binlog,因此能够保证最终的数据一致性。但是,这种方案的实现比较复杂,需要对MySQL的内部机制有深入的理解。
总结
允许延时一致的业务,采用异步通知
- 使用MQ中间件,更新数据之后,通知缓存更新,将操作同一资源的请求,打到同一个队列中。
- 利用canal中间件,不需要修改业务代码,伪装为mysql的一个从节点,canal通过读取binlog数据更新缓存
强一致性,采用Redisson提供的读写过
在读多写少的场景下,可以使用读锁和写锁的机制。
- 共享锁:读锁readLock,加锁之后,其他线程可以共享读操作,写互斥
- 排他锁:独占锁writeLock也加写锁,加锁之后,堵塞其他线程读写操作。