Redis学习——高级篇③
- = = = = = = Redis7高级之缓存双写一致性之更新策略探讨(三)= = = = = =
- 1.缓存双写一致性
- 2.数据库和缓存一致性的几种更新策略
- 2.1 可停机的情况
- 2.2 不可停机的情况,四种更新策略(推荐最后一种,看场景)
- 1.❌先更新数据库,再更新缓存
- 2.❌先更新缓存,再更新数据库
- 3.❌先删除缓存,再更新数据库
- 4.⚠️先更新数据库,再删除缓存
- 3. 总结
= = = = = = Redis7高级之缓存双写一致性之更新策略探讨(三)= = = = = =
还是先放面试题,当一整遍看完,再回头看看 这个面试题题,自己会不会。
1.缓存双写一致性
-
如果redis中有数据
- 需要和数据库中的值相同
-
如果redis中无数据
- 数据库中的值是最新值,且准备回写redis
-
缓存按照操作分
- 只读缓存
- 读写缓存
- 同步直写策略
- 写数据库后也同步写 redis 缓存,缓存中的数据和数据中的一致
- 对于读写缓存来说,要想保证缓存和数据库中的数据一致
- 异步缓写策略
- 正常业务运行中,mysql数据变动了,但是可以在业务上容许出现一定时间后才作用于redis,比如仓库、物流系统
- 异常情况出现了,不得不讲失败的动作重新修补,有可能需要借助kafka或者RabbitMQ等消息中间件,实现重写重试
- 同步直写策略
-
-
采用双检加锁策略
- 多个线程同时去查询数据库的这条数据,就在第一个查询数据的请求上使用一个互斥锁来锁住他。
- 其他线程获取不到锁就一直等待,等第一个线程查询到了数据,然后做了缓存
- 后面的线程进来发现已经有了缓存,就直接走缓存
-
package com.lv.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.lv.User;
import com.lv.mapper.UserMapper;
import com.lv.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
public static final String CACHE_KEY_USER = "user:";
@Resource
private UserMapper userMapper;
@Resource
private RedisTemplate redisTemplate;
/**
* 业务逻辑没有写错,对于小厂中厂(QPS《=1000)可以使用,但是大厂不行
* @param id
* @return
*/
public User findUserById1(Long id){
User user = null;
String key = CACHE_KEY_USER + id;
// 1.先从redis中查询,如果有直接返回结果,没有再去查询 mysql
user = (User) redisTemplate.opsForValue().get(key);
if (user == null){
// 2. redis中没有,查询mysql
user = userMapper.selectById(id);
if (user == null){
// 3.1 redis + mysql 都无数据
// 具体细化,防止多次穿透,业务规定,记录下导致穿透的这个key回写redis
return user;
}else {
// 3.2 mysql有,需要回写到redis,保证下一次的缓存命中率
redisTemplate.opsForValue().set(key,user);
}
}
return user;
}
/**
* 加强补充,避免突然key失效了,打爆mysql,做一下预防,尽量不出现击穿的情况
* @param id
* @return
*/
public User findUserById2(Long id){
User user = null;
String key = CACHE_KEY_USER + id;
// 1.先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
// 第一次查询redis,加锁前
user = (User) redisTemplate.opsForValue().get(key);
if (user == null){
// 2.对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
synchronized (UserServiceImpl.class){
// 第二次查询redis,加锁后
user = (User) redisTemplate.opsForValue().get(key);
// 3. 二次查redis还是null,可以去查mysql了(mysql默认有数据)
if (user == null) {
//4 查询mysql拿数据(mysql默认有数据)
user = userMapper.selectById(id);
if (user == null) {
return null;
} else {
// 5. mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
redisTemplate.opsForValue().setIfAbsent(key, user, 7L, TimeUnit.DAYS);
}
}
}
}
return user;
}
}
2.数据库和缓存一致性的几种更新策略
目的
- 达到最终一致性
- 给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。
- 我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以mysql的数据库写入库为准。
上述方案和后续落地案例是调研后的主流+成熟的做法,但是考虑到各个公司业务系统的差距,不是100%绝对正确,不保证绝对适配全部情况,请同学们自行酌情选择打法,合适自己的最好。
2.1 可停机的情况
基本上怎么处理都可以
- 挂牌报错
- 凌晨升级
- 服务降级
- 温馨提示
- 最好单线程操作(对于重量级的数据操作)
2.2 不可停机的情况,四种更新策略(推荐最后一种,看场景)
1.❌先更新数据库,再更新缓存
异常问题1
- 先更新mysql的某商品的库存,当前商品的库存是100,更新为99个。
- 先更新mysql修改为99成功,然后更新redis。
- 此时假设异常出现,更新redis失败了,这导致mysql里面的库存是99而redis里面的还是100。
- 上述发生,会让数据库里面和缓存redis里面数据不一致,读到redis脏数据
异常问题2
[先更新数据库,再更新缓存] , A、B两个线程发起调用
[正常逻辑]
A update mysql 100
①A update redis 100
②B update mysql 80
③B update redis 80
④
[异常逻辑]多线程环境下,A、B两个线程有快有慢,有前有后有并行
A update mysql 100
①B update mysql 80
③B update redis 80
④A update redis 100
②
最终结果,mysq|和redis数据不一致,/(ㄒoㄒ)/~~
mysql80
,redis100
2.❌先更新缓存,再更新数据库
-
不推荐:一般业务会将mysql作为底单数据库,有最终解释权
-
异常问题
[先更新缓存,再更新数据库] , A、B两个线程发起调用
[正常逻辑]
A update redis 100
①A update mysql 100
②B update redis 80
③B update mysql 80
④
[异常逻辑]多线程环境下,A、B两个线程有快有慢,有前有后有并行
A update redis 100
①B update redis 80
③B update mysql 80
④A update mysql 100
②
最终结果,mysq|和redis数据不一致,/(ㄒoㄒ)/~~
mysql80
,redis100
3.❌先删除缓存,再更新数据库
异常问题
3
- A线程更新完mysq|,发现redis 里面的缓存是脏数据,A线程直接懵逼了
- 两个并发操作,一个是更新操作,另一个是查询操作,
- A删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。
- 于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
4总结流程:
- 请求A进行写操作,删除redis缓存后,工作正在进行中,更新mys…A还么有彻底更新完mysql,还没commit
- 请求B开工查询,查询redis发现缓存不存在(被A从redis中删除了)
- 请求B继续,去数据库查询得到了mysq|中的旧值(A还没有更新完)
- 请求B将旧值写回redis缓存
解决方案(延时双删策略)
注意关键点,我更新完数据库的时间 + sleep的时间 大于 读取数据并写入换的时间 即可(多个100ms即可)
加上sleep的这段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程A再进行删除。所以,线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟观删”。
关于延时双删的细节问题
-
这个线程休眠时间(线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间)
- 第一种 :在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。
- 第二种: 新启动一个后台监控程序,比如WatchDog监控程序,会加时
这种同步淘汰策略,吞吐量降低怎么办
- 第二次删除缓存使用 异步删除
4.⚠️先更新数据库,再删除缓存
异常问题
订阅binlog程序再mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能(下一章具体实现)
解决方法
- 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka / RabbitMQ等)。
- 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
- 如果 能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重 复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
- 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
分布式的事务问题一定要遵守最终一致性,可以允许短暂的信息滞后
多补充一句:如果使用先更新数据库,再删除缓存的方案
如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性,请大家参考。
3. 总结
策略 | 高并发多线程条件下 | 问题 | 现象 | 解决方案 |
---|---|---|---|---|
先删除redis缓存,再更新mysql | 无 | 缓存删除成功但数据库更新失败 | Java程序 从数据库中读到旧值 | 再次更新数据库,重试 |
先删除redis缓存,再更新mysql | 有 | 缓存删除成功但数据库更新中。。。有并发读请求 | 并发请求从数据库读到旧值并回写到redis,导致后续都是从redis读取到旧值 | 延迟双删 |
先更新mysql,再删除redis缓存 | 无 | 数据库更新成功,但缓存删除失败 | Java程序从redis中读到旧值 | 再次删除缓存,重试 |
先更新mysql,再删除redis缓存 | 有 | 数据库更新成功但缓存删除中。。。有并发读请求 | 并发请求从缓存读到旧值 | 等待redis删除完成,这段时间有数据不一致,短暂存在 |
在大多数业务场景下,阳哥建议是 优先使用先更新数据库,再删除缓存的方案(先更库,后删存)。理由如下:
先删除缓存值再更新数据库
- 有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满mysql
- 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置
先更新数据库,再删除缓存
- 如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性。