缓存与分布式锁的应用
缓存与分布式锁的应用
目录- 缓存与分布式锁的应用
- 1、缓存应用
- 1、本地应用
- 2、分布式场景中应用
- 3、缓存相关问题
- 3.1、缓存穿透
- 3.2、缓存雪崩
- 3.3、缓存击穿
- 2、击穿解决方案-锁的应用
- 1、本地锁(包括JUC包下)
- 2、分布式锁
- 3、Redisson——分布式中的JUC
- 4、Redisson 开始
- 1、缓存应用
1、缓存应用
缓存使用场景:
为了提升系统性能,我们一般会将部分数据放入缓存中,加速访问,而数据库只承担数据的落盘工作
那么哪些数据适合放入缓存呢?
- 即时性,数据一致性要求不高的
- 访问量大且更新效率不高的
举例:电商类应用、商品分类、商品列表等适合放入缓存并加一个更新时间(由数据更新频率来定),后台发布一个商品,买家需要5分钟后才能看到新的商品一般是可以接受的
1、本地应用
/**
* 自定义缓存
*/
private Map cache = new HashMap<>();
/**
* 获取真实数据
*
* @return
*/
public NeedLockDataVO getDataVO() {
NeedLockDataVO cacheDataVO = (NeedLockDataVO) cache.get("cacheDataVO");
// 如果有,就返回
if (cacheDataVO != null) {
return cacheDataVO;
}
// 如果没有,获取到db中数据
NeedLockDataVO needLockDataVO = doGetNeedLockDataVO();
// put进缓存
return (NeedLockDataVO) cache.put("cacheDataVO", needLockDataVO);
}
private NeedLockDataVO doGetNeedLockDataVO() {
// TODO 繁琐业务逻辑代码
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
return new NeedLockDataVO();
}
我们这里的缓存组件利用了原生的Map。
在同一个项目,同一个JVM中,即本地保存一个副本的,可以称为本地缓存。
在单个服务应用中,我们使用本地缓存模式,缓存组件和应用如果永远只用同一个机器上部署,不会出现任何问题,并且效率很高。
但是我们思考下面一个场景:
如果在一个分布式的系统下,我们一个服务项目往往会部署十几台服务器上,每一个服务器都自带一个自己的一个本地缓存,这样会出现什么问题呢?
分布式场景下,单体应用自带的缓存仅对与自己所在服务器上生效。假设我们有一个商品服务,同时部署在多个服务器上,客户端发起了查询一个商品列表的请求,我们通过负载均衡找到第一台服务器,发现一号服务器的本地缓存中没有,就从数据库中查询出来,放到了一号服务器的本地缓存中。第二个客户端请求同样是查询这个商品列表,通过负载均衡,找到了第二台服务器,那么此时第二台服务器的本地缓存是不会有第一台的缓存信息的。如此会引来一下问题:
问题:
- 服务器各顾各的,每一个请求进来,都得查一遍放入自己的缓存中
- 如果对数据进行修改,一般还要修改缓存中的数据,假设我们通过负载均衡,只修改了一号服务器缓存数据,那么以后负载均衡到其他服务器上的请求所得到的数据,就会和一号服务器的数据有所不同,就会出现一个严重的问题:数据一致性问题
那么,我们分布式系统下,该如何使用缓存,解决数据一致性的问题呢?
2、分布式场景中应用
我们引入一个中间件redis,将缓存控制交给第三方来处理,所有读取|写入缓存的数据都交由redis,本地不再做缓存
/**
* StringRedisTemplate
*/
@Resource
StringRedisTemplate cache;
/**
* 获取真实数据
*
* @return
*/
public NeedLockDataVO getDataVO() {
// 从redis中取出
String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference(){});
// 如果有,就返回
if (cacheDataVO != null) {
return cacheDataVO;
}
// 如果没有,获取到db中数据
NeedLockDataVO needLockDataVO = doGetNeedLockDataVO();
// 存入redis
cache.opsForValue().set("cacheDataVO", JSON.toJSONString(needLockDataVO));
return needLockDataVO;
}
private NeedLockDataVO doGetNeedLockDataVO() {
// TODO 繁琐业务逻辑代码
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
return new NeedLockDataVO();
}
接下来,我们利用jmeter压测一下会发现,redis后期会频繁报错:OutOfDirectMemoryError
产生的原因:
Redis自动配置
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
}
SpringBoot2.0以后默认使用Lettuce作为操作redis的客户端,它使用netty进行网络通信lettuce的bug导致堆外内存溢出,netty如果没有指定堆外内存,就会默认使用虚拟机的-Xms的值,可以使用-Dio.netty.maxDirectMemory进行设置,时间久了堆外内存溢出问题肯定还会出现,治标不治本。
那我们该如何解决这个棘手的问题呢?
Jedis 替换SpringBoot默认使用的Lettuce
首先排除Lettuce包:
org.springframework.boot
spring-boot-starter-data-redis
io.lettuce
lettuce-core
redis.clients
jedis
堆外内存溢出OutOfDirectMemoryError,完美解决!
我们通过加第三方缓存解决了缓存一致性的问题,可是我们设想一个场景,如果有两台服务器,A先请求更新db,B在之后更新db,但是A请求的服务器,网络出现问题导致延时,B请求的服务器在A请求的服务器之前首先操作缓存,那么我们按照常理来讲,应该是从缓存中查询到最后一次更新的数据,这就引来了另一个问题:缓存的最终一致性问题
那么,我们对这个"缓存最终一致性"问题又该如何解决呢?我们先从缓存存在的安全问题引出来解决方案!
3、缓存相关问题
3.1、缓存穿透
查询了一定不存在的数据-缓存穿透
缓存穿透:是指缓存和数据库中都不存在的数据,而用户不断发起请求,如发起查询id=“-1”的数据或id为特别大不存在的数据,这时候用户很可能是攻击者,攻击会导致数据库压力过大
解决方案:
- 接口层增加校验(用户鉴权、id作为基础校验,过滤id为-1的请求)
- 缓存中去不到的数据,也写入缓存中,以key-null形式保存(缓存时间设置短一些,设置太长会导致正常情况也无法使用),这样可以防止用同一个id暴力攻击
- **布隆过滤器:具体看大佬的文章
3.2、缓存雪崩
缓存中不同数据大批量过期-缓存穿透
缓存雪崩:是指缓存中数据大批量过期而且查询量巨大,引起数据库压力巨大,甚至宕机,与缓存击穿不同,雪崩是查询不同的数据都过期了
解决方案:
- 随机时间:在原有的过期时间的基础上,加上一个随机的时间,防止同一时间数据大批量过期
- 永不过期缓存:在情况允许的情况下,设置缓存数据永不过期
- redis高可用:预防redis宕机导致雪崩问题
- 限流降级:通过加锁和队列的方式进行对数据库的读取和写缓存,例如:对某个key只允许一个线程查询数据库,其他线程等待
- 数据预热:在正式部署之前,先把数据访问一遍加入缓存,设置不同的过期时间,让缓存失效的时间点尽量均匀
3.3、缓存击穿
热点key刚好失效-缓存击穿
缓存击穿:一个热点key在某个时间点失效的情况下,有大批量线程去查询该key,导致大批量线程去查询数据库,引起数据库压力巨大,甚至宕机
解决方案:
- 互斥锁:在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,等到第一个线程将数据写入缓存后,其他线程直接走缓存
- 分布式锁:在分布式场景下,本地互斥锁不能保证只有一个线程去查询数据库,也可以使用分布式锁去避免击穿问题
- 热点数据不过期:直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存
关于缓存击穿,我们如何选定方案呢?
本质上我们是在并发场景很高的情况下,通过降低访问数据库的线程并发量,来达到避免缓存击穿的问题出现。
互斥锁VS分布式锁:
我们很多时候是通过集群部署多个相同的服务,本地互斥锁虽然不能严谨控制单个线程查询数据库,但是我们的目的是降低并发量,只要保证走到数据库的请求能大大降低即可,所以还有另一个思路是 JVM 锁,当然如果要保证缓存最终一致性的场景,我们还是需要用到分布式锁作为最终解决方案的!
JVM 锁保证了在单台服务器上只有一个请求走到数据库,通常来说已经足够保证数据库的压力大大降低,同时在性能上比分布式锁更好。
值得注意的是:无论是使用“分布式锁”,还是“JVM 锁”,加锁时要按 key 维度去加锁。
使用固定的key值加锁,这样会导致不同的 key 之间也会互相阻塞,造成性能严重损耗。
2、击穿解决方案-锁的应用
综合上面的结果,我们的redis缓存虽然提升了性能,但是在一些特殊场景下,仍会存在一些问题(缓存击穿与数据最终一致性)。
我们了解到分布式锁是可以通过单个线程访问数据库资源,解决上面两个问题的,那么我们接下来讨论一下“锁”相关的应用。
1、本地锁(包括JUC包下)
在我们引入解决方案之前,我们先看一个例子:
/**
* 获取真实数据
*
* @return
*/
public NeedLockDataVO getDataVO() {
String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference(){});
// 如果有,就返回
if (cacheDataVO != null) {
return cacheDataVO;
}
// 如果没有,获取到db中数据
NeedLockDataVO needLockDataVO = doGetNeedLockDataVO();
// 存入redis
cache.opsForValue().set("cacheDataVO", JSON.toJSONString(needLockDataVO));
return needLockDataVO;
}
private NeedLockDataVO doGetNeedLockDataVO() {
// 数据本地加锁
synchronized (this){
// TODO 繁琐业务逻辑代码
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
return new NeedLockDataVO();
}
}
假设我们实例,交由Spring来管理,this获取的是同一个
不知道我们有没有发现以下问题:
- 缓存的读取与存入均不在锁内,即便是单体服务器,并发情况下都会出现宕机风险问题。
- 加锁是在本地,多个服务器下,仍然会有多个线程去访问数据库,缓存数据一致性仍然得不到解决
我们对“1”的问题,只需要在进入锁之后查一遍缓存即可。
代码片段更改如下:
private NeedLockDataVO doGetNeedLockDataVO() {
synchronized (this){
// 再次查询缓存,预防宕机风险
String jsonObjectStr = cache.opsForValue().get("cacheDataVO");
NeedLockDataVO cacheDataVO = JSONObject.parseObject(jsonObjectStr, new TypeReference(){});
// 如果有,就返回
if (cacheDataVO != null) {
return cacheDataVO;
}
// TODO 繁琐业务逻辑代码
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
return new NeedLockDataVO();
}
}
我们对“2”的问题,如何做一个分布式的锁来解决当前的隐患问题呢?
2、分布式锁
什么是?
当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问。
分布式解决方案
针对分布式锁的实现,目前比较常用的有以下几种方案:
- 基于数据库实现分布式锁
- 基于缓存(redis,memcached,tair)实现分布式锁
- 基于Zookeeper实现分布式锁
我们着重讨论一下基于缓存的分布式锁演进实现:
阶段一
public Map> getCatalogJsonFromDbWithRedisLock() {
//1:占分布式锁。去redis占坑
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock){
//加锁成功。。。执行业务
Map> dataFromDB = getDataFromDB();
stringRedisTemplate.delete("lock");//删除锁
return dataFromDB;
}else {
//加锁失败。。。。重试
//休眠一百毫秒重试
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
问题:
- 如果我们现在在获取到锁以后,执行业务出现了异常,会导致锁没有释放,造成死锁
原因:加锁和解锁过程互不影响,不会整体回滚,没有对出现异常后锁做处理
解决方案:
- 为锁指定过期时间,到期自动解锁
阶段二
public Map> getCatalogJsonFromDbWithRedisLock() {
//1:占分布式锁。去redis占坑
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock){
//加锁成功。。。执行业务
//2:设置过期时间
stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
Map> dataFromDB = getDataFromDB();
stringRedisTemplate.delete("lock");//删除锁
return dataFromDB;
}else {
//加锁失败。。。。重试
//休眠一百毫秒重试
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
问题:
- 同样是如果因为异常原因,导致过期时间没有设置执行,造成死锁
原因:加锁和设置过期时间侧操作不是原子性
解决方案:
我们可以使用SET key value [EX seconds],保证加锁和过期时间设置的原子性
阶段三
public Map> getCatalogJsonFromDbWithRedisLock() {
//1:占分布式锁。去redis占坑
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111",30, TimeUnit.SECONDS);
if (lock){
//加锁成功。。。执行业务
//2:设置过期时间
// stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
Map> dataFromDB = getDataFromDB();
stringRedisTemplate.delete("lock");//删除锁
return dataFromDB;
}else {
//加锁失败。。。。重试
//休眠一百毫秒重试
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
问题:
- 业务超时发现锁已经到期自动删除了,没锁可以删除了,怎么办?
- 业务用时很长,锁自动过期后,我们把别人的锁删除了,怎么办?其他的线程又进来怎么办?
原因:基于性能和网络的综合原因,我们不能保证超时时间永远小于过期时间,业务超时时间过长,会导锁混乱,甚至达不到加锁的目的。
解决方案:
我们要保证删除锁的时候,我们不可以删除别人的锁
阶段四
public Map> getCatalogJsonFromDbWithRedisLock() {
//1:占分布式锁。去redis占坑
String uuid= UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);
if (lock){
//加锁成功。。。执行业务
//2:设置过期时间
// stringRedisTemplate.expire("lock",30, TimeUnit.SECONDS);
Map> dataFromDB = getDataFromDB();
//获取值对比和对比成功删除一定要是一个原子操作
String lockValue = stringRedisTemplate.opsForValue().get("lock");
if (uuid.equals(lockValue)){
stringRedisTemplate.delete("lock");//删除锁
}
return dataFromDB;
}else {
//加锁失败。。。。重试
//休眠一百毫秒重试
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
问题:
- 我们在做
uuid.equals(lockValue)
之后,由于网络原因导致超时,还没有删除锁之前,其他线程更改了锁,导致我们虽然觉得是自己的值,删除的还是别人的锁,又会有很多线程进来抢占锁。 - 业务用时很长,锁自动过期后,我们把别人的锁删除了,怎么办?其他的线程又进来怎么办?
原因:删除锁没能保证原子性
解决方案:
保证删除锁的时候的原子性
阶段五
public Map> getCatalogJsonFromDbWithRedisLock() {
//1:占分布式锁。去redis占坑
String uuid= UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);
if (lock){
//加锁成功。。。执行业务
Map> dataFromDB = getDataFromDB();
String script="if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
//原子删除锁
Integer lock1 = stringRedisTemplate.execute(new DefaultRedisScript(script, Integer.class), Arrays.asList("lock"), uuid);
return dataFromDB;
}else {
//加锁失败。。。。重试
//休眠一百毫秒重试
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
问题:
- 仍然没有解决锁过期了的问题
原因:业务超时,还没有删除锁,锁就过期了,咋办?
解决方案:
加长锁时间
阶段6
public Map> getCatalogJsonFromDbWithRedisLock() {
//1:占分布式锁。去redis占坑
String uuid= UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid",30, TimeUnit.SECONDS);
if (lock){
//加锁成功。。。执行业务
Map> dataFromDB;
try {
dataFromDB = getDataFromDB();
}finally {
String script="if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
//原子删除锁
Integer lock1 = stringRedisTemplate.execute(new DefaultRedisScript(script, Integer.class), Arrays.asList("lock"), uuid);
}
return dataFromDB;
}else {
//加锁失败。。。。重试
//休眠一百毫秒重试
return getCatalogJsonFromDbWithRedisLock();//自旋方式
}
}
分布式锁总结:
- 设置足够长的过期时间
- 加锁和过期时间必须是原子性操作
- 删除锁也必须是原子性操作
3、Redisson——分布式中的JUC
在我们了解了分布式锁的演进过程后,能解决一般的场景问题,但是遇到一些复杂的场景,我们需要更高级的分布式锁,怎么办呢?Redis为我们提供了一站式解决方案——Redisson(Distributed locks with Redis)
Rediosson是什么:Redisson是一个在Redis的基础上实现的Java驻内存数据网格
Redisson官方地址
Redisson(中|英文)文档链接
所有用法,我们均可翻阅Redisson文档
4、Redisson 开始
配置-参考中文文档
/**
* @author lishanbiao
* @Date 2021/11/22
*/
@Configuration
public class MyRedissonConfig {
@Bean(destroyMethod="shutdown")
RedissonClient redisson() throws IOException {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
测试
@Autowired
private Redisson redisson;
/**
* hello world
*/
@RequestMapping("/delete")
// @RequiresPermissions("coupon:coupon:delete")
public String helloWorld(@RequestBody Long[] ids) {
// 获取锁
RLock lock = redisson.getLock("my-lock");
// 加锁 阻塞式等待……
lock.lock();
try {
System.out.println("加锁成功,执行业务……" + Thread.currentThread().getId());
Thread.sleep(3000);
} catch (Exception e) {
} finally {
// 解锁
System.out.println("释放锁……" + Thread.currentThread().getId());
lock.unlock();
}
return "hello";
}
思考:程序删除之前终端,会不会有死锁问题呢?
测试会发现,并不会(自己动手实践)。
原因:
翻看文档会发现,业务超长执行期间,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
值得注意的是:如果为锁指定了时间,则会关闭看门狗功能,业务超长后,删除锁的程序就会报错
读写锁
这些我只写一些特性(具体请翻阅Redisson文档):
只要有写锁的存在都必须等待
- 读 + 读:相当于无锁
- 读 + 写:写等待读锁,读完后执行
- 写 + 读:读等待
- 写 + 写:写等待
双写模式——写数据库的同时,去更新缓存
失效模式——写数据库的同时去删除缓存,等待下一次读取
我们根据上面两张图可以看出:
无论我们哪种模式,都会存在数据不一致的问题,但是我们可以怎么办?
- 如果用户纬度数据(用户不可能一会儿加单,一会儿删单),并发机率非常小,不用考虑这个问题,加上过期时间,只需要隔一段时间主动查询数据库即可
- 如果是目录,商品介绍等基础数据,对业务产生不了大影响,允许缓存的不一致,若想要考虑可以使用:cananl订阅的方式
- 缓存数据+过期时间:可以保证大部分的需求
- 通过加锁并发读写,适合于写少读多的特点
总结:
- 我们放入缓存的数据不应该是实时性、一致性要求超高的数据
- 不应该过度设计,增加系统的复杂性
- 遇到实时性要求高的数据,我们应该查数据库,即使慢一些
本人只做汇总,以上所有来自各个大佬们