Files
cs-note/hzh/REDIS/SCAN命令.md
T

241 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [redis, scan, keys, traversal, performance]
create time: 2026-06-01 10:30
---
# Redis SCAN 遍历命令
## 概述
在生产环境中,你经常需要找到所有符合某个模式的 Key——比如找出所有 `rate:limit:*` 做清理或统计。直觉上会用 `KEYS *pattern*`,但**大库中使用 KEYS 会导致服务雪崩**。`SCAN` 是唯一的正确选择。
这对应你的薄弱点 Q15:为什么用 SCAN 替代 KEYS。
## 一、KEYS vs SCAN:核心对比
### KEYS:简单但危险的阻塞操作
```bash
# 一行搞定,但会阻塞整个 Redis
KEYS rate:limit:*
→ user1
→ user2
→ user3
...
```
**问题:** KEYS 是一次性全量扫描——它会在单个操作中遍历所有 Key,然后一次性全部返回。在这期间:
```mermaid
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 的三个致命问题
>
> 1. **阻塞主线程**:Redis 单线程,KEYS 执行期间所有其他请求都无法处理
> 2. **内存峰值**:结果一次性返回,匹配大量 Key 时可能 OOM
> 3. **无法增量迭代**:一次返回所有结果,中途无法停止
### SCAN:分步游标,永不阻塞
```bash
# 第一次调用,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 参数详解
```bash
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 基础用法
```go
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 ⚠️ 快捷方法(不推荐用于大数据量)
```go
// rdb.Keys() 底层调用的是 KEYS 命令——会阻塞 Redis!
keys, err := rdb.Keys(ctx, "rate:limit:*").Result()
```
> [!note]- Go-Redis 的坑
>
> `rdb.Keys()` 虽然调用方便,但底层仍然是阻塞式的 `KEYS` 命令。**在大数据量场景下务必使用上面的手动 SCAN 循环**(见 3.1 节)。
### 3.3 带 TYPE 过滤的高效扫描
```go
// 只找 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 最容易让人困惑的地方——**它不保证返回的结果是某一时刻的快照**。
```mermaid
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
-- 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
```go
// 找出所有 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
}
```
## 关联笔记
- [[分布式限流]] — 限流架构中 SCAN 的实际应用场景
- [[内存持续增长排查]] — SCAN 是排查内存问题的必备工具
- [[僵尸Key防护]] — 利用 SCAN 发现和清理僵尸 Key