Skip to content

Redis x Spring 使用 SCAN 命令搜索缓存

🏷️ Redis Spring Boot

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

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

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

命令格式如下:

bash
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]

命令示例:

bash
SCAN 0 MATCH user_login_time_* COUNT 100

该命令会返回两个值:

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

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

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

java
/**
 * 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() 方法:

java
RedisConnection connection = stringRedisTemplate.getRequiredConnectionFactory().getConnection();
ScanOptions scanOptions = new ScanOptions.ScanOptionsBuilder()
        .count(10000)
        .match(CACHE_KEY_PATTERN)
        .build();
Cursor<byte[]> cursor = connection.scan(scanOptions);

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

java
while (cursor.hasNext()) {
    String key = new String(cursor.next());
    // do something
}

附1. 完整的示例代码

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

java
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