1. KEYS 命令的惨痛教训:一次全量扫描引发的事故复盘
先讲一段我自己的经历。某天线上 Redis 实例的慢查询日志突然被刷屏,清一色的KEYS *user*,延迟从平时的 1ms 飙到几百毫秒,CPU 也直接上了 80%。查了一圈才知道,是同事为了解决一个临时数据问题,在脚本里用了 KEYS 命令做全量匹配。数据库里当时有接近三百万个 key,这条 KEYS 一次就把整个键空间的哈希表从头到尾翻了个底朝天。
KEYS 命令的问题在于它的执行模型:Redis 的核心命令执行业务是单线程的,一条命令从开始执行到结束,中间不会去处理其他任何命令。换句话说,KEYS 跑多久,其他所有请求就在队列里排多久。几百万 key 的全表扫描,少说也是几百毫秒起步,在业务高峰期这几百毫秒足以引发雪崩。更糟糕的是,KEYS 是一次性返回所有匹配结果的,结果集如果很大,网络传输、客户端解析、内存占用都会跟着出问题。
所以后来团队定了一条硬规矩:生产环境禁止使用 KEYS,除非你明确知道键空间很小、且当前处于低峰期。那么问题来了,如果确实需要遍历大量 key,该怎么办?答案就是标题里说的“渐进式遍历”,也就是以 SCAN 为代表的一系列命令。它不是一次性做完,而是把一次全量操作拆成很多次小操作,每次只遍历哈希表的一小部分桶,通过一个游标记录“下次从哪里继续”。这样单次命令的耗时被压得很低,不会堵住 Redis 的事件循环,对线上业务基本无感。
“渐进式遍历”不是一个命令,而是一族命令:
SCAN:遍历整个键空间,也就是数据库里的所有 key。HSCAN:遍历某个哈希键里的所有 field-value。SSCAN:遍历某个集合键里的所有成员。ZSCAN:遍历某个有序集合键里的所有成员和分数。
它们共享同一套游标机制,只是作用的数据结构不同。后面我会逐个展开,重点先把 SCAN 讲透,因为它是一切的基础。
这篇文章适合谁看?凡是已经接触过 Redis 基础命令、知道 KEYS 有风险、又需要在生产环境做数据清理、数据迁移、容量分析、批量删除的开发者,都可以把这篇当作一次完整的系统性梳理。我会从命令用法讲到底层哈希表的遍历顺序,再到 rehash 期间为什么能做到不重不漏,最后给出一套可以直接抄走的实战姿势。
2. SCAN 的基础交互:游标不是页码,而是一张书签
SCAN 的完整语法长这样:
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]第一次调用时,游标传 0。Redis 会返回两个东西:一个是“下一个游标”,另一个是本次遍历到的一批 key。你拿到下一个游标后,原封不动地把它作为下一次调用的参数,继续扫。直到某次返回的游标又重新变成 0,这一轮完整遍历就结束了。
用 redis-cli 演示一下更直观:
127.0.0.1:6379> scan 0 1) "1" 2) 1) "key:007" 2) "key:032" 3) "key:101" 127.0.0.1:6379> scan 1 1) "5" 2) 1) "key:004" 127.0.0.1:6379> scan 5 1) "0" 2) (empty array)看到这里,很多人会犯一个错误:把返回的游标当成“页码”,以为第一次返回 1 就代表第一页,第二次就传 2。不是的。游标不是页码,更像是一张书签。它不保证连续,不保证递增,甚至可能变小。你唯一需要做的就是“上次给我什么,我这次就原样传回什么”,不要在客户端对它做任何加减乘除。
关于游标和 SCAN 的行为,有三个最常见的误解,我一次性说清楚。
误解一:SCAN 返回的是某个时间点的“一致性快照”。其实 SCAN 遍历过程中,Redis 并没有为你的游标创建任何快照。新增的 key 可能会出现在本轮遍历里,也可能不会,取决于新 key 被哈希到哪个桶、那个桶有没有被扫过。如果遍历过程中有 key 被删除,它可能直接消失;如果 key 在遍历过程中被修改导致在哈希表中迁移位置,还可能重复返回同一个 key。所以 SCAN 只适合做“大概率拿到全集”的遍历,不适合用来做严格的事务性操作。
误解二:游标可以换一台机器继续用。游标本质上是 Redis 内部哈希表遍历状态的编码,和当前实例的哈希表大小、rehash 进度都有关系。你把这个游标拿到另一台机器,甚至在同一台实例上重启服务后,都可能失效。跨节点迁移数据时,游标要在同一个节点内自恰,集群环境需要在每个节点上分别开一轮扫描。
误解三:SCAN 返回的 key 数量应该等于 COUNT 参数。很多初学者把 COUNT 当作“每轮返回多少个 key”,实际完全不是一回事。COUNT 的真正含义,是告诉 Redis“这次迭代你大概检查多少个哈希桶”。注意,是检查多少个桶,不是返回多少个 key。一个桶里可能一条链表挂了 500 个 key,那你 count=10 也可能一次返回 500 个;反过来一个桶是空的,你 count=1000 也可能返回 0 个。这个坑我在后面实战章节会详细展开。
如果你只想先跑起来,最朴素的使用方式是这样的(Python 版伪代码,redis-py 客户端):
import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0) cursor = 0 while True: cursor, keys = r.scan(cursor, count=100) for key in keys: # 在这里处理你的 key print(key) if cursor == 0: break这套代码的退出条件只有一个:游标回到 0。不要自作聪明加“如果这次没返回 key 就 break”之类的条件,因为当 Redis 里的 key 分布得很稀疏时,一次 SCAN 返回 0 个 key 但游标非 0 是完全正常的,你提前 break 就会漏掉后面的数据。
3. 遍历顺序的秘密:反向二进制计数器与那串看似乱跳的游标
我第一次在 redis-cli 里测试 SCAN 时,被返回的游标顺序搞得很懵:表大小不是很大的情况下,scan 0 返回 10,scan 10 返回 2,scan 2 返回 1……游标怎么还带倒着跳的?后来去翻了 Redis 源码里 dict.c 的 dictScan 实现,才搞明白这串数字背后的门道。
要理解 SCAN 的遍历顺序,得先知道 Redis 的 key 是怎么存的。Redis 的键空间本质上是一个哈希表(dict 结构),底层是一个数组加链表。数组的每一个槽位就是一个哈希桶,key 经过 hash 函数计算出一个数值,再跟 mask 做位与运算得到桶下标。这里的哈希表大小永远是 2 的 n 次幂,比如 4、8、16、32,所以 mask 就等于 size - 1。表大小是 4 时,mask 是 3(二进制 11),一个 key 的桶下标就是hash & 3,只可能是 0 到 3。
如果让 SCAN 老老实实按 0、1、2、3 的顺序扫,那就太简单了。但它偏偏不这么干。实际表大小为 4 时,SCAN 的访问顺序是这样的:
0 -> 2 -> 1 -> 3 -> 0(回到 0 表示结束)表大小为 8 时,顺序变成:
0 -> 4 -> 2 -> 6 -> 1 -> 5 -> 3 -> 7 -> 0这个序列乍一看毫无规律,实际上是一种特殊的计数方式,叫“反向二进制计数器”(reverse binary counter)。理解方式很简单:把普通的二进制计数器数值按位反转,就得到了 SCAN 的真正访问顺序。
拿表大小 8 举例,普通计数器从 0 到 7 的二进制依次是 000、001、010、011、100、101、110、111,把每一位倒过来读,就得到:
| 普通计数器值 | 二进制 | 位反转后 | 反转后的值 |
|---|---|---|---|
| 0 | 000 | 000 | 0 |
| 1 | 001 | 100 | 4 |
| 2 | 010 | 010 | 2 |
| 3 | 011 | 110 | 6 |
| 4 | 100 | 001 | 1 |
| 5 | 101 | 101 | 5 |
| 6 | 110 | 011 | 3 |
| 7 | 111 | 111 | 7 |
反转后的序列正好就是 0、4、2、6、1、5、3、7。普通二进制计数器是从最低位开始进位,看起来是 0、1、2、3、4……而反向计数器是从最高位开始进位,所以视觉上就是游标数值在高位和低位之间反复横跳,看起来一点也不“连续”。
Redis 源码里推进游标的核心逻辑就这么几行:
v |= ~m0; v = rev(v); v++; v = rev(v);翻译成人话:先给当前游标按当前 mask 补位,然后做一次位反转,再加一,再反转回来。这个过程的实质就是上面表格里的“反转 -> 加一 -> 反转”,相当于对普通计数器加一,但反转的顺序让进位变成从高位往低位传递。
这个设计不是闲得没事玩位运算,而是为了应对哈希表大小会变化的情况。Redis 的 dict 在元素变多或者变少时会触发扩容和缩容,如果 SCAN 用线性 0、1、2、3 的方式从头扫到尾,表大小一旦在遍历中途变化,已经扫过的桶和还没扫的桶之间的边界就会乱掉,要么漏数据,要么重复扫一大片。反向二进制的遍历顺序就是为 rehash 场景量身定做的,下一节重点讲这个。
顺便说一个小知识:游标在你看来是一个很奇怪的整数,有时候大得离谱,这很正常。因为游标内部不只是桶下标,还编码了一些遍历状态,特别是需要兼容 rehash 过程中两个哈希表的时候,游标的值会包含更多信息。所以始终记住一句话:把游标当成不透明的状态标签,不要尝试解析它,更不要对它做算术。
4. 渐进式 rehash 下的不漏保证:SCAN 最精妙的那块拼图
Redis 的哈希表并不是一直固定大小的,它会根据负载因子动态调整。元素数量达到一定程度就会扩容,空余槽位太多又会缩容。最要命的是,这个扩容和缩容不是一次性完成的,而是“渐进式”的:Redis 会同时维护两个哈希表,旧表 ht[0] 和新表 ht[1],每当客户端执行增删改查操作时,顺带把旧表的一部分桶迁移到新表,后台定时任务也会持续推进迁移。直到旧表全部搬空,才释放掉旧表。
这就是“渐进式 rehash”,它保证了扩容过程不会像 KEYS 那样一瞬间占用大量 CPU。但这也给 SCAN 出了个大难题:遍历进行到一半,哈希表从 4 个桶变成了 8 个桶,旧表里 A 桶的数据可能已经搬到了新表的 X 桶,你说这个数据到底算“扫过了”还是“没扫过”?如果边界不清,重扫或者漏扫都来了。
Redis 能解决这个问题,靠的就是上一节说的反向二进制计数器。把表从 4 扩到 8,旧表里的一个桶会“分裂”成新表里的两个桶。具体对应关系如下:
| 旧表桶(表大小 4) | 扩容后对应的新表桶(表大小 8) | 在 8 表 SCAN 顺序中的位置 |
|---|---|---|
| 0 | 0 和 4 | 第 1、2 位 |
| 1 | 1 和 5 | 第 5、6 位 |
| 2 | 2 和 6 | 第 3、4 位 |
| 3 | 3 和 7 | 第 7、8 位 |
注意看:旧表桶 0 分裂出来的桶 0 和桶 4,在 8 表的 SCAN 访问顺序里是紧挨着的;桶 1 分裂出的桶 1 和桶 5 也是紧挨着的;桶 2 和桶 6、桶 3 和桶 7 同样如此。这正是反向二进制的核心性质:表扩容一倍时,同一个旧桶分裂出的两个新桶,在遍历序列中一定相邻。
有了这个性质,SCAN 在 rehash 过程中就能把进度处理得非常干净。想象一下,当游标扫到旧桶 i 对应的分裂对时,它会在同一轮里同时检查新表里这两个相邻的分裂桶,旧表里还残留的数据也一并遍历;如果游标还没走到这对桶,那么这对桶里的数据一个都不会被提前扫到;如果游标已经走过了这对桶,那么两个都已经扫完。整个过程不会出现“其中一个桶扫了、另一个漏了”的中间状态。
rehash 期间 SCAN 的具体逻辑大致是:拿到游标后,先在 ht[0] 里遍历对应桶的链表;如果正在 rehash,同时也在 ht[1] 里遍历对应桶的链表;然后按反向计数器推进游标。这样,不管 rehash 走到哪一步,遍历结果都不会因为表大小变化而错乱。
说了这么多“不重不漏”,得给 SCAN 的保证范围下一个准确的定义。官方文档对于 SCAN 的语义是这样承诺的:
- 从完整遍历开始到结束,如果某个 key 一直存在,那么它一定会被 SCAN 返回到。
- 遍历过程中新增的 key,可能会被返回,也可能不会。
- 遍历过程中被删除的 key,不被保证一定会被返回。
- 同一个 key 在遍历过程中,有可能会被重复返回。
所以严格来说,SCAN 的核心保证是“不漏掉一直存在的元素”,而不是“完全不会重复返回”。绝大多数情况下重复很少,但你不能拿“绝对不重复”去设计业务逻辑。版本越新的 Redis,在 rehash 时重复返回的概率控制得越稳,但官方文档一直保留这个免责声明,就是希望大家把心态摆正:SCAN 是一个增量遍历工具,不是一个严格的集合快照接口。
对实现细节不感兴趣的同学,只需要记住结论:SCAN 用了一种看似很妖的遍历顺序,本质上是为了在哈希表扩容、缩容时仍然能保持遍历进度的一致性。这个问题的难度比表面看上去高得多,Redis 用十几行代码解决了它,而且解决得相当优雅。这也是我建议每一个用 Redis 的人稍微读一读 dictScan 源码的原因,那种“原来如此”的感觉,比背一百条命令都值。
5. COUNT、MATCH 与循环删除:SCAN 实战的三处陷阱与正确姿势
5.1 COUNT 的真实含义与调参逻辑
COUNT 默认值是 10,它表示“这次命令要检查多少个哈希桶”。再次强调,检查桶数不等于返回 key 数。哈希表的每个桶可能挂着一条很长的链表,也可能一个元素都没有。如果你在 count=10 的 SCAN 里看到一次返回了 300 个 key,不用惊讶;如果你看到一个桶是空的、返回了空列表,下次还得继续传游标扫,也别觉得奇怪。
在实际项目里,COUNT 该怎么定?我的建议是:先从 100 开始,观察 Redis 的延迟曲线和慢日志,如果单次 SCAN 的耗时没有明显上涨,再逐步往上加。线上业务繁忙时,加到 500 到 1000 就要非常谨慎了,因为 count 太大,单次遍历的桶太多,代价会逐渐逼近 KEYS,失去“渐进式”的意义。反过来,count=10 太小,一轮全量遍历需要发起很多次网络请求,总耗时又被拉高了。这里没有绝对正确的值,只有依赖你的实际数据分布和延迟预算去调。
5.2 MATCH 和 TYPE 的过滤,并不是免费的
MATCH 参数支持 glob 风格的通配符,比如MATCH user:*。很多人以为 MATCH 会先从所有 key 里挑出匹配的再返回,所以扫描速度会变快。实际上 MATCH 只是对遍历到桶里的每个 key 做模式匹配,匹配不上的就跳过不返回,但遍历桶本身的工作量一点都没减少。如果模式非常稀疏,比如MATCH *abc*通配符在开头,它每看到一个 key 都要做一遍字符串匹配,成本反而更高。
TYPE 参数是 Redis 6.0 以后加入的,用来过滤 key 的类型,比如TYPE string就只返回字符串类型的 key。做容量分析和清理时这个参数很实用,省得你扫回来一堆 key 再逐个 TYPE 判断。但要注意,TYPE 过滤依然建立在完整遍历桶的基础上,它不是“跳过遍历”,只是“少返回”。
5.3 循环删除 key 的正确姿势与常见死循环
用 SCAN 做批量删除,是生产环境里最刚需的场景之一。这里最容易踩的坑有三个。
第一个坑是退出条件写错。比如下面这段代码:
cursor = 0 while True: cursor, keys = r.scan(cursor, match="tmp:*", count=100) if not keys: break # 错误:keys 为空不代表遍历结束 r.delete(*keys)这个坑我见过好多回:一次 SCAN 返回了空列表但游标还没归零,按这个逻辑一 break,直接把剩余没扫到的数据全部漏掉了。正确的退出条件只有一个,就是游标回到 0。
第二个坑是每个 key 单独 DELETE,RTT 全浪费在网络往返上。正确做法是攒一批然后批量删除,而且删除大量 key 时优先用UNLINK而不是DEL。UNLINK 是异步释放内存的命令,不会因为某个键背后挂着一个几百万成员的集合而卡住事件循环。虽然 SCAN 本身已经不阻塞了,但如果你用 DEL 删大 key,那一瞬间的阻塞可能比 KEYS 更致命。
import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) cursor = 0 while True: cursor, keys = r.scan(cursor, match="tmp:*", count=500) if keys: r.unlink(*keys) if cursor == 0: break第三个坑是“边遍历边新增匹配 key”导致一轮永远结束不了。SCAN 遍历过程中,如果业务侧持续写入新 key,且新 key 恰好落在还没扫描的桶里,那这轮遍历在回到 0 之前还是会扫到它们。万一写入速度极快,可能出现“每轮都能扫到新 key”的现象。这不是 SCAN 的 bug,而是你的业务在做全量清理的同时又有源源不断的数据产生,这种场景应该先暂停写入,或者在应用层加一个循环次数上限,防止脚本无限跑下去。
5.4 游标的跨实例问题
SCAN 的游标只在同一个 Redis 实例的同一个 db 内有效。如果你在带主从的环境里切换了连接节点,或者实例重启过,旧游标最好直接抛弃,从 0 重新开始,否则扫描结果可能不符合预期。数据迁移类任务如果一定要断点续传,必须自己把游标持久化到应用侧,并且明确知道这台节点和上次扫的是同一个节点。
6. HSCAN / SSCAN / ZSCAN 与渐进式遍历思想的延伸应用
6.1 家族命令逐个说
SCAN 解决的是“遍历整个键空间”的问题。但很多时候我们不需要遍历所有 key,只需要把某一个 key 下面的大集合翻一遍。比如一个哈希键底下挂了 50 万个 field,一个 Set 里存了 100 万个成员,如果直接HGETALL或SMEMBERS,Redis 要把整个数据结构一次性序列化并发送,内存和带宽都受不了。这时候就该用它们的渐进式版本了。
HSCAN 的用法:
HSCAN myhash 0 MATCH field:* COUNT 100返回结果是一串 field 和 value 交替出现的列表,游标机制和 SCAN 完全一致。SSCAN 遍历 Set 成员,ZSCAN 遍历有序集合的成员和分数。它们三个的参数和 SCAN 一模一样,也支持 COUNT 和 MATCH,只是作用对象从“整个键空间”变成了“某个 key 内部的数据结构”。
我自己写数据迁移脚本时,最常用的组合就是 SCAN + HSCAN / SSCAN / ZSCAN 嵌套:先用 SCAN 扫出所有符合条件的 key,再对每个 key 判断类型,如果是大集合就再开一轮渐进式遍历去读取内部数据。这样整个过程里,Redis 永远不会被某一条命令压垮。
6.2 渐进式遍历思想在更大范围的延伸
如果你只看命令本身,会觉得 SCAN 只是一个“安全版的 KEYS”。但把这个思想放大,你会发现 Redis 很多底层机制都在用同样的套路:rehash 是渐进式的,把一次大迁移摊到多次操作里;主从全量同步时,生成 RDB 的同时还在持续接收写请求,本质上也是把“快照 + 增量”分摊到不同时间窗口。SCAN 是这套思想在命令层面的一个典型代表。
落地到实际工程里,SCAN 最常见的延伸场景有这几类:
- 在线数据清理:低峰期跑 SCAN + MATCH + UNLINK,分批清掉过期前缀的 key。
- 数据迁移与同步:用 SCAN 扫全量 key,逐个读值后写入目标端,配合双写机制做增量切换。
- 容量分析与大 key 发现:redis-cli 自带的
--bigkeys选项,底层原理就是循环执行 SCAN,每次 count=10,把扫到的 key 按类型统计大小,然后输出 Top 大 key。这个工具在生产环境相对安全的原因就是它用了渐进式遍历。 - 数据校验与抽样:想对线上数据做随机抽样,不需要 KEYS 全量拉下来,用 SCAN 的游标跳转天然就会均匀地访问各个桶。
还有一个小技巧:如果线上压力很高,而你又必须做一次全量扫描,可以给 SCAN 循环加上节流逻辑。比如每轮扫描后 sleep 20 毫秒,把整体 QPS 压到很低,避免多轮 SCAN 叠加后对实例形成持续压力。这招看着土,但真的能让你在业务高峰期也能安全地跑清理任务。
6.3 别把 SCAN 当事件流用
最后提醒一个方向性的错误:SCAN 是“全量遍历”工具,不是“增量监听”工具。千万别搞一个每 100ms 轮询一次的 SCAN 循环,去模拟 key 变更事件。SCAN 本身要遍历哈希桶,轮询频率越高,对实例的持续负担越重,而且它返回的数据天然带有重复和延迟,你还要去做去重逻辑,得不偿失。真正要监听 key 变化,应该用 Redis 的 keyspace notification 机制,或者把变更信息写入 Stream,让下游按需消费。工具选型错了,后面怎么优化都是白费劲。
从我自己的使用习惯来说,凡是需要在大数据量上做统计、清理、迁移的脚本,第一选择永远是 SCAN 家族。先把 count 调到 100,跑一小段观察延迟,再逐步加码,整个过程保持敬畏心。Redis 单线程这个特性,决定了任何“省事”的全量操作都可能变成事故导火索。渐进式遍历虽然让脚本写得稍微绕一点,但它换来的,是线上服务的平稳和你半夜不用爬起来处理告警的睡眠质量。