觉得对你有益的小伙伴记得点个赞+关注
后续完整内容持续更新中
希望一起交流的欢迎发邮件至javalyhn@163.com
缓存双写一致性,就是指缓存与数据库数据的一致问题。别看就这么几个字,其实这里的学问是很多的。
1. 缓存的优点与缺点
1.1 缓存的优点
- 缩短服务的响应时间,给予用户更好的的体验
- 增大系统的吞吐量,效果同上
- 减轻数据库压力,防止高峰期数据被压垮
1.2 缓存的缺点
- 缓存有很多的类型,memcached、redis.....不同的选型会增加不同的维护难度,因为这原本就是一个单独的数据库
- 缓存要考虑分布式,在分布式的情况下,比如redis有很多坑,增加了系统的复杂度
- 对于缓存的准确性有高要求时,就必须考虑下文要讲的 「缓存双写一致性」
2. 只读缓存与读写缓存
只读缓存与读写缓存是缓存按照操作来分的两种结果。
2.1 只读缓存
每次修改直接写入后端数据库,如果Redis缓存不命中,则什么都不用操作,如果Redis缓存命中,则删除缓存中的数据,待下次读取时从后端数据库中加载最新值到缓存中。
2.2 读写缓存
读写缓存:若要对数据进行增删改,需要在Cache进行。 同时根据采取的写回策略,决定是否同步写回DB。
3. 同步直写策略与异步缓写策略
3.1 同步直写策略
写缓存的时候也同步数据库,缓存与数据库中的数据一致。
对于读写缓存来说,要想保证缓存和数据库中的一致性,就要采用同步直写策略
3.2 异步缓写策略
写缓存与写数据库不同步,而是异步的在一段时间过后写数据库(写缓存),中途会出现数据库与缓存数据不一致
3.3 什么时候同步直写什么时候异步缓写
-
小数据,某一小段热点数据,要求立刻变更,可以前台服务降级一下,后台马上同步直写
-
正常业务,马上更新了,可以在业务上容许出现1h后Reids起效,可以用异步缓写
-
出现异常后,不得不将失败的动作重新修补,不得不借助kafka或者rabbitmq等消息中间件,实现解耦后重写
4. 缓存双写一致性的两个要点
- 如果缓存中没有数据,
数据库中的数据要是最新值
- 如果缓存中有数据,
缓存中的数据一定是最新值
5. 数据库和缓存一致性的几种更新策略
5.1 先更新数据库,再更新缓存
这个更新策略会出现头疼的脏数据问题
下面给出问题出现过程
- 先更新MySQL的某个商品库存,将当前100个库存商品更新为99个
- MySQL商品库存更新为99成功,接着去更新Redis
此时假设出现异常,导致Reids更新失败
,导致MySQL商品库存为99个,Redis仍为100- 上述发生,会让数据库里面和缓存redis里面数据不一致,读到脏数据
这种很少用,不推荐
5.2 先删除缓存,再更新数据库
public void deleteOrderData(Order order){
try(Jedis jedis = RedisUtils.getJedis()){
//1 线程A成功删除缓存
jedis.del(order.getId() + "");
//2 线程A更新mysql
orderDao.update(order); `假设花费了30s完成`
}catch(Exception e){
e.printStackTrace();
}
}
捋一下上述过程
- 此时有两个并发线程,线程A先进入方法,线程A先删除缓存数据,然后去更新mysql,由于网络延时,mysql一直在更新,线程A阻塞
- 接着线程B进入方法要去读取缓存数据
public Order selectOrderData() {Order order
try(Jedis jedis = RedisUtils.getJedis()){
//1 线程B先去Reids里面找,找到返回数据,否则去数据库查找
String result = jedis.get(order.getId() + "");
if(result != null) {
return (Order) JSON.parse(result);
}else{
order = orderDao.getOrderById(order.getId());
//2 线程B会将读取到的旧数据写入Reids
jedis.set(order.getId() + "",order.toString());
return order;
}
}catch(Exception e) {
e.printStackTrace();
}
return null;
}
-
线程B此时读取到的Reids里面的数据是空的(被线程A删除),此时发生了如下问题
线程B从mysql中获得旧值
B线程发现Reids没有(缓存缺失)马上去redis里面获取,得到旧值线程B将旧值写回Reids
获得旧值数据后返回前台并回写进redis(刚被A线程删除的旧数据有极大可能又被写回了)。
-
线程A更新完mysql,发现redis里面缓存的还是脏数据,白干了。。。两个并发操作,一个是更新操作,一个是读取操作,A更新操作删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
总结流程: (1)请求A进行写操作,删除缓存后,工作正在进行中......A还么有彻底更新完
(2)请求B开工,查询redis发现缓存不存在
(3)请求B继续,去数据库查询得到了myslq中的旧值
(4)请求B将旧值写入redis缓存
(5)请求A将新值写入mysql数据库
如果数据库更新失败,导致B线程请求再次访问缓存时,发现redis里面没数据,缓存缺失,再去读取mysql时,从数据库中读取到旧值
5.3 针对先删缓存再更新数据库出现问题的解决方案
- 加锁(采用DCL双端检索机制)
延时双删
-
怎么确定延时双删所需要延迟的时间呢 在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时, 以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加
百毫秒
即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
-
如果是mysql主从读写分离架构呢
(1)请求A进行写操作,删除缓存
(2)请求A将数据写入数据库了,
(3)请求B查询缓存发现,缓存没有值
(4)请求B去从库查询
,这时,还没有完成主从同步
,因此查询到的是旧值
(5)请求B将旧值写入缓存
(6)数据库完成主从同步,从库变为新值上述情形,就是数据不一致的原因。还是使用双删延时策略。
只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms
- 这种同步淘汰策略吞吐量低怎么办?
延时删除用异步任务解决
5.4 先更新数据库,再删除缓存
问题如下
假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。
针对这个问题,老外写过一篇论文 docs.microsoft.com/en-us/azure…
知名社交网站facebook也在论文《Scaling Memcache at Facebook》中提出 www.usenix.org/system/file…
5.5 针对先更新数据库再删除缓存出现问题的解决方案
- 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
- 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
- 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
- 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
5.6 先更新缓存,再更新数据库
杜绝!!!
6. 到底用5.2 还是 5.3
在大多数业务场景下,我们会把Redis作为只读缓存使用。假如定位是只读缓存来说
,
理论上我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存,但是没有完美方案,两害相衡趋其轻的原则
个人建议是,优先使用先更新数据库,再删除缓存的方案
。理由如下:
1 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力,严重导致打满mysql。
2 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置
。
多补充一句,如果使用先更新数据库,再删除缓存的方案
如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。