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

7.0 KiB
Raw Blame History

tags, create time
tags create time
redis
scan
keys
traversal
performance
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 的三个致命问题

  1. 阻塞主线程:Redis 单线程,KEYS 执行期间所有其他请求都无法处理
  2. 内存峰值:结果一次性返回,匹配大量 Key 时可能 OOM
  3. 无法增量迭代:一次返回所有结果,中途无法停止

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
}

关联笔记