缓存问题总结~【Redis,雪崩,穿透,击穿,Redisson 分布式锁,Spring Cache】

安装 redis

参考:Docker 安装 Redis

使用 redis

1、导入依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2、配置 redis

1
2
3
4
spring:
redis:
host: 192.168.56.10
port: 6379

3、自动注入 StringRedisTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
String catalogJson = ops.get("catalogJson");

// 缓存没有。查询DB,放入缓存
if (catalogJson == null) {
Map<String, List<Catalog2Vo>> categoriesDb = getCategoriesDb();
String toJSONString = JSON.toJSONString(categoriesDb);
ops.set("catalogJson",toJSONString);
return categoriesDb;
}

// 缓存有。直接返回。
Map<String, List<Catalog2Vo>> listMap = JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catalog2Vo>>>() {});
return listMap;

Redis 堆外内存溢出

当进行压力测试时后期后出现堆外内存溢出OutOfDirectMemoryError

产生原因:

1)、springboot2.0以后默认使用lettuce作为操作redis的客户端,它使用netty进行网络通信

2)、lettuce的bug导致netty堆外内存溢出。netty如果没有指定堆外内存,默认使用Xms的值,可以使用-Dio.netty.maxDirectMemory进行设置

解决方案:由于是lettuce的bug造成,不能直接使用-Dio.netty.maxDirectMemory去调大虚拟机堆外内存,治标不治本。
1)、升级lettuce客户端。但是没有解决的
2)、切换使用jedis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>

lettuce和jedis是操作redis的底层客户端,RedisTemplate是再次封装

缓存失效

缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

解决:缓存空对象、布隆过滤器

缓存雪崩
缓存雪崩是指在我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

解决方案:
》缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
》如果缓存数据库是分布式部署,将热点数据均匀分布在不同缓存数据库中。
》设置热点数据永远不过期。
》出现雪崩:降级 熔断
事前:尽量保证整个 redis 集群的高可用性,发现机器宕机尽快补上。选择合适的内存淘汰策略。
事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL崩掉
事后:利用 redis 持久化机制保存的数据尽快恢复缓存

缓存击穿
雪崩、击穿不同的是:
》击穿:并发查同一条数据。缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
》雪崩:不同数据都过期了,很多数据都查不到从而查数据库。

解决方案:
》设置热点数据永远不过期。
》加互斥锁:业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db去数据库加载,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。

解决方案

1)、雪崩:加入随机时间。避免同时失效
2)、穿透:加入空对象+失效时间。
3)、击穿:加排它锁。

本地锁

可以使用,synchronized,或 JUC 下的 lock。
在这里插入图片描述

锁时序问题

下面黑色的锁。结果放入缓存也要放入锁中,保证原子性。否则,可能造成多次查询。
在这里插入图片描述

本地锁在分布式下的问题

关于本地锁,在分布式情况下会出现查询多次的情况。每个微服务都要有缓存服务、数据更新时只更新自己的缓存,造成缓存数据不一致

解决方案:分布式缓存,微服务共用 缓存中间件:redis,zookeeper
在这里插入图片描述

分布式锁,基本原理

相当于厕所占坑。多个人去厕所,一个人占坑,其他人只能排队。等待它蹲完,释放坑位的时候,其他人才能再次占坑。
在这里插入图片描述

分布式锁,阶段1:SET NX

在这里插入图片描述

分布式锁,阶段2:EX

在这里插入图片描述

分布式锁,阶段3:SET NX EX

在这里插入图片描述

分布式锁,阶段4:UUID

在这里插入图片描述

分布式锁,阶段5:LUA

参考:使用Redis的分布式锁

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

在这里插入图片描述

Redisson 简介

redisson 使用文档

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service)Redisson提供了使用Redis的最简单和最便捷的方法。

Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

Redisson 使用

1、导入依赖

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.15.0</version>
</dependency>

2、配置 redisson

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class MyRedisConfig {

// redission通过redissonClient对象使用
// 如果是redis集群,可以配置:config.useClusterServers().addNodeAddress("127.0.0.1:7001", "127.0.0.1:7002");
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() {
Config config = new Config();
// 创建单例模式的配置
config.useSingleServer().setAddress("redis://192.168.1.1:6379");
return Redisson.create(config);
}
}

可重入锁(Reentrant Lock)

A调用B。AB都需要同一锁,此时可重入锁就可以重入,A就可以调用B。不可重入锁时,A调用B将死锁。

基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。

1
2
3
4
5
// 参数为锁名字
RLock lock = redissonClient.getLock("CatalogJson-Lock");//该锁实现了JUC.locks.lock接口
lock.lock();//阻塞等待(默认30s失效时间,执行到三分之一,也就是20s时,会自动续期到30s)
// 解锁放到finally // 如果这里宕机:有看门狗,不用担心
lock.unlock();

大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。

默认情况下,看门狗的检查锁的超时时间是30秒(每到20s就会自动续借成30s,是1/3的关系),也可以通过修改Config.lockWatchdogTimeout来另行指定。
在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 加锁以后10秒钟自动解锁,看门狗不续命
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
...
} finally {
lock.unlock();
}
}
如果传递了锁的超时时间,就执行脚本,进行占锁;
如果没传递锁时间,使用看门狗的时间,占锁。如果返回占锁成功future,调用future.onComplete();
没异常的话调用scheduleExpirationRenewal(threadId);
重新设置过期时间,定时任务;
看门狗的原理是定时任务:重新给锁设置过期时间,新的过期时间就是看门狗的默认时间;
锁时间/3是定时任务周期;

Redisson同时还为分布式锁提供了异步执行的相关方法:

1
2
3
4
RLock lock = redisson.getLock("anyLock");
lock.lockAsync();
lock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = lock.tryLockAsync(100, 10, TimeUnit.SECONDS);

RLock对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Map<String, List<Catalog2Vo>> getCatalogJsonDbWithRedisson() {
Map<String, List<Catalog2Vo>> categoryMap=null;
RLock lock = redissonClient.getLock("CatalogJson-Lock");
lock.lock();
try {
Thread.sleep(30000);
categoryMap = getCategoryMap();
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
return categoryMap;
}
}

读写锁(ReadWriteLock)

基于Redis的Redisson分布式可重入读写锁RReadWriteLock Java对象实现了java.util.concurrent.locks.ReadWriteLock接口。其中读锁和写锁都继承了RLock接口。

分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。

1
2
3
4
5
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();
1
2
3
4
5
6
7
8
9
10
11
12
// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();

总结:读锁(共享锁)、写锁(排它锁)。
在这里插入图片描述

信号量(Semaphore)

在这里插入图片描述

闭锁(CountDownLatch)

在这里插入图片描述

缓存一致性

缓存和数据库一致性。

双写模式
DB更新,缓存也更新。
在这里插入图片描述
失效模式
DB更新,删除缓存。
在这里插入图片描述
解决方案
在这里插入图片描述

我们系统的一致性解决方案:
1、缓存的所有数据都有过期时间,数据过期下一次查询主动更新。
2、读写数据的时候,加上分布式的读写锁。

Spring Cache

【SpringBoot】④SpringBoot与缓存

穿透:缓存空对象。spring.cache.redis.cache-null-values=true
击穿:读缓存,加锁。@Cacheable(sync=true)
雪崩:指定过期时间 1h。spring.cache.redis.time-to-live=3600000
在这里插入图片描述