Redis-缓存

Redis

Redis 介绍

Redis 全称 Remote Dictionary Server,即远程字典服务器。是一个基于内存的键值型NoSQL数据库,支持多种数据结构,如字符串、哈希、列表、集合、有序集合等。Redis 是一个高性能的 key-value 存储系统,常用于缓存、消息队列、排行榜等场景。

Redis 特点

  • 键值存储:Redis 是一个键值存储系统,支持多种数据结构,如字符串、哈希、列表、集合、有序集合等。
  • 单线程:Redis 是单线程的,通过非阻塞 I/O 和事件驱动机制来实现高并发,每个命令具备原子性。
  • 低延迟:Redis 通过将数据存储在内存中来实现低延迟。
  • 持久化:Redis 支持 RDB 和 AOF 两种持久化方式,可以将内存中的数据持久化到磁盘中。
  • 支持集群:Redis 支持主从复制、哨兵和集群等功能。
  • 支持多语言:Redis 支持多种语言的客户端,如 Java、Python、Node.js 等。

Redis 安装

官网下载地址:https://redis.io/download

Redis 命令

1
redis-server redis.conf

Redis 缓存

缓存作用模型

alt text

缓存更新策略

alt text

主动更新策略

  1. 由缓存的调用者,在更新数据库的同时,更新缓存。
  2. 将缓存和数据库整合为一个服务,由服务来维护统一性。
  3. 调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库。

一般采用第一种方案,即在更新数据库的同时,更新缓存。

方案二会导致缓存和数据库的耦合度过高,不利于系统的扩展和维护。

方案三会可能导致缓存数据和数据库数据不一致,不利于数据的一致性。

缓存更新策略的最佳实践方案

  1. 低一致性需求:使用Redis自带的内存淘汰机制
  2. 高一致性需求:主动更新,并以超时剔除作为兜底策略

alt text

  • 读操作
    • 缓存命中则直接返回
    • 缓存未命中则查询数据库,并写入缓存,设定超时时间
  • 写操作
    • 先写数据库,再删除缓存
    • 要确保数据库与缓存操作的原子性

alt text

ps. 缓存为快速操作,可能会在数据库写入之前读取到脏数据,造成线程安全问题;之所以不选择更新缓存,因为更新操作相比删除操作,会带来更多无用操作。

缓存穿透

缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会命中,导致请求直接访问数据库,增加数据库的压力。

解决方案

alt text

  1. 缓存空对象
  • 实现简单, 维护成本低
  • 额外的内存开销(可以设置较短的过期时间)
  • 可能造成短期的不一致(当有新的数据插入时,造成数据不一致)
  1. 布隆过滤器
  • 一个很长的二进制向量和一系列随机映射函数
  • 内存占用少,没有多余key
  • 有一定的误判率(不存在是真的不存在,存在是可能存在)
  1. 增强id复杂度
  • 对id进行加密,增加复杂度
  • 一定程度上可以防止穿透
  1. 限流
  • 限制请求频率,防止攻击

一般在开发中,会使用第一种方案,即缓存空对象。

流程图变化:

alt text

缓存雪崩

缓存雪崩是指缓存中的大量数据同时过期或者Redis服务宕机,导致请求直接访问数据库,增加数据库的压力。

解决方案

  1. 缓存数据过期时间随机
  • 给不同的key的TTL设置随机值,避免大量key同时过期
  1. 多级缓存

  2. Redis集群

  • 通过主从复制和哨兵机制,保证Redis的高可用性
  1. 限流

alt text

缓存击穿

alt text

缓存击穿问题也叫热点Key问题,是指一个被高并发访问且缓存重建业务较复杂的Key突然失效,导致大量请求瞬间访问数据库,增加数据库的压力。

解决方案

  1. 互斥锁
  • 在缓存失效的时候,使用互斥锁,只允许一个线程访问数据库,其他线程等待
  • 适用于缓存失效后,数据重建时间较短的情况
  1. 逻辑过期
  • 设置一个逻辑上不会过期的缓存,当逻辑过期时,重新设置缓存
  • 某个线程设置缓存时,其他线程直接得到缓存数据,不考虑缓存是否过期

alt text

alt text

使用互斥锁来解决:

alt text

代码实现:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@Override
public Result queryById(Long id) {
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("商铺不存在");
}

return Result.ok(shop);
}

// 互斥锁解决缓存击穿
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);

// 2. 如果缓存中有数据,直接返回
if (StrUtil.isNotBlank(shopJson)) { // 是真实商铺数据才会进入if返回数据
return JSONUtil.toBean(shopJson, Shop.class);
}

// 判断命中的是否为空值
if (shopJson != null) {
// 返回错误信息
return null;
}

// 3. 未命中 实现缓存重建
// 获取互斥锁
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
Shop shop = null;
try {
// 判断是否获取成功
if (!isLock) {
// 未获取到锁,休眠后重试
Thread.sleep(50);
return queryWithMutex(id);
}

// 成功,从数据库中查询
shop = getById(id);

// 模拟重建的延时
Thread.sleep(200);

// 不存在,将空值写入缓存,防止缓存穿透
if (shop == null) {
// 设置空值缓存 过期时间为2分钟
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}

// 4. 将查询到的数据写入缓存 设置过期时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放互斥锁
unlock(LOCK_SHOP_KEY + id);
}

// 5. 返回数据
return shop;
}

使用逻辑过期来解决:

alt text

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);

// 2. 如果缓存中不存在数据,直接返回null
if (StrUtil.isBlank(shopJson)) {
return null;
}

// 3. 命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();

// 4. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 4.1 未过期,直接返回信息
return shop;
}

// 4.2 已过期,需要缓存重建
// 5. 缓存重建
// 5.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);

// 5.2 判断是否获取成功
if (isLock) {
// 再次检查是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,释放锁,直接返回
unlock(lockKey);
return shop;
}
// 过期
// 5.3 获取锁成功,开启独立线程,实现缓存重建(利用线程池)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 缓存重建
this.saveShop2Redis(id, CACHE_SHOP_TTL);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}

// 5.4 未成功,返回商铺信息(过期的数据)
return shop;
}

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
stringRedisTemplate.delete(key);
}

public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. 查询店铺信息
Shop shop = getById(id);
Thread.sleep(200);

// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));

// 3. 写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

封装缓存工具类

代码如下:

主要通过泛型函数式编程来实现缓存工具的封装。

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@Slf4j
@Component
public class CacheClient {

private final StringRedisTemplate stringRedisTemplate;

public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}

public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));

// 保存到redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}

public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback
, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1. 从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);

// 2. 如果缓存中有数据,直接返回
if (StrUtil.isNotBlank(json)) { // 是真实商铺数据才会进入if返回数据
return JSONUtil.toBean(json, type);
}

// 判断命中的是否为空值
if (json != null) {
// 返回错误信息
return null;
}

// 3. 如果缓存中没有数据,从数据库中查询
R r = dbFallback.apply(id);

// 不存在,将空值写入缓存,防止缓存穿透
if (r == null) {
// 设置空值缓存 过期时间为2分钟
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}

// 4. 将查询到的数据写入缓存 设置过期时间
this.set(key, r, time, unit);

// 5. 返回数据
return r;
}

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1. 从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);

// 2. 如果缓存中不存在数据,直接返回null
if (StrUtil.isBlank(json)) {
return null;
}

// 3. 命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(data, type);
LocalDateTime expireTime = redisData.getExpireTime();

// 4. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 4.1 未过期,直接返回信息
return r;
}

// 4.2 已过期,需要缓存重建
// 5. 缓存重建
// 5.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);

// 5.2 判断是否获取成功
if (isLock) {
// 再次检查是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,释放锁,直接返回
unlock(lockKey);
return r;
}
// 过期
// 5.3 获取锁成功,开启独立线程,实现缓存重建(利用线程池)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R r1 = dbFallback.apply(id);
// 写入Redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}

// 5.4 未成功,返回商铺信息(过期的数据)
return r;
}

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}