241 lines
7.0 KiB
Markdown
241 lines
7.0 KiB
Markdown
|
|
---
|
|||
|
|
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
|