Redis 如何高效安全的遍历所有 Key?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
当面试官问出这个问题时,他不仅仅是想知道一两个命令的名字。其核心考察点通常包括:
- 对 Redis 关键命令危险性的认知:你是否了解
KEYS *命令在生产环境中的致命缺陷。 - 掌握安全遍历的替代方案:你是否知道并使用
SCAN命令族,以及理解其背后的工作原理。 - 对 Redis 核心架构的理解:你是否能从 Redis 单线程模型的角度,解释为什么
KEYS命令会引发问题,以及SCAN是如何避免的。 - 工程实践与深入思考:你是否考虑过遍历过程中的一致性、性能影响、大 Key 处理等实际问题,以及是否有更高级的键空间管理思路。
核心答案
绝对不要在生产环境使用 KEYS * 命令。安全高效遍历所有 Key 的标准方法是使用 SCAN 命令。
SCAN 命令通过游标进行增量式迭代,不会阻塞 Redis 服务器,是遍历大数据集的安全选择。为了遍历特定类型的 Key,应使用对应的 SSCAN、HSCAN 和 ZSCAN 命令。
深度解析
原理/机制:为什么 KEYS * 是危险的?
KEYS 命令会一次性返回所有匹配模式的键。其危险性根源于 Redis 的 单线程事件循环模型。
- 阻塞:
KEYS命令在执行时,会遍历整个数据库的键空间。如果数据库中有数百万甚至更多的 Key,这个操作会消耗大量 CPU 时间,并阻塞住整个 Redis 服务器。在此期间,所有其他客户端发来的命令(包括GET、SET等简单命令)都必须等待,导致服务响应超时甚至雪崩。 - 内存与网络压力:如果匹配的 Key 数量巨大,返回的结果集可能会消耗大量服务器内存和网络带宽。
SCAN 命令如何解决?
SCAN 采用基于游标的迭代器。每次调用 SCAN cursor [MATCH pattern] [COUNT count],它只返回一小部分元素(数量约为 COUNT 参数指定,但只是一个提示,实际返回数量可能不同)和一个新的游标。客户端需要保存这个新游标,并在下一次调用时传入,直到返回的游标为 0,表示迭代结束。
- 非阻塞:每次
SCAN操作的时间复杂度是O(1),不会长时间占用主线程。 - 弱一致性:由于
SCAN遍历期间,数据库可能发生增删改,因此它不保证能返回遍历开始后新增的键,也可能会返回已经遍历过但后来被删除的键。这是一种 “弱一致性” 的遍历,适合统计、清理等不要求精确一致性的场景。
代码示例
以下是一个使用 Jedis 客户端进行安全遍历的示例,清晰地展示了游标的用法:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.ScanParams;
import redis.clients.jedis.ScanResult;
import java.util.List;
public class RedisSafeScanExample {
public static void main(String[] args) {
// 1. 创建连接(生产环境应使用连接池)
try (Jedis jedis = new Jedis("localhost", 6379)) {
// 2. 设置扫描参数:匹配模式 和 每次扫描的数量提示
ScanParams scanParams = new ScanParams();
scanParams.match("user:session:*"); // 匹配所有以 user:session: 开头的key
scanParams.count(100); // 提示每次扫描约100个元素
// 3. 开始迭代扫描
String cursor = ScanParams.SCAN_POINTER_START; // 起始游标为 "0"
int totalKeys = 0;
do {
// 执行 SCAN 命令
ScanResult<String> scanResult = jedis.scan(cursor, scanParams);
// 获取下一次迭代的游标
cursor = scanResult.getCursor();
// 获取本次扫描到的 key 列表
List<String> keys = scanResult.getResult();
// 处理本次扫描到的 keys
totalKeys += keys.size();
processKeys(keys); // 你的业务处理逻辑
System.out.println("Scanned " + keys.size() + " keys. Next cursor: " + cursor);
} while (!cursor.equals(ScanParams.SCAN_POINTER_START)); // 游票回到 "0" 表示结束
System.out.println("Total keys found: " + totalKeys);
}
}
private static void processKeys(List<String> keys) {
// 这里可以执行你的业务逻辑,例如:
// 1. 批量获取值:jedis.mget(keys.toArray(new String[0]))
// 2. 检查并删除过期会话
// 3. 统计信息等
for (String key : keys) {
// 示例:打印 key
// System.out.println("Processing key: " + key);
}
}
}
对于 Set、Hash、Sorted Set 的遍历,原理相同:
// 遍历一个名为 `largeSet` 的 Set
try (Jedis jedis = new Jedis("localhost", 6379)) {
String cursor = ScanParams.SCAN_POINTER_START;
ScanParams scanParams = new ScanParams().count(50);
do {
ScanResult<String> sscanResult = jedis.sscan("largeSet", cursor, scanParams);
cursor = sscanResult.getCursor();
List<String> setMembers = sscanResult.getResult();
// 处理 setMembers...
} while (!cursor.equals(ScanParams.SCAN_POINTER_START));
}
// 遍历一个名为 `user:1000:profile` 的 Hash
try (Jedis jedis = new Jedis("localhost", 6379)) {
String cursor = ScanParams.SCAN_POINTER_START;
ScanParams scanParams = new ScanParams().count(50);
do {
ScanResult<Map.Entry<String, String>> hscanResult =
jedis.hscan("user:1000:profile", cursor, scanParams);
cursor = hscanResult.getCursor();
List<Map.Entry<String, String>> hashEntries = hscanResult.getResult();
// 处理 hashEntries...
} while (!cursor.equals(ScanParams.SCAN_POINTER_START));
}
最佳实践与注意事项
-
使用连接池:生产环境务必使用
JedisPool或Lettuce连接池管理连接,避免每次创建新连接的开销。 -
COUNT参数是提示值:Redis 并不保证每次返回恰好COUNT个元素。你可以根据网络和服务端压力调整这个值(例如 100-1000)。值太小会增加网络往返次数,值太大会使单次操作变慢。需要进行权衡测试。 -
在连接空闲时进行:尽管
SCAN是非阻塞的,但大量迭代仍会消耗服务器和网络资源。尽量在业务低峰期执行。 -
无需在客户端去重:
SCAN命令的设计保证,在遍历过程中,只要数据集大小不变,每个元素最多被返回一次。但如果遍历期间有元素被删除并重新添加,它可能被再次返回,客户端通常无需处理这种极端情况。 -
MATCH过滤在服务端元素返回后进行:这意味着,即使设置了MATCH 'user:*',COUNT参数指定的也是扫描的原始键数量,而不是返回的匹配数量。如果匹配率很低,可能需要多次扫描才能返回一个结果,效率低下。 -
考虑使用 Lettuce:对于新项目,更推荐使用 Lettuce 客户端。它性能更好,且天然支持异步和响应式编程。Lettuce 的
ScanArgs和KeyScanCursor等 API 与 Jedis 类似,但设计更现代。// Lettuce 示例片段 RedisClient client = RedisClient.create("redis://localhost"); StatefulRedisConnection<String, String> connection = client.connect(); RedisCommands<String, String> commands = connection.sync(); KeyScanCursor<String> cursor = commands.scan(ScanArgs.Builder.matches("user:*").limit(100)); List<String> keys = cursor.getKeys(); // ... 迭代直到 cursor.isFinished()
常见误区
- 误区一:
SCAN能保证遍历期间的所有数据一致性。- 正解:
SCAN是弱一致性的,遍历过程中增删的数据可能被漏掉或重复。若需要强一致性的快照,应使用RDB或AOF文件进行分析,或在业务层加锁(不推荐,影响性能)。
- 正解:
- 误区二:
COUNT值设得越大,遍历越快。- 正解:过大的
COUNT值会让单次SCAN操作变慢,可能变相导致“微阻塞”。需要在单次耗时和总迭代次数之间寻找平衡。
- 正解:过大的
- 误区三:遍历所有 Key 是常规操作。
- 正解:遍历所有 Key 通常是运维、监控(如分析 Key 模式、内存使用)或数据迁移时的特定操作。在正常的业务逻辑设计中,应尽量避免需要全量遍历的场景,这本身就可能是设计缺陷的信号。更好的做法是通过业务数据结构(例如一个全局的 Set 记录所有用户会话 Key)来管理。
总结
生产环境下,安全遍历 Redis Key 的唯一正确姿势是使用 SCAN 命令族,它通过游标分批次、非阻塞地获取数据,完美规避了 KEYS 命令可能引发的服务阻塞风险。