Redis 键扫描优化:从 KEYS 到 SCAN 的优雅升级
Redis 键扫描优化:从 KEYS 到 SCAN 的优雅升级
简单说一下应用的场景,这个是生产项目的正式需求
用户APP使用时间的上报,每次用户退出后会调用一个接口,存储到redis中。我们将用户 app 使用时长数据存储在 Redis 中,键格式如 user_app_time:{date}:{userId},并定期批量插入数据库(如 MySQL)以持久化
KEYS 的问题:阻塞与资源消耗
在旧实现中,我们先计算前一天日期,形成模式如 user_app_time:*:{date},然后:
Set<String> redisKeys = stringRedisTemplate.keys(pattern);
这会一次性获取所有匹配键。对于少量键,这没问题。但想象一个流行 app,有数百万用户——Redis 可能每天持有数十万甚至上百万键。KEYS 命令线性扫描整个键空间,并在执行期间阻塞 Redis 服务器。其他操作无法进行,导致:
- 服务器阻塞:Redis 大多数操作单线程,KEYS 会暂停一切。如果数据集大,可能引起延迟峰值、超时甚至中断。
- 内存开销:所有键一次性加载到客户端内存,如果集合巨大,Java 应用可能出现 OutOfMemory 错误。
- 可扩展性问题:随着数据增长,执行时间线性增加。我们见过 ~50,000 键的任务耗时 30 秒以上,对定时任务不可接受。
- 无分页:全量或无,無法增量处理。
获取键后,我们循环解析值、构建领域对象,并分批 1000 条插入数据库。错误处理简单,所有记录先收集到一个大列表再切片——内存效率低下。在一次事件中,Redis 其他查询响应时间激增,影响实时用户数据处理。
SCAN 的优雅解决方案:游标增量处理
引入 SCAN:Redis 的非阻塞键迭代替代。它使用游标逐步遍历键空间,允许分批处理而不停止服务器。在优化代码中,我们用 RedisCallback 包装:
List<UserUseAppTimeDomain> buffer = new ArrayList<>(1000);
stringRedisTemplate.execute((RedisConnection connection) -> {ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();try (Cursor<byte[]> cursor = connection.scan(options)) {List<UserUseAppTimeDomain> buffer = new ArrayList<>();while (cursor.hasNext()) {String redisKey = new String(cursor.next(), StandardCharsets.UTF_8);// 解析值、构建记录、添加到 bufferif (buffer.size() >= 1000) {// 批量插入数据库buffer.clear();}}// 处理剩余记录return null;} catch (Exception e) {// 错误处理}
});
优化后,我改用 SCAN 命令,这是 Redis 推荐的非阻塞替代方案。
它通过游标逐步遍历键空间,不会一次性阻塞服务器。在代码中,我用 RedisCallback 包装了执行逻辑:设置 ScanOptions,包括 match 模式和 count=1000 来控制每次迭代的批次大小。然后,用 try-with-resources 打开游标,while 循环 hasNext() 来逐个处理键**。关键是引入了一个 buffer 列表,边扫描边解析键和值,构建记录添加到 buffer。当 buffer 达到 1000 条时,就立即批量插入数据库并清空**。
这样实现流式处理,不用等到所有数据都收集完再操作,内存使用更高效,也减少了峰值占用。处理完所有键后,再插入剩余的 buffer 记录,最后设置一个锁键标记任务完成,以防重复执行。异常处理也更稳当,如果扫描出错,会发邮件警报并抛异常。
这个变化带来的性能提升很明显:在同样的 ~50,000 键场景下,任务时间从 30 秒降到 5 秒以内,Redis 保持负载均衡,其他查询几乎不受影响。
Scan就像是扫描-解析-插入像一条流式管道,无缝衔接。我们还调整了键格式,从原版的 user_app_time:{userId}:{date} 改为 {date}:{userId},以更好地匹配扫描模式
当然,SCAN 有个小注意点:Redis 哈希表扩展时可能返回重复键,但实际影响不大,如果需要可以加 Set 去重。