Redis x Spring 使用 SCAN 命令搜索缓存
🏷️ Redis Spring Boot
发现维护的项目中有个用户登录时间的缓存没有设置过期时间,导致产线环境的 Redis 中存在大量永不过期的废弃 Key 。
KEYS
命令虽然可以批量查询匹配的缓存 Key ,但是数据量大时会非常慢,而且很容易造成服务器卡顿,进而影响其它正常的请求。所以产线环境中一般会禁用这个命令,以防止开发人员误操作。
这里可以采用 SCAN
命令来实现类似的效果。关于 SCAN
命令的详细说明可以参考官方文档。
命令格式如下:
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
命令示例:
SCAN 0 MATCH user_login_time_* COUNT 100
该命令会返回两个值:
- 下一次扫描使用的 cursor 。
- 需要注意的是这个 cursor 每次调用并不一定比上次的值大,这个值仅对 Redis 来说有意义;
- 另外这个值返回 0 表示扫描结束了;
- 匹配到的 Key 列表。
- 并不一定每次扫描都会有匹配到的 Key 值,所以不能用返回的列表是否为空来判断扫描是否结束了;
- 有可能返回重复的 Key ;
Spring 中的 RedisTemplate
并没有直接提供 SCAN
命令的封装,但还是可以在 RedisConnection
中执行这个命令。
scan()
方法接收一个 ScanOptions
参数,可以指定每次扫描的 Key 数量( count )和匹配的字符( pattern ),返回一个 Cursor<byte[]>
类型的游标。
/**
* Use a {@link Cursor} to iterate over keys.
*
* @param options must not be {@literal null}.
* @return never {@literal null}.
* @since 1.4
* @see <a href="https://redis.io/commands/scan">Redis Documentation: SCAN</a>
*/
Cursor<byte[]> scan(ScanOptions options);
2
3
4
5
6
7
8
9
从 RedisTemplate
中获取 RedisConnection
并调用 scan()
方法:
RedisConnection connection = stringRedisTemplate.getRequiredConnectionFactory().getConnection();
ScanOptions scanOptions = new ScanOptions.ScanOptionsBuilder()
.count(10000)
.match(CACHE_KEY_PATTERN)
.build();
Cursor<byte[]> cursor = connection.scan(scanOptions);
2
3
4
5
6
游标本身就是一个迭代器,使用方法也是一样的。
while (cursor.hasNext()) {
String key = new String(cursor.next());
// do something
}
2
3
4
附1. 完整的示例代码
下面是一个使用 SCAN
命令的示例,用来删除以 user_login_time_
为前缀的过期缓存(这个缓存中保存了登录的时间戳,正好可以用这个来判断缓存是否过期)。
import org.apache.commons.lang3.StringUtils;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.*;
import java.util.concurrent.TimeUnit;
@SpringBootTest
@RunWith(SpringRunner.class)
public class CacheToolTests {
private static final Logger logger = LoggerFactory.getLogger(CacheToolTests.class);
public static final long TIMEOUT = 2 * 24 * 60 * 60 * 1000;
public static final String CACHE_KEY_PATTERN = "user_login_time_*";
@Autowired
private StringRedisTemplate stringRedisTemplate;
private long maxDeleteValue = 0;
private String maxDeleteKey = "";
private int expiredKeyCount = 0;
private int unexpiredKeyCount = 0;
private final Map<String, Long> unexpiredKeyMap = new HashMap<>();
@Test
public void clearExpiredKey() {
long startTs = System.currentTimeMillis();
logger.info("clearExpiredKey start at {}", startTs);
RedisConnection connection = stringRedisTemplate.getRequiredConnectionFactory().getConnection();
ScanOptions scanOptions = new ScanOptions.ScanOptionsBuilder()
.count(10000)
.match(CACHE_KEY_PATTERN)
.build();
Cursor<byte[]> cursor = connection.scan(scanOptions);
List<String> deleteKeys = new ArrayList<>();
List<String> keys = new ArrayList<>();
while (cursor.hasNext()) {
String key = new String(cursor.next());
keys.add(key);
if (keys.size() < 2000) {
continue;
}
multiCheckKey(deleteKeys, keys);
}
multiCheckKey(deleteKeys, keys);
if (deleteKeys.size() > 0) {
stringRedisTemplate.delete(deleteKeys);
deleteKeys.clear();
}
setExpireTime();
long endTs = System.currentTimeMillis();
logger.info("clearExpiredKey end at {} 已删除 {} 个过期Key,现余 {} 个有效 Key,已删除的数值最大的 Key {}:{} - {} 共耗时 {} ms",
endTs,
expiredKeyCount,
unexpiredKeyCount,
maxDeleteKey,
maxDeleteValue,
new Date(maxDeleteValue),
endTs - startTs);
}
private void multiCheckKey(List<String> deleteKeys, List<String> keys) {
List<String> strTimestampList = stringRedisTemplate.opsForValue().multiGet(keys);
if (strTimestampList == null) {
throw new RuntimeException("批量获取缓存值异常");
}
for (int i = 0; i < keys.size(); i++) {
String currentKey = keys.get(i);
String currentValue = strTimestampList.get(i);
// logger.info("key: {}, value: {}", currentKey, currentValue);
if (StringUtils.isEmpty(currentValue)) {
deleteKey(deleteKeys, currentKey);
continue;
}
long currentLoginTimestamp;
try {
currentLoginTimestamp = Long.parseLong(currentValue);
} catch (NumberFormatException ex) {
deleteKey(deleteKeys, currentKey);
continue;
}
long lifetime = System.currentTimeMillis() - currentLoginTimestamp;
if (lifetime > TIMEOUT) {
if (currentLoginTimestamp > maxDeleteValue) {
maxDeleteValue = currentLoginTimestamp;
maxDeleteKey = currentKey;
}
deleteKey(deleteKeys, currentKey);
expiredKeyCount++;
} else {
unexpiredKeyMap.put(currentKey, TIMEOUT - lifetime);
unexpiredKeyCount++;
}
}
keys.clear();
}
private void deleteKey(List<String> deleteKeys, String currentKey) {
deleteKeys.add(currentKey);
if (deleteKeys.size() >= 2000) {
stringRedisTemplate.delete(deleteKeys);
deleteKeys.clear();
}
}
private void setExpireTime() {
if (unexpiredKeyMap.isEmpty()) {
return;
}
try {
stringRedisTemplate.setEnableTransactionSupport(true);
stringRedisTemplate.multi();
for (String key : unexpiredKeyMap.keySet()) {
stringRedisTemplate.expire(key, unexpiredKeyMap.get(key), TimeUnit.MILLISECONDS);
}
stringRedisTemplate.exec();
} catch (Exception ex) {
stringRedisTemplate.discard();
logger.error("批量设置过期时间异常", ex);
} finally {
stringRedisTemplate.setEnableTransactionSupport(false);
}
unexpiredKeyMap.clear();
}
}
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
由于 Key 比较多,为了提高效率,这里使用到了批量获取(MGET)、删除(DEL 命令本身就支持批量操作)和设置过期时间(EXPIRE - 通过启用 multi 事务实现批量操作),以减少访问 Redis 服务器的次数。
这段代码中如下几个数值会影响访问 Reids 服务器的次数:
- 扫描的数量(即
ScanOptions
中的 count 属性)- 这个值表示每次扫描的 Key 的数量,并不是每次返回的 Key 的数量。
- 假设有 100 万个 Key, 每次扫描 1 万个,则总共要访问 Redis 服务器 100 次。
- 批量获取和删除的 Key 的数量(即每多少个 Key 执行一次批量操作)
这几个值也并不是越大越好,可以多调整几次,对比下运行效果。
这个是调整后的一次运行结果,比没有批量操作时性能要高很多很多。
clearExpiredKey end at 1665284888618 已删除 547791 个过期Key,现余 2744 个有效 Key,已删除的数值最大的 Key user_login_time_9754d871-c5ce-4867-a5b9-94c9ad5fbe2dminiapp:1665112037767 - Fri Oct 07 11:07:17 CST 2022 共耗时 39871 ms