Redis 如何高效安全的遍历所有 Key?
结论:不要用
KEYS *。正确做法是SCAN——基于游标 + 位反转增量遍历的无状态迭代器,每次只返回一小批,不阻塞主线程。它提供的是弱保证:从遍历开始到结束一直存在的 key 至少返回一次,但可能返回重复、也可能漏掉遍历期间新增或删除的 key。生产上配合MATCH、COUNT、去重与限流使用。
一、KEYS、SCAN 与业务侧遍历
| 方式 | 复杂度 | 阻塞 | 一致性 | 结论 |
|---|---|---|---|---|
KEYS pattern | O(N) | 全库扫描期间完全阻塞 | 快照式,准确 | 只允许在离线环境用 |
SCAN cursor MATCH p COUNT n | 每次 O(1) 摊还 | 每次最多几十微秒 | 弱一致,可能重复 | 生产唯一推荐 |
HSCAN / SSCAN / ZSCAN | 同上 | 同上 | 同上 | 遍历大集合内部元素 |
| 从库上执行 SCAN | 同上 | 不影响主库 | 有复制延迟 | 大批量巡检的推荐姿势 |
二、游标为什么是"位反转"的
哈希表扩容/缩容时,桶的编号会变:mask 从 3(2^2-1) 变成 7(2^3-1)
若按 0,1,2,3 顺序遍历,扩容后已扫过的桶可能被重复或跳过。
Redis 的做法:把游标按位反转后再递增(reverse binary iteration)
游标序列(mask=7,位反转后):
0 → 4 → 2 → 6 → 1 → 5 → 3 → 7
好处:无论表在这期间扩容还是缩容,已遍历的桶不会漏,最多重复。
因此 SCAN 的游标不是"第几页",不能做分页:你不能用 SCAN 100 COUNT 10 取第 11 页,每次都必须从上一次返回的游标继续,直到返回 0 表示结束。
三、正确用法
# 1) 基础迭代:COUNT 是提示值,实际返回条数可多可少
redis-cli SCAN 0 MATCH 'order:*' COUNT 1000
# 2) 循环直到游标为 0(脚本示例)
cursor=0
while :; do
out=$(redis-cli SCAN $cursor MATCH 'order:*' COUNT 1000)
cursor=$(echo "$out" | head -1)
echo "$out" | tail -n +2
[ "$cursor" = "0" ] && break
done
# 3) 只关心类型(6.0+),减少客户端过滤
redis-cli SCAN 0 TYPE hash COUNT 500
# 4) 遍历大集合的内部元素,而不是整个库
redis-cli HSCAN user:1001 0 COUNT 100
redis-cli ZSCAN rank:week 0 COUNT 100
// 生产封装:限速 + 去重 + 可中断
public void scanAll(String pattern, Consumer<String> handler) {
ScanOptions opts = ScanOptions.scanOptions().match(pattern).count(500).build();
Set<String> seen = new HashSet<>(); // 应对重复返回
try (Cursor<String> cursor = redis.scan(opts)) {
while (cursor.hasNext()) {
String key = cursor.next();
if (seen.add(key)) { handler.accept(key); }
RateLimiter.acquire(); // 每秒上限,避免打满
}
}
}
四、弱一致性的边界
- 会重复:rehash 过程中同一个 key 可能出现在两个桶里,被返回两次,必须去重(幂等处理)。
- 会漏新:遍历开始后才创建的 key 不保证返回;遍历期间被删除的 key 也可能仍被返回。
- 不要当快照:需要"某一时刻的完整 key 列表"就
BGSAVE后离线分析 RDB,或用rdb-tools/redis-rdb-cli。 - COUNT 不是上限:它只是"每次扫描的桶数提示",返回条数可能远大于 COUNT(桶内链表长时)。
五、常见追问
- 问:SCAN 会不会阻塞主线程?答:每次调用只扫有限个桶,耗时通常在几十微秒级;但如果
COUNT设得极大、或遇到大 key 的 HSCAN,单次仍可能变慢。 - 问:为什么不能用
KEYS做巡检?答:O(N) 全库遍历且期间不处理其他命令,百万级 key 会造成秒级卡顿,等同于故障。 - 问:
SCAN 0每次都能从头开始吗?答:能,游标 0 表示"从头开始"。但注意返回 0 也表示"遍历结束",二者靠上下文区分。 - 问:如何统计某前缀的 key 数量?答:遍历时累加(接受弱一致),或维护计数 key(写时 INCR);
DBSIZE只能给出总数。 - 问:集群模式下怎么扫全库?答:对每个主节点分别 SCAN,客户端按节点建立连接;
--cluster call或 cluster 客户端的getPartitions()都能拿到节点列表。 - 问:大 key 巡检怎么做?答:
redis-cli --bigkeys内部就是 SCAN +TYPE+ 抽样MEMORY USAGE;更精细用--memkeys或离线分析 RDB。
小结
- 遍历三原则:不用 KEYS、用 SCAN 的游标迭代、结果去重并限速。
- SCAN 的保证只有"一直存在的 key 至少出现一次",其余(完整性、顺序、快照)都不要假设。
- 只要把 SCAN 当成"最终能扫完、但会重复"的工具,巡检脚本就不会写错。