赞
踩
发现维护的项目中有个用户登录时间的缓存没有设置过期时间,导致产线环境的 Redis 中存在大量永不过期的废弃 Key 。
KEYS
命令虽然可以批量查询匹配的缓存 Key ,但是数据量大时会非常慢,而且很容易造成服务器卡顿,进而影响其它正常的请求。所以产线环境中一般会禁用这个命令,以防止开发人员误操作。
这里可以采用 SCAN
命令来实现类似的效果。关于 SCAN
命令的详细说明可以参考官方文档
。
命令格式如下:
- SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
- 复制代码
命令示例:
- SCAN 0 MATCH user_login_time_* COUNT 100
- 复制代码
该命令会返回两个值:
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
- }
- 复制代码
下面是一个使用 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 属性)
这几个值也并不是越大越好,可以多调整几次,对比下运行效果。
这个是调整后的一次运行结果,比没有批量操作时性能要高很多很多。
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
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。