对缓存双写一致性的理解
1. 如果redis中有数据
需要和数据库的值相同
2.如果redis中无数据
数据库中的值要是最新值,且准备回写redis
3. 缓存按照操作来分,分成两种
- 只读缓存
- 读写缓存
- 同步直写策略
- 写数据库之后也同步写redis缓存,缓存和数据库中的数据保持一致
- 对于读写缓存来说,要想保证缓存和数据库中的数据一致性,就要采用同步直写策略
- 异步缓写策略
- 正常业务中,MySQL数据变了,但是可以在业务上容许出现一定时间后才作用于redis,比如仓库:下单之后不会立即发货;物流系统:不会实时更新显示,只有一定时间后你再次刷新才会显示最新地址
- 异常情况出现了,不得不将失败的动作重新修补,有可能需要借助kafka或者RabbitMQ等消息中间件,实现重试重写
- 同步直写策略
4. 有这么一种情况,微服务查询redis无数据mysql中有数据,为了保存数据双写一致性回写redis你需要注意什么?有没有了解过双检加锁策略?如何尽量避免缓存击穿?
业务顺序如下:
上述业务中采用双检加锁策略和避免缓存击穿的代码编写:
public class UserService {
//定义key前缀
public static final String CACHE_KEY_USER = "user";
@Autowired
private UserMapper userMapper;
@Autowired
private RedisTemplate redisTemplate;
/**
* 加强补充,避免key突然失效,进而打爆mysql,做一下预防,尽量不出现缓存击穿的事情
*/
public User test(String id){
User user=null;
String key=CACHE_KEY_USER+id;
//1. 先从redis里面查询,如果有则直接返回结果,如果没有再去查询mysql
user = (User)redisTemplate.opsForValue().get(key);
if (user == null){
// 对于高并发的优化,进来就先加一把锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
synchronized(UserService.class){
user = (User)redisTemplate.opsForValue().get(key);
//第二次查redis还是null,可以去数据库中查
user = userMapper.selectByUserName(id);
if (user != null){
//数据库有数据,需要回写redis,完成数据一致性的同步工作
redisTemplate.opsForValue().setIfAbsent(key,user,7L, TimeUnit.DAYS);
}
}
}
return user;
}
}
数据库和缓存一致性的几种更新策略
1. 目的 :达到最终一致性
2. 服务器可以停机的情况下
挂牌报错,凌晨升级,给出温馨提示,然后服务降级
使用单线程,重量级的数据操作不要使用多线程
3.四种更新策略
3.1 先更新数据库,在更新缓存
问题一:
1.先更新mysql的某商品的库存,当前商品的库存是100个,更新为99个
2.先更新mysql修改为99成功,然后更新redis
3.这个时候如果出现了异常,redis更新失败了,导致mysql里面的库存是99但是redis里面的还是100
4.这个时候就会让数据库和redis里面的数据不一致,其他请求过来就会从redis中读取到脏数据
问题二:
我们使用A、B两个线程来进行问题的说明
A、B两个线程在正常的逻辑下的运行状况如下:
1、A update mysql 100
2、A update redis 100
3、B update mysql 80
4、B update redis 80
=========================
在多线程环境下A、B两个线程有快有慢,有前有后有并行,异常逻辑运行状况如下:
1、A update mysql 100
2、B update mysql 80
这个时候B线程先一步抢到了redis的权限
3、B update redis 80
4、A update redis 100
========================
最终结果,mysql和redis数据不一致
MySQL:80,redis:100
3.2 先更新缓存,在更新数据库
不推荐,业务上一般把MySQL作为底单数据库,保证最后解释
[先更新缓存,再更新数据库],A、B两个线程发起调用
[正常逻辑]
1 A update redis 100
2 A update mysql 100
3 B update redis 80
4 B update mysql 80
====================================
[异常逻辑]多线程环境下,A. B两个线程有快有慢有并行
A update redis 100
B update redis 80
B update mysq| 80
A update mysql 100
mysql100,redis80
3.3 先删除缓存,在更新数据库
异常问题(还是使用A、B两个线程来说明情况)
1、A线程先成功删除了redis里面的数据,然后去更新mysql,此时mysql正在更新,还没有结束。B线程突然出现需要读取缓存数据
2、此时redis里面的数据是空的,B线程来读取时,先去读redis里面的数据(但是redis里面的数据已经被A线程删除),这个时候就会出现两个问题:
2.1 B线程从redis没有数据就去MySQL中读取,从数据库中读取出来的是旧值
2.2 B线程从数据库中读取出旧值之后,返回给前台并且回写进redis,这样redis里面被删除的脏数据就又被B线程回写了进来
3、A县城更新完MySQL,发现redis里面的数据是脏数据,A线程蒙了
两个并发操作,一个是更新操作,另一个是查询操作,A删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。
于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
4、总结流程:
(1)请求A进行写操作,删除redis缓存后,工作正在进行中,更新mysql.....
A还么有彻底更新完mysql,还没commit
(2)请求B开工查询,查询redis发现缓存不存在(被A从redis中删除了)
(3)请求B继续,去数据库查询得到了mysq中的旧值(A还没有更新完)
(4)请求B将旧值写回redis缓存
(5)请求A将新值写入mysql数据库
上述情况就会导致不一致的情形出现
先删除缓存,再更新数据库:如果数据库更新失败或超时或返回不及时,导致B线程请求访问缓存时发现redis里面没数据,缓存缺失,B再去读取mysql时,从数据库中读取到旧值,还写回redis, 导致A白干了,o(π_ _π)o
解决方案:可以采用延时双删策略
加上sleep的这段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程A再进行删除。所以,线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。
延迟双删面试题
这个删除该休眠多久呢?线程A sleep的时间,就需要大于一次写操作的时间。
这个时间怎么确定呢? 第一种方法: 在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。 这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。 第二种方法: 新启动一个后台监控程序,比如WatchDog监控程序
这种同步淘汰策略,吞吐量降低怎么办?
3.4 先更新数据库,在删除缓存(目前用的较多)
- 异常问题:
时间 | 线程A | 线程B | 出现的问题 |
---|---|---|---|
t1 | 更新数据库中的值...... | ||
t2 | 缓存立刻命中,此时B读取的是缓存旧值 | A还没来得及删除缓存的值,导致B缓存命中读到旧值 | |
t3 | 更新缓存的数据,over |
先更新数据库,在删除缓存,假如缓存删除失败或者来不及删除,导致请求再次访问redis时缓存命中,读取到的是缓存的旧值。
-
业务指导思想
微软云:Cache-Aside pattern - Azure Architecture Center | Microsoft Learn
后面的阿里巴巴canal也是类似的思想
订阅binlog程序在MySQL中有现成的中间件叫canal,可以完成订阅binlog日志的功能。
-
解决方案
1 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
2 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
3 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
4 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
-
类似经典的分布式事务问题,只有一个权威答案,只能达到最终一致性
流量充值,先下发短信实际充值可能滞后5分钟,可以接受
电商发货,短信下发但是物流明天见
小总结
方案如何选择?利弊如何
在大多数业务场景下, 个人建议是,优先使用先更新数据库,再删除缓存的方案(先更库→后删存)。理由如下:
1先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满mysql。
2如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
多补充一句:如果使用先更新数据库,再删除缓存的方案)
如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性。
策略 | 高并发多线程条件下 | 问题 | 现象 | 解决方案 |
---|---|---|---|---|
先删除redis缓存,再更新mysql | 无 | 缓存删除成功但数据库更新失败 | Java程序从数据库中读到旧值 | 再次更新数据库,重试 |
有 | 缓存删除成功但数据库更新中... 有并发请求 | 并发请求从数据库读到旧值并回写到redis,导致后续都是从redis读取到旧值 | 再次删除缓存,重试 | |
先更新mysql,再删除redis缓存 | 无 | 数据库更新成功,但缓存删除失败 | Java程序从redis中读到旧值 | 再次删除缓存,重试 |
有 | 数据库更新成功但缓存删除中...... 有并发读请求 | 并发请求从缓存读到旧值 | 等待redis删除完成,这段时间数据不一致,短暂存在。 |