1. 线上一个 KEYS 把 Redis 拖垮的真实场景
先说结论:在 Redis 里按前缀批量查 key,能用SCAN就别用KEYS。KEYS pattern这个命令看起来最直观,KEYS user:*一把梭,但它有两个致命问题:一是时间复杂度是 O(N),N 是整个库的 key 总数,不是匹配到的数量;二是 Redis 处理命令是单线程的,KEYS执行期间会阻塞其他所有请求,key 越多卡得越久。
我遇到过最典型的一次,是某个业务方在监控面板上想统计一下order:cache:*到底有多少条,直接在客户端敲了KEYS order:cache:*。当时那个实例大概 800 万 key,命令发出去之后,整个实例的 P99 从 2ms 飙到 3 秒多,上游接口大面积超时,最后是靠重启才缓过来。从那以后我们内部直接把KEYS加进了命令黑名单,用rename-command禁掉。
那按前缀查 key 这个需求本身是合理的,比如清理某类缓存、做数据迁移、排查脏数据,都需要按前缀捞一批 key。正确的做法是用SCAN的游标遍历:它每次只返回一小批,不阻塞主线程,客户端拿着游标一轮轮翻,直到游标回到 0 表示遍历结束。代价是它不保证强一致快照,遍历过程中数据被改动可能漏掉或重复,但对绝大多数「按前缀找 key」的场景完全够用。
这篇就围绕SCAN讲清楚三件事:命令参数怎么配、MATCH前缀骨架怎么写、COUNT怎么调,最后给你一套用redis-cli验证遍历完整性和阻塞耗时的具体动作。如果你在写代码时需要调用模型能力来辅助生成或审查这类脚本,可以顺手用 TaoToken 的模型对话做对照,地址是 https://taotoken.net/api ,模型对话入口在 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model-chat 。
2. SCAN 命令骨架与 COUNT、MATCH 参数拆解
SCAN的完整语法是:
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]四个部分逐个说。
cursor是游标,第一次调用传0,之后每次传上一次返回的游标值。当返回的游标又是0时,说明这一轮遍历结束。注意游标不是「第几个 key」这种偏移量,它是一个内部哈希表的反向二进制迭代游标,所以你会看到它跳来跳去,比如 0 → 17 → 288 → 224,这是正常的,别试图去理解它的数值规律。
MATCH pattern是模式匹配,支持 glob 风格通配符,*匹配任意字符,?匹配单个字符,[abc]匹配字符集。按前缀查就是MATCH user:*这种写法。这里有个关键点必须记住:MATCH 是在元素被扫描出来之后才做过滤的,不是先按 pattern 去定位数据。也就是说COUNT 10表示「这次从哈希表里扫 10 个槽位」,扫出来的这 10 个里可能一个都不匹配user:*,于是你看到返回空列表但游标还在往前走。这就是为什么小 COUNT 配窄前缀时,会连续好几次返回空结果。
COUNT count是每次扫描的工作量提示,默认 10。官方文档明确说它只是个 hint,实际返回条数可能比 COUNT 多也可能少,取决于内部编码(比如 ziplist、intset、hashtable 的槽位分布)。所以别把 COUNT 当成「精确返回 N 条」。
TYPE type是 Redis 6.0 之后加的,可以只扫指定类型的 key,比如TYPE string,能进一步减少无效匹配,前缀 + 类型双过滤时很有用。
一个最小可用的前缀遍历骨架长这样:
# 第一轮,游标从 0 开始 SCAN 0 MATCH user:* COUNT 1000 # 假设返回游标 512 SCAN 512 MATCH user:* COUNT 1000 # 继续,直到返回的游标为 0用 shell 写一个自动翻页的循环,方便在redis-cli里直接跑:
#!/bin/bash # scan_prefix.sh 用法: ./scan_prefix.sh "user:*" 1000 PATTERN="${1:-*}" COUNT="${2:-1000}" CURSOR=0 TOTAL=0 while :; do # 用 --raw 去掉引号,方便解析 RESULT=$(redis-cli --raw SCAN "$CURSOR" MATCH "$PATTERN" COUNT "$COUNT") CURSOR=$(echo "$RESULT" | head -n 1) KEYS=$(echo "$RESULT" | tail -n +2) if [ -n "$KEYS" ]; then echo "$KEYS" N=$(echo "$KEYS" | wc -l) TOTAL=$((TOTAL + N)) fi if [ "$CURSOR" = "0" ]; then break fi done echo "---- total matched: $TOTAL ----" >&2这段脚本的核心就是「拿游标 → 取结果 → 判断游标是否为 0」。--raw参数很关键,不加的话redis-cli会给每个元素加引号,解析起来很烦。
3. COUNT 调优:为什么 1000 是个常用起点
COUNT 的取值直接决定两件事:网络往返次数和单次阻塞时长。COUNT 太小,比如默认的 10,800 万 key 的库你要来回 80 万次,网络 RTT 累加起来非常可观,而且每次返回空结果的概率高,客户端循环空转。COUNT 太大,比如直接上 10 万,单次SCAN内部要扫的槽位多,虽然不像KEYS那样一次性全扫,但单次耗时也会拉长,阻塞风险上升。
我的经验值是这样:
| 场景 | 建议 COUNT | 理由 |
|---|---|---|
前缀命中率高(如user:*占大半) | 500 ~ 1000 | 每次都能返回不少结果,往返次数少 |
前缀命中率低(如tmp:lock:*很稀疏) | 2000 ~ 5000 | 抵消 MATCH 后置过滤带来的空返回 |
| 结果集预期 1 万以内 | 直接设为预期大小 | 一轮基本扫完,最省事 |
| 超大库 + 在线业务 | 500 起步,观察耗时再调 | 优先保证不阻塞 |
有个常见误区:以为 COUNT 是「返回结果条数」。不是的,COUNT 是「扫描的槽位数量」,MATCH 过滤后返回的才是结果。所以前缀越稀疏,COUNT 越要往大调,否则你会看到连续十几次空返回。
另外 COUNT 每次调用可以不一样,只要游标接得上就行。比如第一轮用 1000 探路,发现返回结果很少,后面几轮直接提到 5000,完全合法。
4. 用 redis-cli 验证遍历完整性与阻塞耗时
光会写命令不够,得能验证「遍历全不全」和「到底阻不阻塞」。下面这套动作可以直接照做。
4.1 造测试数据
先灌一批带前缀的 key,方便验证:
# 灌 10000 个 user: 前缀的 key for i in $(seq 1 10000); do redis-cli SET "user:$i" "v$i" > /dev/null done # 再灌 5000 个 order: 前缀的 key 做干扰 for i in $(seq 1 5000); do redis-cli SET "order:$i" "v$i" > /dev/null done4.2 验证遍历完整性
用DBSIZE拿到总数,再用SCAN遍历user:*,对比数量:
redis-cli DBSIZE # 预期 15000 # 用第 2 节的脚本遍历 ./scan_prefix.sh "user:*" 1000 | wc -l # 预期 10000(脚本最后一行 total 输出到了 stderr,不影响 wc)如果数量对得上,说明遍历完整。如果对不上,先检查是不是遍历过程中有写入,SCAN不保证快照一致,边写边扫可能漏。
4.3 验证阻塞耗时
这是最关键的一步,对比KEYS和SCAN的耗时。用redis-cli --latency开一个窗口持续观察延迟,另一个窗口执行命令:
# 窗口 A:持续观察延迟 redis-cli --latency # 窗口 B:执行 KEYS,观察窗口 A 的延迟尖刺 redis-cli KEYS "user:*" > /dev/null # 窗口 B:执行 SCAN 单轮,观察窗口 A 是否平稳 redis-cli SCAN 0 MATCH "user:*" COUNT 1000 > /dev/null实测下来,KEYS那一瞬间窗口 A 的延迟会明显跳高,而SCAN单轮基本看不到尖刺。你也可以用redis-cli --intrinsic-latency 100看基线,再在执行命令时对比。
更严谨一点,用INFO commandstats看命令耗时统计:
redis-cli INFO commandstats | grep -E "cmdstat_keys|cmdstat_scan"cmdstat_keys的usec_per_call会明显高于cmdstat_scan,而且keys的calls通常很少(因为被禁了),scan的调用次数多但单次耗时低。
5. 本篇常见错排查
报错一:ERR unknown command 'SCAN'或NOPERM this user has no permissions to run the 'scan' command
前者说明 Redis 版本太老(2.8 以下),升级即可。后者是 ACL 权限问题,Redis 6 之后默认用户可能没给scan权限,需要管理员授权:
redis-cli ACL SETUSER myuser +scan +dbsize报错二:遍历结果比预期少
最常见原因是遍历期间有 key 被删除或新增。SCAN是弱一致遍历,不保证快照。如果业务要求精确,要么在低峰期做,要么用DUMP+RESTORE做快照副本再扫。另一个原因是MATCH写错了,比如user*漏了冒号,会匹配到username这类无关 key。
报错三:连续多次返回空列表,以为命令坏了
这是MATCH后置过滤的正常现象,不是 bug。把COUNT调大,或者接受空返回继续翻游标。判断是否结束只看游标是否为 0,不要看结果是否为空。
报错四:客户端循环里游标类型搞错
SCAN返回的游标是字符串,有些客户端库返回的是数字,比较时要注意类型。比如 Jedis 里ScanParams.SCAN_POINTER_START是字符串"0",别拿int 0去比。Python 的redis-py里scan_iter已经帮你封装好了游标循环,直接用更省心:
import redis r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True) for key in r.scan_iter(match='user:*', count=1000): print(key)报错五:SCAN和HSCAN、SSCAN、ZSCAN搞混
SCAN扫的是整个 key 空间,HSCAN扫的是某个 hash 的 field,SSCAN扫 set 成员,ZSCAN扫 zset 成员。按前缀查 key 用SCAN,别用错。
6. 落地建议与工具链衔接
把SCAN用稳,记住三条:前缀查 key 一律走SCAN,KEYS在生产环境直接禁;COUNT按前缀稀疏度调,稀疏就往大调;遍历结束只看游标是否为 0,别被空返回吓到。
如果你在写这类运维脚本或封装工具类时需要快速验证一段逻辑、生成对照代码,可以用 TaoToken 的模型对话能力做辅助,入口在 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model-chat 。长期做编码和 Agent 类项目的话,Coding Plan 更适合持续调用,地址是 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding-plan 。需要管理调用凭证就去 API Keys 页面 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=api-keys ,接入细节看文档 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc 。
最后补一个实战小技巧:如果你的前缀查询是高频操作,与其每次SCAN全库,不如在写入时维护一个前缀索引 set,比如SET user:1的同时SADD idx:user user:1,查的时候直接SMEMBERS idx:user。代价是写入多一步、要处理过期同步,但查询从 O(N) 降到 O(1)。这个方案适合前缀种类固定、查询频繁的场景,SCAN则适合临时排查和低频迁移。两者不冲突,按场景选。