思考:redis作为缓存,mysql的数据如何与redis进行同步?(双写一致性问题)
注意在回答这个问题的时候一定先介绍业务背景前提业务背景大致可以分为两个:
- 数据一致性要求高
- 数据允许延迟一致 接下来我们就从这两方面进行介绍。
1. 双写一致
双写一致性:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致
上图分为两个流程:
- 读操作: 缓存命中,直接返回;缓存未命中查询数据库,写入缓存,设定超时时间
- 写数据: 延迟双删
1.1. 延迟双删
延迟双删步骤如图所示,接下来有问题问小伙伴。
思考:延迟双删,先删除数据库,还是先删除缓存?
答案:无论先删除谁是会存在问题
1.2.先删除缓存,再更新数据库
假设,缓存和数据库值都是10
先删除缓存,再更新数据库,正常情况下步骤如下:
- 步骤一: 线程一先删除缓存
- 步骤二: 线程一更新数据库v=20
- 步骤三: 线程二在线程一更新完成后才查询数据库v=20
- 步骤四: 线程二查询数据库后将缓存写入redis缓存中v=20
先删除缓存,再更新数据库,异常情况下步骤如下:
- 步骤一: 线程一先删除缓存
- 步骤二: 线程一在没有完成删除缓存和更新数据库步骤下,就切换到线程二,线程二查询缓存未命中,查询数据库,v=10
- 步骤三: 线程二查询数据库后将缓存写入redis,缓存结果v=10
- 步骤四: 切换到线程一更新数据库v=20
结论:先删除缓存,再更新数据库可能出现脏数据
1.3.先更新数据库,再删除缓存
假设,缓存和数据库值都是10
先更新数据库,再删除缓存,正常情况下步骤如下:
- 步骤一: 线程二先更新数据库v=20
- 步骤二: 线程二再删除缓存
- 步骤三: 线程一 查询缓存,未命中,查询数据库v=20
- 步骤四: 线程一将查询结果写入缓存redis,缓存结果v=20
先更新数据库,再删除缓存,异常情况下步骤如下:
- 步骤一: 线程一 查询缓存,未命中, 查询数据库v=10
- 步骤二: 线程一操作未完成切换到线程二, 线程二再删除缓存.更新数据库v = 20
- 步骤三: 线程二 删除缓存
- 步骤四: 线程一将查询结果写入缓存redis,缓存结果v=10
结论:先更新数据库,再删除缓存可能出现脏数据
1.4. 延迟双删问题
思考:延迟双删为什么要删除两次缓存?
答案:无论先删除缓存,还是先修改数据库,都会存在脏数据风险,所以在写操作的时候使用延迟双删,降低脏数据出现概率
思考:为什么要延时删除?
因为一般数据是主从模式,读写分离,数据库需要延时一会将数据从主节点同步到从节点
注意:延时删除还是存在问题,因为延时时间无法确定,所以还是存在脏数据风险
2.双写一致,强一致性解决方案
使用分布式锁,解决双写一致性的强一致性,但是性能较差
2.1. 分布式锁优化
思考:一般什么情况下数据才会进行缓存?
答案:读多写少数据才会进行缓存
所以我们可以使用读写锁进行优化分布式锁:
- 共享锁:读锁readLock,加锁之后,其他线程可以共享读操作
- 排他锁:独占锁writeLock也叫,加锁之后,阻塞其他线程读写操作
在读数据的时候我们可以使用共享锁,保证所有线程可以读,但是不可以写。
在写数据的时候我们可以使用排他锁,保证所有线程可以读,但是不可以写
redisson代码实现
- 读操作
public Item getById(Integer id){
RReadWriteLock readWriteLock =redissonClient.getReadWriteLock(s:"ITEM_READ_WRITE_LOCK");
//读之前加读锁,读锁的作用就是等待该Lockkey释放写锁以后再读
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,name:"华为手机",desc:"华为手机",price:5999.00);
//写入缓存
redisTemplate.opsForValue().set("item:"+id,item);
//返回数据
return item;
}finally {
readLock.unlock();
}
}
- 写操作
public void updateById(Integer id){
RReadWriteLock readWriteLock =redissonClient.getReadWriteLock(s:"ITEM_READ_WRITE_LOCK");
//写之前加写锁,写锁加锁成功,读锁只能等待
RLock writeLock =readWriteLock.writeLock();
try {
//开锁
writeLock.lock();
System.out.println("writeLock...");
//更新业务数据
Item item =new Item(id,name:"华为手机",desc:"华为手机",price:5299.00);
try {
Thread.sleep(millis:10000);
}catch(InterruptedException e){
e.printStackTrace();
}
//删除缓存
redisTemplate.delete(key:"item:"+id);
}finally {
writeLock.unlock();
}
}
2.双写一致,最终一致性解决方案
双写一致,最终一致性可能在我们实际使用中用到的最多,解决方案也比较多
异步通知保证数据的最终一致性
异步通知保证数据的最终一致性
基于Canal的异步通知
二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句。
3. 双写一致面试
面试官:redis为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)
候选人:
就说我最近做的这个项目,里面有xxxx(根据自己的简历上写)的功能,需要让数据库与redis高度保持一致,因为要求时效性比较高,我们当时采用的读写锁保证的强一致性。
我们采用的是redisson实现的读写锁,在读的时候添加共享锁,可以保证读读不互斥,读写互斥。当我们更新数据的时候,添加排他锁,它是读写,读读都互斥,这样就能保证在写数据的同时是不会让其他线程读数据的,避免了脏数据。这里面需要注意的是读方法和写方法上需要使用同一把锁才行。
面试官:那这个排他锁是如何保证读写、读读互斥的呢?
候选人:
其实排他锁底层使用也是setnx,保证了同时只能有一个线程操作锁住的方法
面试官:你听说过延时双删吗?为什么不用它呢?
候选人:
延迟双删,如果是写操作,我们先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据,其中这个延时多久不太好确定,在延时的过程中可能会出现脏数据,并不能保证强一致性,所以没有采用它。
面试官:redis做为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)
候选人:
就说我最近做的这个项目,里面有xxxx(根据自己的简历上写)的功能,数据同步可以有一定的延时(符合大部分业务)
我们当时采用的阿里的canal组件实现数据同步:不需要更改业务代码,部署一个canal服务。canal服务把自己伪装成mysql的一个从节点,当mysql数据更新以后,canal会读取binlog数据,然后在通过canal的客户端获取到数据,更新缓存即可。