RedisTemplate实现scan扫描keys

在生产环境中,redis需要大量扫描keys时一般都使用scan命令执行。我每次需要用scan命令时都忘记怎么写,这里记录一下笔记

#

大家都知道redis中的keys 命令不能在生产环境乱用。因为keys命令在执行时首先会对redis内存加锁,再遍历整个库中的key获取需要的库。虽然效率很高,但生产环境如果用keys命令,很有可能导致出现线上事故。因此一般都使用scan命令替代

我每次需要用scan命令时都忘记怎么写,这里记录一下笔记

# SCAN命令

SCAN命令是基于游标的迭代器,通过不断调用迭代器来进行查找过程。

例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 搜索"user_token*" 每次返回5个
redis > scan 0 match user_token* count 5 
 1) "6"
 2) 1) "user_token:1000"
 2) "user_token:1001"
 3) "user_token:1010"
 4) "user_token:2300"
 5) "user_token:1389"

 redis > scan 6 match user_token* count 5 
 1) "10"
 2) 1) "user_token:3100"
 2) "user_token:1201"
 3) "user_token:1410"
 4) "user_token:5300"
 5) "user_token:3389"

# 注意项

SCAN是增量式遍历,每次迭代返回时都只是当前时刻的元素。因此有如下特性:

  • 如果有一个元素, 它从遍历开始直到遍历结束期间都存在于被遍历的数据集当中, 那么 SCAN 命令总会在某次遍历中将这个元素返回给用户。
  • 如果有一个元素, 它从遍历开始就已经被删除,且直到遍历结束也没有被添加回来, 那么 SCAN 命令确保不会返回这个元素。
  • 同一个元素可能会被返回多次。 处理重复元素的工作交由应用程序负责, 比如说, 可以考虑将遍历返回的元素放入Set中
  • 如果一个元素是在遍历过程中被添加到数据集的, 又或者是在遍历过程中从数据集中被删除的, 那么这个元素可能会被返回, 也可能不会, 这是不确定的。

因此,

  • 如果针对变动量较少的key集合,SCAN命令的结果可以视为等价于keys命令的结果
  • 如果针对不断变动的key集合,SCAN命令的结果可能相比于实际上这个瞬间的key集合有多或少,并且可能重复。需要在后续的程序中处理这个问题

# Redistemplate使用SCAN

使用Java的Spring框架中的模块spring-data-redis,可以方便的使用封装好的类RedisTemplate进行redis操作。

# Spring Boot 3以前

Spring Boot 3.3之前,Redistemplate中不支持直接使用SCAN命令。RedisTemplate是对org.springframework.data.redis.connection这个类的封装。因此推荐使用redisTemplate直接操作connection中的scan方法进行SCAN操作

例子,注:pattern为需要扫描的key的表达式,如business_key*

 1
 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

// 写法1
public Set<String> scan(String pattern) {
    return redisTemplate.execute((RedisCallback<Set<String>>) connection -> {
        Set<String> keysTmp = new HashSet<>();
        Cursor<byte[]> cursor = connection.scan(new ScanOptions.ScanOptionsBuilder().match(pattern).count(1000).build());
        while (cursor.hasNext()) {
            keysTmp.add(new String(cursor.next()));
        }
        return keysTmp;
    });
}

// 写法2
public Set<String> scanKeys(String pattern) {
    Set<String> keys = new HashSet<>();
    this.redisTemplate.execute((RedisConnection connection) -> {
        try (Cursor<byte[]> cursor = connection.scan(ScanOptions.scanOptions().match(pattern).build())) {
            cursor.forEachRemaining(item -> {
                String key = new String(item, StandardCharsets.UTF_8);
                keys.add(key);
            });
            return null;
            }
        });
        return keys;
    }

注:

  • 如果不使用redieTemplate.execute,一定要主动执行关闭cursor.close()。否则可能导致连接一直保持,耗尽资源
  • 使用redisTemplate.execute会自动进行cursor.close()关闭操作

# Spring Boot 3.3 之后

Spring Boot 3.3之后,connection.scan被标为过时方法,且RedisTemplate支持了scan操作。因此虽然旧写法不能用了,但操作scan更方便了。同样,我们还可以使用try-with-resource语法让整个写法更优雅

例子

1
2
3
4
5
6
7
public Set<String> scanKeys(String pattern) {
    Set<String> keys = new HashSet<>();
    try(Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(1000).build())){
        cursor.forEachRemaining(keys::add);
    }
    return keys;
}
Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计