7.0 KiB
7.0 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-06-01 10:30 |
Redis SCAN 遍历命令
概述
在生产环境中,你经常需要找到所有符合某个模式的 Key——比如找出所有 rate:limit:* 做清理或统计。直觉上会用 KEYS *pattern*,但大库中使用 KEYS 会导致服务雪崩。SCAN 是唯一的正确选择。
这对应你的薄弱点 Q15:为什么用 SCAN 替代 KEYS。
一、KEYS vs SCAN:核心对比
KEYS:简单但危险的阻塞操作
# 一行搞定,但会阻塞整个 Redis
KEYS rate:limit:*
→ user1
→ user2
→ user3
...
问题: KEYS 是一次性全量扫描——它会在单个操作中遍历所有 Key,然后一次性全部返回。在这期间:
flowchart LR
A["KEYS *pattern* 开始"] --> B["阻塞 Redis 主线程<br/>遍历所有数据库"]
B --> C["如果库中有 1000 万个 Key?<br/>可能耗时数秒到数十秒"]
C --> D["所有其他客户端被阻塞<br/>超时/连接堆积"]
D --> E["生产事故:服务不可用"]
style A fill:#fff3e0
style D fill:#ffebee
style E fill:#b71c1c,color:#fff
[!warning]- KEYS 的三个致命问题
- 阻塞主线程:Redis 单线程,KEYS 执行期间所有其他请求都无法处理
- 内存峰值:结果一次性返回,匹配大量 Key 时可能 OOM
- 无法增量迭代:一次返回所有结果,中途无法停止
SCAN:分步游标,永不阻塞
# 第一次调用,cursor = 0
SCAN 0 MATCH rate:limit:* COUNT 100
→ 1) "12345" ← 下一个 cursor
→ 2) 1) "user1"
2) "user2"
...
# 下次用返回的 cursor 继续
SCAN 12345 MATCH rate:limit:* COUNT 100
→ 1) "67890"
→ 2) 1) "user3"
...
# 当 cursor 返回 "0" 时,遍历完成
优势:
- 每次只返回少量结果(由 COUNT 控制)
- 不会阻塞 Redis 主线程
- 可以安全地在生产环境使用
二、SCAN 参数详解
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
| 参数 | 含义 | 示例 |
|---|---|---|
cursor |
游标,0 表示开始,返回 0 表示结束 | 0, 12345 |
MATCH |
模式过滤(支持 * ? [] 通配符) |
rate:limit:* |
COUNT |
每次迭代的元素数量(默认 10) | COUNT 100 |
TYPE |
按数据类型过滤(Redis 4.0+) | TYPE string |
COUNT 参数的误区
COUNT 不是精确返回数量,而是告诉 Redis "这一轮大概应该返回多少条"。实际返回数量可能多也可能少。
COUNT 越大 → 每次返回越多,总迭代次数越少,但单次延迟略高
COUNT 越小 → 每次返回越少,总迭代次数越多,但单次延迟极低
建议值: 根据数据量和延迟要求调整,通常 100 ~ 500 是 sweet spot。
三、Go-Redis 中的使用
3.1 基础用法
func scanAllKeys(ctx context.Context, rdb *redis.Client, pattern string) ([]string, error) {
var allKeys []string
// Cursor 为 0 时会自动从第一轮开始
cursor := uint64(0)
for {
// KeysByPattern 内部处理了游标迭代
keys, nextCursor, err := rdb.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return nil, err
}
allKeys = append(allKeys, keys...)
if nextCursor == 0 {
break // 遍历完成
}
cursor = nextCursor
}
return allKeys, nil
}
3.2 ⚠️ 快捷方法(不推荐用于大数据量)
// rdb.Keys() 底层调用的是 KEYS 命令——会阻塞 Redis!
keys, err := rdb.Keys(ctx, "rate:limit:*").Result()
[!note]- Go-Redis 的坑
rdb.Keys()虽然调用方便,但底层仍然是阻塞式的KEYS命令。在大数据量场景下务必使用上面的手动 SCAN 循环(见 3.1 节)。
3.3 带 TYPE 过滤的高效扫描
// 只找 String 类型的限流 Key
cursor := uint64(0)
for {
iter := rdb.Scan(ctx, cursor, "rate:limit:*", 200)
keys, _ := iter.Result()
for _, key := range keys {
t, _ := rdb.Type(ctx, key).Result()
if t.Err() != nil || t.Val() != "string" {
continue
}
// 处理这个限流 Key...
handleKey(key)
}
if iter.Cursor() == 0 {
break
}
}
四、SCAN 的不一致性保证
这是 SCAN 最容易让人困惑的地方——它不保证返回的结果是某一时刻的快照。
flowchart TD
A["SCAN 开始遍历"] --> B["过程中有新 Key 插入"]
B --> C{"新 Key 会被返回吗?"}
C -- "不一定" --> D["可能被下一轮 SCAN 返回"]
C -- "也可能错过" --> E["本轮不会看到"]
A --> F["过程中有 Key 被删除"]
F --> G{"已访问过的 Key 被删?"}
G -- "不会重复返回" --> H["正常,因为游标已越过"]
G -- "未访问到的 Key 被删?" --> I["自然跳过,不影响"]
style C fill:#fff3e0
style D fill:#e3f2fd
style E fill:#fff9c4
三个一致性保证:
| 保证 | 说明 |
|---|---|
| ✅ 不会遗漏 | 如果一个 Key 在整个 SCAN 过程中始终存在且没被删除,它至少被返回一次 |
| ⚠️ 可能重复 | 同一个 Key 可能在多次迭代中返回多次(去重即可) |
| ⚠️ 可能遗漏 | 如果在 SCAN 开始前就存在的 Key 中途被删除了,它可能被跳过 |
[!tip]- 实际影响
对限流场景来说,SCAN 的一致性模型完全够用——你要做的通常是清理即将过期的 Key 或者统计当前有多少活跃 Key,不需要精确到某一毫秒的快照。只需要在代码中对返回的 Key 集合做
set去重即可。
五、Lua 脚本中使用 SCAN
Redis Lua 中也支持 redis.call('SCAN', ...),但有重要限制:
-- Lua 中可以使用 SCAN
local cursor = 0
repeat
local result = redis.call('SCAN', cursor, 'MATCH', KEYS[1], 'COUNT', 100)
cursor = tonumber(result[1])
local keys = result[2]
-- 处理 keys...
until cursor == 0
[!warning]- Lua + SCAN 的性能陷阱
Lua 脚本最长只能运行 5 秒。如果你在 Lua 里用 SCAN 遍历一个包含百万级 Key 的数据库,很可能超时。解决思路:
- 减少
COUNT参数- 把 SCAN 放在 Lua 外部,用 Go 循环控制
- 使用
KEYS替代仅在开发/小库环境中
六、排查场景应用
查找僵尸 Key
// 找出所有 rate:limit:* 开头的 Key 并检查 TTL
cursor := uint64(0)
for {
keys, next, _ := rdb.Scan(ctx, cursor, "rate:limit:*", 200).Result()
for _, key := range keys {
ttl, _ := rdb.TTL(ctx, key).Result()
if ttl < 0 {
log.Printf("zombie key found: %s (TTL=%d)", key, ttl)
}
}
if next == 0 {
break
}
cursor = next
}