夜间模式
缓存击穿
缓存击穿是缓存雪崩的青春版本,一部分key失效了所造成的问题。
但是这一部分的key不是普通的key,是热点key,即被高并发、高访问量的key。
所以缓存击穿又被叫做热点Key问题,当某些高并发、高访问量的key失效并且缓存重建业务复杂在某个时刻失效了,那么大量的请求打在数据库上可能造成程序崩溃。
解决缓存击穿的方法
使用互斥锁:在缓存失效时,使用互斥锁控制对数据库的访问,防止大量并发请求同时查询数据库。
详细时序图
简略时序图
这样一来,只会有一个线程在操作数据库,其他的线程在等待缓存构建完成,通过控制并发请求对数据库的访问,防止数据库压力骤增。,且保证了数据一致性,因为同一时间只有一个线程操作数据库
热点数据预热:在缓存失效前,提前将热点数据重新加载到缓存中,避免缓存过期。
永不过期策略:对极为重要的热点数据,设置缓存永不过期,并在后台定期更新。
此类数据适合访问频率非常高且变化不频繁的数据。与需要长期保留且不经常变化的配置信息。
二级缓存:在本地内存中缓存热点数据,作为Redis缓存的二级缓存,减少对数据库的直接访问。
逻辑过期策略:逻辑过期策略是在缓存中设置一个过期时间,当数据超过过期时间后,被认为是过期的,但数据仍然保留在缓存中,等待后台异步更新。
key永不过期,可以设置逻辑过期
逻辑过期:在原有的字段加上有效期,服务器读取key时判断逻辑过期时间是否过期,过期了加上锁进行异步刷新缓存,在此期间所有请求都将返回旧数据而不是缓存回询,不会造成线程堵塞,对一致性的要求不高
通过异步更新机制,尽量保证缓存中的数据与数据库一致。不会因为单个数据过期而导致大量数据失效。适用于数据更新频率较高,且对数据一致性要求较高的场景。
详细时序图
简略时序图
逻辑过期和互斥锁方案的区别
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 没有额外内存消耗 保证了一致性 | 线程需要等待,可能会堵塞 死锁风险 |
逻辑过期 | 线程无需等待,性能优异 | 不能保证一致性 需要多存储一些信息(逻辑过期信息) 有额外内存消耗 |
- 互斥锁用于对一致性敏感的数据
- 逻辑过期用于数据一致性不敏感的情况,性能要求
互有优劣,各自有应用场景。
实现
使用 Java 使用 实现缓存击穿解决方案
互斥锁
借助Redis的SetNx
,当不存在Key时才能被设置,即只能被一个线程设置值,其他线程设置时已存在,设置失败不会被覆盖返回 0 。
释放锁,删除key
java
public Shop queryWithPassMutex(Long id){
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在 NULL不成立
if (StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
// return JSONUtil.toBean(shopJson,Shop.class);
return shop;
}
//店铺为空时
if (shopJson != null){
return null;
}
//如果缓存失效
Shop shop = null;
String LockKey = RedisConstants.LOCK_SHOP_KEY + id;
try {
//加锁成功返回True
//失败返回 false
boolean isLock = tryLock(LockKey);
if (!isLock) {
//有锁
Thread.sleep(50);
return queryWithPassMutex(id);
}
//成功获取锁,进行数据的获取
shop = getById(id);
//解决缓存穿透
//对象为空
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// return Result.fail("店铺不存在");
return null;
}
//缓存雪崩的随机时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL + RandomUtil.randomLong(5, 30), TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
//释放锁
unLOCK(LockKey);
}
return shop;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
互斥锁的设置与释放
java
/**
*
* 设置互斥锁
* @param key
* @return
*/
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);//直接返回会拆箱有空值
}
/**
* 释放锁
* */
private void unLOCK(String key){
stringRedisTemplate.delete(key);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
逻辑时间过期
先实现 Redis 逻辑时间的工具类
java
@Data
@Builder
public class RedisData {
private LocalDateTime expireTime;//过期时间
private Object data; //对象泛型
}
1
2
3
4
5
6
2
3
4
5
6
异步查询请求
java
/**
*
* 异步请求查询
* @param id
* @param expireSecond
*/
private void saveShop2Redis(Long id, Long expireSecond){
Shop shop = getById(id);
//添加过期时间
RedisData redisData = RedisData.builder()
.expireTime(LocalDateTime.now().plusSeconds(expireSecond))
.data(shop)
.build();
//存入数据库
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
个人书写逻辑版本
java
public Shop queryWithLogicalExpire(Long id){
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StrUtil.isBlank(shopJson)){
// 如果不存在直接返回
return null;
}
//反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = (Shop) redisData.getData();
//查看过期时间是否过期
if(LocalDateTime.now().isAfter(redisData.getExpireTime())){
//已过期 上锁
boolean lock = tryLock(RedisConstants.LOCK_SHOP_KEY + id);
// 上锁成功
if (lock){
try {
Thread thread = new Thread(() -> {
//重建缓存
saveShop2Redis(id, RedisConstants.CACHE_SHOP_TTL + RandomUtil.randomLong(5, 30));
});
thread.start();
}finally {
unLOCK(RedisConstants.LOCK_SHOP_KEY + id);
}
}
}
return shop;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
这个方法有种缺点,线程频繁创建与销毁,序列化有更好的解决办法
首先建立线程池
JAVA
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
1
重写方法
java
public Shop queryWithLogicalExpire(Long id){
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StrUtil.isBlank(shopJson)){
// 如果不存在直接返回
return null;
}
//反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
//未过期返回
if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
return shop;
}
//获取互斥锁
boolean lock = tryLock(RedisConstants.LOCK_SHOP_KEY + id);
if (lock){
// 获取成功
CACHE_REBUILD_EXECUTOR.submit(()->{
//重建缓存
try {
this.saveShop2Redis(id, RandomUtil.randomLong(5, 30));
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
unLOCK(RedisConstants.LOCK_SHOP_KEY + id);
}
});
}
return shop;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36