当前位置:   article > 正文

【Redis x Spring】使用 SCAN 命令搜索缓存_redis scan java

redis scan java

发现维护的项目中有个用户登录时间的缓存没有设置过期时间,导致产线环境的 Redis 中存在大量永不过期的废弃 Key 。

KEYS 命令虽然可以批量查询匹配的缓存 Key ,但是数据量大时会非常慢,而且很容易造成服务器卡顿,进而影响其它正常的请求。所以产线环境中一般会禁用这个命令,以防止开发人员误操作。

这里可以采用 SCAN 命令来实现类似的效果。关于 SCAN 命令的详细说明可以参考官方文档

命令格式如下:

  1. SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
  2. 复制代码

命令示例:

  1. SCAN 0 MATCH user_login_time_* COUNT 100
  2. 复制代码

该命令会返回两个值:

  1. 下一次扫描使用的 cursor
    • 需要注意的是这个 cursor 每次调用并不一定比上次的值大,这个值仅对 Redis 来说有意义;
    • 另外这个值返回 0 表示扫描结束了;
  2. 匹配到的 Key 列表。
    • 并不一定每次扫描都会有匹配到的 Key 值,所以不能用返回的列表是否为空来判断扫描是否结束了;
    • 有可能返回重复的 Key ;

Spring 中的 RedisTemplate 并没有直接提供 SCAN 命令的封装,但还是可以在 RedisConnection 中执行这个命令。

scan() 方法接收一个 ScanOptions 参数,可以指定每次扫描的 Key 数量( count )和匹配的字符( pattern ),返回一个 Cursor<byte[]> 类型的游标

  1. /**
  2. * Use a {@link Cursor} to iterate over keys.
  3. *
  4. * @param options must not be {@literal null}.
  5. * @return never {@literal null}.
  6. * @since 1.4
  7. * @see <a href="https://redis.io/commands/scan">Redis Documentation: SCAN</a>
  8. */
  9. Cursor<byte[]> scan(ScanOptions options);
  10. 复制代码

RedisTemplate 中获取 RedisConnection 并调用 scan() 方法:

  1. RedisConnection connection = stringRedisTemplate.getRequiredConnectionFactory().getConnection();
  2. ScanOptions scanOptions = new ScanOptions.ScanOptionsBuilder()
  3. .count(10000)
  4. .match(CACHE_KEY_PATTERN)
  5. .build();
  6. Cursor<byte[]> cursor = connection.scan(scanOptions);
  7. 复制代码

游标本身就是一个迭代器,使用方法也是一样的。

  1. while (cursor.hasNext()) {
  2. String key = new String(cursor.next());
  3. // do something
  4. }
  5. 复制代码

附1. 完整的示例代码

下面是一个使用 SCAN 命令的示例,用来删除以 user_login_time_ 为前缀的过期缓存(这个缓存中保存了登录的时间戳,正好可以用这个来判断缓存是否过期)。

  1. import org.apache.commons.lang3.StringUtils;
  2. import org.junit.Test;
  3. import org.junit.runner.RunWith;
  4. import org.slf4j.Logger;
  5. import org.slf4j.LoggerFactory;
  6. import org.springframework.beans.factory.annotation.Autowired;
  7. import org.springframework.boot.test.context.SpringBootTest;
  8. import org.springframework.data.redis.connection.RedisConnection;
  9. import org.springframework.data.redis.core.Cursor;
  10. import org.springframework.data.redis.core.ScanOptions;
  11. import org.springframework.data.redis.core.StringRedisTemplate;
  12. import org.springframework.test.context.junit4.SpringRunner;
  13. import java.util.*;
  14. import java.util.concurrent.TimeUnit;
  15. @SpringBootTest
  16. @RunWith(SpringRunner.class)
  17. public class CacheToolTests {
  18. private static final Logger logger = LoggerFactory.getLogger(CacheToolTests.class);
  19. public static final long TIMEOUT = 2 * 24 * 60 * 60 * 1000;
  20. public static final String CACHE_KEY_PATTERN = "user_login_time_*";
  21. @Autowired
  22. private StringRedisTemplate stringRedisTemplate;
  23. private long maxDeleteValue = 0;
  24. private String maxDeleteKey = "";
  25. private int expiredKeyCount = 0;
  26. private int unexpiredKeyCount = 0;
  27. private final Map<String, Long> unexpiredKeyMap = new HashMap<>();
  28. @Test
  29. public void clearExpiredKey() {
  30. long startTs = System.currentTimeMillis();
  31. logger.info("clearExpiredKey start at {}", startTs);
  32. RedisConnection connection = stringRedisTemplate.getRequiredConnectionFactory().getConnection();
  33. ScanOptions scanOptions = new ScanOptions.ScanOptionsBuilder()
  34. .count(10000)
  35. .match(CACHE_KEY_PATTERN)
  36. .build();
  37. Cursor<byte[]> cursor = connection.scan(scanOptions);
  38. List<String> deleteKeys = new ArrayList<>();
  39. List<String> keys = new ArrayList<>();
  40. while (cursor.hasNext()) {
  41. String key = new String(cursor.next());
  42. keys.add(key);
  43. if (keys.size() < 2000) {
  44. continue;
  45. }
  46. multiCheckKey(deleteKeys, keys);
  47. }
  48. multiCheckKey(deleteKeys, keys);
  49. if (deleteKeys.size() > 0) {
  50. stringRedisTemplate.delete(deleteKeys);
  51. deleteKeys.clear();
  52. }
  53. setExpireTime();
  54. long endTs = System.currentTimeMillis();
  55. logger.info("clearExpiredKey end at {} 已删除 {} 个过期Key,现余 {} 个有效 Key,已删除的数值最大的 Key {}:{} - {} 共耗时 {} ms",
  56. endTs,
  57. expiredKeyCount,
  58. unexpiredKeyCount,
  59. maxDeleteKey,
  60. maxDeleteValue,
  61. new Date(maxDeleteValue),
  62. endTs - startTs);
  63. }
  64. private void multiCheckKey(List<String> deleteKeys, List<String> keys) {
  65. List<String> strTimestampList = stringRedisTemplate.opsForValue().multiGet(keys);
  66. if (strTimestampList == null) {
  67. throw new RuntimeException("批量获取缓存值异常");
  68. }
  69. for (int i = 0; i < keys.size(); i++) {
  70. String currentKey = keys.get(i);
  71. String currentValue = strTimestampList.get(i);
  72. // logger.info("key: {}, value: {}", currentKey, currentValue);
  73. if (StringUtils.isEmpty(currentValue)) {
  74. deleteKey(deleteKeys, currentKey);
  75. continue;
  76. }
  77. long currentLoginTimestamp;
  78. try {
  79. currentLoginTimestamp = Long.parseLong(currentValue);
  80. } catch (NumberFormatException ex) {
  81. deleteKey(deleteKeys, currentKey);
  82. continue;
  83. }
  84. long lifetime = System.currentTimeMillis() - currentLoginTimestamp;
  85. if (lifetime > TIMEOUT) {
  86. if (currentLoginTimestamp > maxDeleteValue) {
  87. maxDeleteValue = currentLoginTimestamp;
  88. maxDeleteKey = currentKey;
  89. }
  90. deleteKey(deleteKeys, currentKey);
  91. expiredKeyCount++;
  92. } else {
  93. unexpiredKeyMap.put(currentKey, TIMEOUT - lifetime);
  94. unexpiredKeyCount++;
  95. }
  96. }
  97. keys.clear();
  98. }
  99. private void deleteKey(List<String> deleteKeys, String currentKey) {
  100. deleteKeys.add(currentKey);
  101. if (deleteKeys.size() >= 2000) {
  102. stringRedisTemplate.delete(deleteKeys);
  103. deleteKeys.clear();
  104. }
  105. }
  106. private void setExpireTime() {
  107. if (unexpiredKeyMap.isEmpty()) {
  108. return;
  109. }
  110. try {
  111. stringRedisTemplate.setEnableTransactionSupport(true);
  112. stringRedisTemplate.multi();
  113. for (String key : unexpiredKeyMap.keySet()) {
  114. stringRedisTemplate.expire(key, unexpiredKeyMap.get(key), TimeUnit.MILLISECONDS);
  115. }
  116. stringRedisTemplate.exec();
  117. } catch (Exception ex) {
  118. stringRedisTemplate.discard();
  119. logger.error("批量设置过期时间异常", ex);
  120. } finally {
  121. stringRedisTemplate.setEnableTransactionSupport(false);
  122. }
  123. unexpiredKeyMap.clear();
  124. }
  125. }
  126. 复制代码

由于 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

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家小花儿/article/detail/240403
推荐阅读
相关标签
  

闽ICP备14008679号