七的博客

Redis SCAN批量大小对性能的影响

调优

Redis SCAN批量大小对性能的影响

1. 背景

项目中会有一些功能,需要去遍历 Redis 中的一批 Key 的信息,然后通过这一批 Key 去查询对应的数据。

通常就会有 2 种做法:

  • 使用 KEYS 命令一次性返回所有匹配的 Key。因为 Redis 的 IO 线程是单线程,所以如果 KEYS 处理大数据量时,其他客户端请求需要排队等待处理。
  • 使用 SCAN 命令分批去匹配符合条件的 Key 。 SCAN 命令是非阻塞的,因为在每次调用时只返回一部分结果。这样就可以避免服务器阻塞,可以快速处理其他客户端的请求。

在实际生产环境中,大批 Key 遍历时会优先选择 SCAN 命令,这样不会影响其他业务功能的正常执行。

SCAN 命令是 Redis 中用于遍历集合的一种非阻塞迭代器,用法:

SCAN cursor [MATCH pattern] [COUNT count]
  • cursor: 游标。遍历开始时会设置为 0,每次调用后返回新的游标值,直到返回 0 时表示遍历结束。
  • MATCH pattern: 这是个可选参数,用于匹配特定模式的 Key。
  • COUNT count: 也是个可选参数。 参数是建议每次返回的元素数量,并不保证返回的数量。

举个用法小例子:

127.0.0.1:6379> SCAN 0 MATCH user:* COUNT 10

上面的这个例子就是遍历所有以 【user: 】开头的 Key,每次返回的元素数量控制的是 10。

到了实际的项目环境中, SCAN 命令每一批遍历多少 KEY 比较合适,这个值需要进行测试后才能得到比较准确的数据。

不过测试的目标就是:

  • 确保 Redis 的 CPU 和内存使用稳定,不会出现异常高的资源使用。
  • 访问速度快。
  • 减少通信次数。

2.测试环境准备

  • 客户端: Ubuntu + 百兆带宽 + Java应用
  • 服务端: 云 Linux 云服务器 + 下行20M带宽
  • 地址位置:客户端跟服务端地理位置接近,平均延时 9ms 左右。
  • Redis: Redis5 + 20 万左右的 key数据。
  • 测试代码: JDK8 + Spring Redis Template 。

注意: 生产环境下应用跟 Redis 通常位于同一区域进行内网通信,不会出现跨公网通信情况。这里只是为了演示 Scan count 对性能的影响

2. 测试步骤

  • 往 Redis 中写入 20万条数据,Key 的名称前缀为 TEST_KEY:xxxxxxxxxxx,这里的 xxx 可以用自增的ID代替即可。
  • 通过编写 Java 代码,连接 Redis 进行 Scan 操作读取 20 万条数据 Key。
  • 尝试切换不同的 Scan count 数值,记录耗时时间。
  • 每个 Scan count 重复测试 5 次,读取时间取 5 次时间的平均值。 这样可以避免单次时间过大或者过小引起的数据误差。

3. 测试代码

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.util.StopWatch;

import java.util.ArrayList;
import java.util.List;


public class ScanKeyPerformanceTest {
    // Redis key 的前缀
    private static final String REDIS_KEY_PREFIX = "TEST_KEY";
    
    // Redis scan 每一批的大小
    private static final int SCAN_BATCH_SIZE = 5000;

    private final RedisTemplate<String, Object> redisTemplate;

    public ScanKeyPerformanceTest(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public static void main(String[] args) {
        final AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(RedisConfig.class);
        final RedisTemplate<String, Object> redisTemplate = context.getBean(RedisTemplate.class);

        // 执行测试方法
        final ScanKeyPerformanceTest test = new ScanKeyPerformanceTest(redisTemplate);
        test.testScanRedisKey();
    }

    public void testScanRedisKey() {
        redisTemplate.execute((RedisConnection connection) -> {
            ScanOptions scanOptions = ScanOptions.scanOptions().match(REDIS_KEY_PREFIX + "*").count(SCAN_BATCH_SIZE).build();
            Cursor<byte[]> cursor = connection.scan(scanOptions);

            // 分配一个大数组存放结果
            final List<String> resultKeyList = new ArrayList<>(20 * 10000);

            // 循环遍历,拿到符合条件的 Redis key 列表
            while (cursor.hasNext()) {
                resultKeyList.add(new String(cursor.next()));
            }

            return null;
        });
    }
}    

4. 测试结果

发送以及请求大小来源于 Wireshark 抓包数据中的统计,统计的是 IPV4层的包大小。

批量条数 (Batch Count) 平均读取时间 (ms) 发送请求大小 (kB) 接收请求大小 (MB)
500 16921 192 10
1000 11566 138 10
2000 6814 78 10
3000 5634 63 10
4000 6638 51 10
5000 4619 48 10
6000 4452 43 10
7000 3805 45 10
8000 5601 43 10
9000 4648 39 10
10000 4080 37 10
15000 3148 32 10
20000 2570 30 10
30000 2498 37 10
50000 2187 36 10
100000 2251 35 10
150000 2058 44 10
200000 1818 40 10

Redis Scan Batch Size 对读取数据时间的影响

从上图中可以看出,随着 batch count 的增加,总体读取时间是在呈现下降的趋势,最后直到某个点后趋于稳定。

  • 500 到 2000: 这个范围内批处理大小的增加很明显就降低了从 Redis 读取数据的时间,平均时间从 16921ms 降到 6814 ms。
  • 2000 到 3000: 读取时间继续降低,但下降幅度没那么大了。
  • 3000 到 5000: 读取时间继续降低,显示出批处理大小继续增加的好处。
  • 5000 到 6000: 读取时间还在降低。
  • 6000 到 7000: 读取时间已经到了 1 万以内的最低时间 3805 ms。
  • 7000 到 8000: 读取时间上升到平均 5601 ms,表明增加批处理大小不一定总是能继续降低时间。
  • 8000 到 100000: 总体趋势显示读取时间趋于稳定,波动在一个小范围内,基本跟 7000条一批差不多耗时。
  • 150000 到 200000: 读取时间看起来是进一步降低,到达最小值 1818 ms 。

可以从上面数据得到的几点小结论:

  • 持续增加批处理大小,并不一定会让读取时间减少,反而有时候会变的更慢。
  • 批处理大小需要取一个比较平衡的值,过大收益不明显,而且会让 Redis 服务器负载过高。

5. 注意事项

  • Scan 命令返回的结果可能包含重复 Key, 调用方必须做去重处理。
  • SCAN 命令是多次扫描,这个过程中数据可能会发生变化。所以导致结果集可能不一定完全一致,这个在业务上可以考虑下是否会受影响。