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);
从 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);
游标本身就是一个迭代器,使用方法也是一样的。
while (cursor.hasNext()) {
String key = new String(cursor.next());
// do something
}
附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();
}
}
由于 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