☰
亿级Redis Set安全遍历:SSCAN游标原理与生产级避坑实践
2026/10/12 19:07:18 网站建设 项目流程

我接手过一个老旧的统计服务,里面有个 Set 集合存放用户标签,日积月累到了九千多万个元素。一开始图省事,直接用 SMEMBERS 一把梭把全量数据拉出来做离线分析,结果某天下午线上直接超时爆炸,慢日志里全是大 KEY 扫描的告警。后来换成了 SSCAN 做分批遍历,本以为万事大吉,结果又在亿级规模下踩了新坑:游标转得飞起,返回的元素却少得可怜,明明加了 COUNT 参数,遍历耗时却比 SMEMBERS 还离谱。这篇就聊聊我用 SSCAN 安全遍历海量 Set 集合的经验,包括那些官方文档不会明说但我实测踩过的坑。

这篇文章适合正在用 Redis 存大集合的后端开发、运维和数据工程师阅读,特别是你的 Set 集合已经超过百万、甚至千万亿级,又或者你正准备把 SMEMBERS、KEYS 这种"全表扫描"操作替换成游标遍历。我会把 SSCAN 的原理拆开讲清楚,然后给出一套能直接抄作业的配置和代码模板,最后附上我自己经历过的线上事故复盘。

1. 先搞清楚:SMEMBERS 和 KEYS 为什么会成为灾难

很多人第一次遇到"遍历 Redis 大集合"的需求时,脑子里蹦出的第一个工具往往是 SMEMBERS 或者 KEYS。这不是你的错,因为这俩命令太直观了,一个返回所有成员,一个匹配所有键,一看就懂。但在亿级数据面前,这俩命令就是帮你按下生产事故按钮的那只手。

1.1 SMEMBERS 的全量返回到底发生了什么

SMEMBERS 的逻辑非常简单粗暴:一次性把所有成员从内存中取出来,通过网络全部返回给客户端。如果集合里有五千万个元素,哪怕每个元素平均只有 20 个字节,算上 Redis 内部的对象头、指针、链表节点这些开销,一次返回的数据量也在几个 GB 级别。这就有两个直接后果:

第一,Redis 是单线程模型,SMEMBERS 在执行期间,所有其他命令全部排队等待。取出五千万个元素并序列化传输,这个过程会让服务端阻塞几十秒甚至几分钟,期间所有读写请求全部卡死,表现就是"连接超时""命令超时",甚至触发哨兵/集群的节点健康检查失败,导致主从切换。

第二,客户端要一次性接住这几 GB 数据,内存直接被打满。如果客户端是 Java 写的,这几 GB 的 List 对象会瞬间挤爆堆内存,紧接着就是 GC 风暴、OOM,最后应用进程挂掉。我见过不止一次,统计服务里一个 SMEMBERS 下去,Redis 和业务应用一起躺平,那场面简直灾难片。

1.2 KEYS 命令的隐患其实和 SMEMBERS 同源

KEYS 命令的问题更隐蔽。它遍历的是整个键空间,如果库里有几十万个键,用 KEYS 去匹配,和 SMEMBERS 一样,也是全量扫描、一次性返回。它同样会阻塞 Redis 主线程,也会一次性捞回大量数据。

很多人知道 KEYS 不能用,但理解往往停留在"它很慢"或"它会阻塞",这还不够。真正需要理解的是:KEYS 和 SMEMBERS 的本质都是"全表扫描",时间复杂度 O(N),N 是全部键或全部成员。这类操作在规模小的时候无所谓,一旦 N 涨到百万、千万、亿,O(N) 阻塞主线程的代价就是妥妥的生产事故。

提示:如果你只是要在本地测试环境里看几个键,用 KEYS 问题不大。但凡是线上环境,无论是 KEYS 还是 SMEMBERS,都别碰。

1.3 什么时候你才真正需要遍历一个大 Set

绕了一圈,你可能会问:"那我干脆不遍历不就行了?"问题在于,很多业务场景绕不开。我整理了一下,主要在下面这些情况下你必须遍历一个大 Set:

  • 数据导出:把某批用户 ID 导出来给数仓做分析,或者同步到另一个存储。
  • 数据清洗:集合里混入了脏数据,需要逐个检查、过滤掉。
  • 数据迁移:要把 Redis 里的集合搬到新的集群或者新的 key,比如拆分大 key。
  • 统计盘点:需要知道集合里有多少元素满足某个模式,或者统计元素分布。

这些场景的共同点,是你必须看到每一个成员,但你不需要一次性看到全部。这时候就轮到 SSCAN 登场。

2. SSCAN 的游标机制,到底是怎么安全遍历的

SSCAN 的核心思路是"化整为零":它不让 Redis 一次性把结果全吐出来,而是每次只返回一小批数据,同时给客户端一个"游标",让客户端拿着这个游标继续取下一批,直到游标回到 0,表示遍历完成。

2.1 一次 SSCAN 调用的基本姿势

SSCAN 的完整命令格式是:

SSCAN key cursor [MATCH pattern] [COUNT count]

举个例子,假设 key 叫user_tags,从游标 0 开始遍历:

SSCAN user_tags 0 COUNT 500

Redis 返回两个值:第一个是"下一个游标",第二个是这批返回的成员列表。如果返回的游标是 0,说明遍历结束了;如果游标不是 0,就拿着它继续调:

SSCAN user_tags 12456 COUNT 500

这里的游标,你可以理解成一个"书签",Redis 拿着这个书签,记录自己扫描到哪个位置了。和很多人想象的不同,它不是一个递增的数字序号,而是一个不透明的算术状态,你唯一能做的就是把它原样传回去,千万不要自己改动它,更不要试图从中解读位置信息。

2.2 COUNT 参数的真面目:不是"返回多少条",是"努力扫描多少"

这是新手最容易误解的地方。看名字 COUNT,大多数人会以为它等于"每次返回多少条数据"。我在刚开始用的时候也这么以为,结果设置 COUNT 500,发现经常返回十几条,甚至偶尔返回 0 条,一度以为命令有问题。

实际上的原理是这样的:SSCAN 底层是对哈希表做增量迭代,COUNT 参数控制的是"本次迭代要遍历多少个哈希桶"的期望值,而不是"要返回多少个元素"。遍历多少个桶,和桶里面实际装了多少元素,是两码事。哈希表里很多桶是空的,一个桶里如果挂了一个很长的链表(哈希冲突严重),那这一个桶里可能有几十上百个元素;反过来,如果大部分桶是空的,你在 count 个桶里可能只找到零星几个元素。

所以更准确的理解是:COUNT是一个 hint(提示),表示你期望这次调用扫描的桶数量级别。Redis 官方建议默认值是 10,但实际使用时,在亿级集合上,这个值太小会导致调用次数太多、RTT 开销太大。我自己一般设置在几百到几千之间,但不能盲目加大。

2.3 游标迭代的"一致性"承诺,别指望太多

SSCAN 和它的兄弟们(SCAN、HSCAN、ZSCAN)有一个共同的特点:它们在遍历期间,并不保证"快照一致性"。什么意思呢?

如果在遍历过程中,有其他客户端往集合里添加了新元素,那么这个新元素有可能被这次遍历读到,也有可能读不到,这取决于它被插入到了哪个哈希桶、这个桶有没有被扫描过。反过来,如果在遍历过程中删除了某些元素,已删除的元素也有可能出现在结果里,因为删除动作发生时,可能还没轮到扫描那个桶。

官方对此的表述,我用自己的话翻译一下:

  • 如果一个元素在整个遍历期间从头到尾都存在,那么它一定会被返回。
  • 如果一个元素在遍历开始前被删除,或者遍历结束后才被添加,那么它一定不会被返回。
  • 如果一个元素在遍历中途被修改或删除,那它对本次遍历"不可见",但 Redis 不保证遍历结果里绝不含它。

这意味着,SSCAN 提供的是"大致一致性"的遍历结果,不是严格快照。如果业务上要求绝对严丝合缝的一致性快照,SSCAN 做不到,你只能考虑在低峰期做,或者用其他方案(比如临时锁写入)来配合。

3. 亿级 Set 的隐藏陷阱:编码转换让 COUNT 失效

讲到这里,我得把我踩过的最大的坑单独拿出来说。很多教程会告诉你"SSCAN 很快、很安全、不会阻塞",这句话在大多数情况下没错,但有个前提:当集合规模还停留在"较小"或者"中等"时,这个结论成立。一旦集合膨胀到亿级,SSCAN 的 COUNT 参数有可能会失效,遍历性能会突然恶化到你怀疑人生。

3.1 小集合时 Redis 根本不用哈希表

Redis 对 Set 集合的存储,是有"编码"优化的,翻译成大白话就是:同一个 Set,在元素少的时候,用精简数据结构存,在元素多的时候,切换成另一种数据结构。

整数组成的 Set,在元素数量小于 512 个时,使用 intset 编码,底层是一个有序的整数数组,查找和遍历非常快,内存也很省。字符串组成的 Set,在元素数量小于 512 个且每个元素长度较短时,使用 listpack 编码,底层是一块紧凑的内存块,同样是省内存、快遍历。

但元素涨到 512 个以上,或者元素长度超过阈值,Redis 就会把编码切换成 hashtable,也就是真正的哈希表结构。游标遍历的机制,只对 hashtable 编码有效。所以当你的 Set 还在 intset 或 listpack 编码阶段,执行 SSCAN 的语义和效果,和后续在 hashtable 阶段有细微差异。

3.2 真正的灾难:hashtable 扩容与 rehash 撞上遍历

当 Set 切换成 hashtable 后,随着元素继续暴涨,哈希表的容量(桶的数量)会不断翻倍扩容。Redis 的哈希表扩容不是一次性完成的,而是采用"渐进式 rehash",也就是把数据从旧桶一点点搬到新桶。这个搬迁过程,和 SSCAN 遍历过程如果叠加在一起,就会产生一个极其隐蔽的恶性问题。

什么问题?我来拆解一下。假设集合有六千万个元素,哈希桶数量会根据负载因子不断调整,桶数量通常维持在和元素数量一个量级。当扩容发生时,旧表和新表的桶总数会暂时同时存在,SSCAN 为了保证不漏数据,必须同时遍历两张表。

这时候 COUNT 参数就失灵了。COUNT 期望控制的是"本次扫描多少个桶",但在 rehash 期间,你要遍历的桶数量是旧表加新表的总和,实际扫描的桶数可能会远超 COUNT 值。更难受的是,扩容过程中大量元素从旧表挪到新表,你在旧表扫描时,会发现很多桶已经搬空了,扫描半天拿到空手而归;去新表扫吧,新表的大部分桶还没被填入,又是空空如也。一轮 SSCAN 调用下去,你说你期望扫描 500 个桶,实际可能访问了几千个桶,返回的元素却寥寥无几。

3.3 哈希轮转越跑越慢,最终拖垮主线程

你可能会说:"调用慢一点就慢一点,多调用几次不就行了?"问题没这么简单。SSCAN 的单次调用虽然不是 O(N) 全量阻塞,但在哈希表处于巨大规模且持续 rehash 的状态下,单次遍历的耗时也会显著上升。这个耗时虽然比 SMEMBERS 短,但它是实实在在占用 Redis 主线程执行的。当你用脚本循环调用 SSCAN,比如要遍历一亿个元素,以每批 500 个为例,就要循环二十万次。

这二十万次每次都要在主线程上跑一段字典扫描,叠加起来的总耗时是非常恐怖的。在高频访问的线上环境,这种"温水煮青蛙"式的阻塞,比一次性 SMEMBERS 那种"瞬间致命"更阴险——它不会立刻把 Redis 打死,但会让平均延迟持续走高,引发大量毛刺超时,也更容易被监控误判为"慢节点"甚至触发主从切换。

我在一个客户现场排查的时候,发现他们 SSCAN 一次调用平均耗时从开始时的 2 毫秒,逐步恶化到 50 毫秒以上,最终导致某个核心接口的 P99 从 30 毫秒涨到八百毫秒。翻开慢日志,全是 SSCAN 的调用,但每条命令看起来都不算特别夸张。这就是 rehash 期间的隐性阻塞。

注意:我的经验是,在集合规模预估超过千万级别时,SSCAN 之前先检查一下集合的编码和容量状态。如果它正在大量扩容,最好错峰遍历,否则 COUNT 指示会失真,遍历成本会远超预期。

3.4 判断你的 Set 是否处于扩容状态

写代码的时候怎么判断?有两条路。

第一条,用DEBUG OBJECT key查看底层信息。比如:

DEBUG OBJECT user_tags

返回结果里包含encoding字段和serializedlength字段,前者能看出是 hashtable 还是其他编码,后者能大概估计序列化后的长度。这个命令在生产环境不是任何时候都能用,但排查问题时可以临时开一下。

第二条,用INFO key或者OBJECT ENCODING。OBJECT ENCODING能直接看编码类型:

OBJECT ENCODING user_tags

如果是hashtable,说明已经是大集合形态了。但要注意,OBJECT ENCODING只能告诉你当前编码,看不出来 rehash 进行到哪一步,这个细节在以后版本里也许会有更好的暴露方式,但目前只能通过观察延迟变化来间接判断。

4. 我在生产环境使用的 SSCAN 安全遍历方案

光说不练假把式。接下来这套方案,是我在多个项目里实际跑过、确认能稳定扛住千万到亿级 Set 集合的配置组合和代码模板,可以直接抄。

4.1 COUNT 参数选择策略

COUNT 值的选取,我一般是分两档:

  • 如果你遍历的集合规模在百万以下,直接COUNT 100就行,调用次数不会太多,Redis 主线程压力也很小。
  • 如果在千万级以上,我倾向于COUNT 1000左右,偶尔用到COUNT 5000。这里的逻辑是:在一次 SSCAN 调用中,允许 Redis 多扫描一些桶,用单次调用的耗时增加,换取总调用次数的减少,从而降低 RTT(网络往返)带来的总开销。

但你得清楚,COUNT 在 rehash 期间会失真,所以它不是越大越好。过大反而会让单次调用耗时突然飙高,在主线程上制造一次明显延迟尖刺。我做过测试,在接近一亿元素的 Set 上,COUNT 5000的单次 CPU 耗时比COUNT 1000翻了两倍以上,但返回元素数并没有等比例提升,因为大量时间花在遍历空桶上。综合来看,COUNT 1000在吞吐和延迟之间比较平衡。

4.2 MATCH 参数的"全量扫描依然存在"陷阱

很多人以为用MATCH参数就能"只遍历匹配的元素",会少干很多活。这是第二个幻觉。MATCH只是在 SSCAN 已经遍历到的元素里做一遍模式过滤,它不会跳过哈希桶,也不会减少扫描量。

举个例子:

SSCAN user_tags 0 MATCH user:* COUNT 1000

Redis 照常扫描一千个桶,照常把这些桶里的元素拿出来,然后再把不符合user:*模式的元素丢掉,只返回匹配项。如果你的集合里只有万分之一的元素匹配这个模式,那你可能会发现,游标跑了好几十轮,一条结果都没返回。

这不是 SSCAN 性能变好了,恰恰相反,你付出的扫描代价和不加 MATCH 是完全一样的。所以,如果需要遍历大量数据,注意别指望 MATCH 帮你"减负"。

4.3 并发遍历的正确姿势

如果你的业务场景里,遍历时间窗口要求很紧,你可能会想到并发。这里有个好消息和一个坏消息。

好消息是,Redis 官方文档明确说,因为游标机制本身是无状态的,你完全可以把遍历任务拆成多路并发,每路从不同的初始游标开始独立遍历,最后把结果合并。理论上这能线性提升遍历速度。

坏消息是,你没法选择一个简单的"平均分配"策略,因为游标不是简单递增的数字,你没法保证两个游标各管一半的桶。如果硬要并发,常见做法是起若干个 worker,每个 worker 独立跑一个完整的 SSCAN 流程,它们从同一个游标 0 开始,但这不是"分片",而是"各跑各的",最后所有 worker 会遍历整张表,数据重复采集也不奇怪。

我自己的经验是:除非遍历时间紧迫到不能用串行解决,否则别上并发。因为并发 SSCAN 会放大对 Redis 主线程的扫描压力,尤其当集合在 rehash 时,更是雪上加霜。真需要并发的时候,我只建议把集合先拆分成多个子 key,各自独立遍历,再加总结果,这才是健康的分片方式。

4.4 一个可直接参考的 Python 封装

我习惯把 SSCAN 遍历封装成一个生成器,不管集合多大,都用相同的方式消费它。下面这段 Python 代码基于 redis-py,实测在线上稳定运行过:

import redis def sscan_all(r: redis.Redis, key: str, batch: int = 1000, pattern: str = None): """ 安全遍历 Redis Set 集合的全部元素。 """ cursor = 0 while True: cursor, members = r.sscan(key, cursor, match=pattern, count=batch) for member in members: yield member if cursor == 0: break

关键点是我写了个死循环,用cursor作为唯一状态,cursor == 0作为终止条件。注意,首次调用cursor传 0,之后每次传返回的游标,绝对不能自己想当然地递增或拼接。

如果你的业务需要边遍历边删除,那倒要小心了。虽然 SCAN 系列命令"在遍历过程中删除当前元素是安全的",但你要确保删除策略不会反过来影响哈希表的 rehash 进度,否则可能引发额外的扩容压力。总体上我建议:遍历归遍历,删除归删除,尽量拆成两个阶段,先收齐要删的元素,再统一去删。

4.5 时间预算与渐进式遍历

对于亿级集合,我再推荐一个思路:把整个遍历过程拆成多次任务执行,而不是一次性脚本跑完。比如一共要遍历一亿个元素,就设计成按游断点恢复的任务,每轮任务最多跑两分钟,处理完一批就把当前游标存到临时存储里,下一轮从游标继续。

你可以利用 SSCAN 游标本身就支持断点的特性,把游标序列化保存到 MySQL 或 Redis 的另一个普通 key 里。举个例子:一轮任务执行了 5000 次 SSCAN,消耗了 3 秒,游标停在了某个位置,就把这个值记录下来;下一轮任务从该游标继续,一直重复到游标归零。

这个方案的好处,一是避免单次长任务占用主线程太久;二是如果编排平台把任务中断了,你不需要从头再来,直接从断点续跑就行。我实际在多人协作的数据管道里就是这么干的,效果非常稳。

5. 真实事故复盘:一场由 SSCAN 引发的 P2 故障

光讲方案不回顾事故,总觉得少了点什么。这里分享一个我参与排查过的典型案例。

5.1 事故现象:接口延迟突然飙升

某天上午十一点,核心业务接口的 P99 延迟突然从 40 毫秒涨到 600 毫秒,接口成功率从 99.99% 掉到 98%。监控面板上 Redis 实例的 CPU 使用率涨到 90% 以上,慢日志里大量出现SSCAN命令,单次耗时在 60 到 200 毫秒之间。

打开慢日志的时候,我一眼就盯上了那些 SSCAN 记录。这个 key 是一个用户行为的标签集合,当时大概有一亿多个元素,而且因为在做数据回补,上游服务一直在往里写新数据。用大白话讲,这个集合正处在"一边膨胀一边被扫描"的高危状态。

5.2 排查路径:为什么 SSCAN 会变慢

我们拉取了 INFO 里的hash_max_listpack_entries和hash_max_listpack_value配置,同时用 OBJECT ENCODING 确认,目标是 hashtable 编码。接着我们做了个小实验:手工执行SSCAN key 0 COUNT 1000,发现返回的元素数量比预期少,而耗时波动非常大,有些时候 20 毫秒,有些时候能到 180 毫秒。

再对照 INFO 的used_memory和mem_fragmentation_ratio,结合INFO stats里的expired_keys数量波动,我们判断问题出在"持续写入导致哈希表频繁扩容,rehash 撞上游标遍历"。每次 SSCAN 在执行时,Redis 既要访问旧桶,又要访问新桶,还要处理搬迁,CPU 消耗自然暴涨。

5.3 止损动作:三个措施快速止血

事故处理讲究又快又稳,我们当时的动作是:

第一步,停止刷数据。把上游向这个 Set 里写入的定时任务临时暂停。写入停了,rehash 的触发压力立刻减小,遍历扫描的耗时马上回落。

第二步,把 SSCAN 的 COUNT 值调回去。原本脚本用的是 COUNT 5000,后来改成 COUNT 1000,虽然调用次数增加了,但单次延迟尖刺明显减少,整体对接口的影响反而变小。

第三步,排查调用链,发现是这个统计任务在高峰期抢占了 Redis 主线程,于是直接把统计任务挪到凌晨低峰期执行,并把遍历断点持久化,设计了可续跑机制。

5.4 教训总结:SSCAN 也需要"错峰出行"

这个事故给我的教训很深刻。SSCAN 确实解决了 SMEMBERS 一次性全量返回的问题,但它并不是"免费的午餐"。它本质还是在 Redis 主线程上做扫描,只是把一次性的大阻塞切成了很多次小阻塞。如果你的业务场景是那种对延迟极其敏感的在线接口共享同一个 Redis 实例,那就算每次 SSCAN 只阻塞 20 毫秒,在高峰期也够让用户体验吃一壶了。

我现在做方案设计时,有一条硬性原则:需要遍历超大集合的任务,必须错峰执行,且要评估对公共 Redis 实例的影响。如果一个 Redis 实例上承载了多个业务的在线请求,大集合遍历任务甚至有义务单独拆到一个独立的 Redis 实例去跑,不能为了省机器而绑架所有业务。

6. 避坑清单与可行的扩展思路

最后整理一份我在实战中提炼的避坑清单,方便你对照检查自己的代码和配置。

序号坑点危害解决方案
1用 SMEMBERS 遍历大集合主线程阻塞,客户端 OOM改用 SSCAN 分批遍历
2用 KEYS 匹配大键空间全量扫描阻塞改用 SCAN 命令
3误以为 COUNT 是"返回条数"结果条数与预期不符,误判命令问题理解 COUNT 是扫描桶数的 hint
4在 rehash 期间大 COUNT 遍历单次耗时飙升、延迟尖刺降低 COUNT 值、错峰遍历
5依赖 MATCH 减少扫描量实际扫描量与不匹配时相同,性能无提升只在过滤结果有用时使用
6并发多个 SSCAN 客户端分片遍历游标无法简单分片,可能重复或漏数据使用单线程串行遍历,或拆分 key
7遍历期间写入大量新元素哈希表频繁扩容,rehash 拖慢游标暂停写入或错峰执行
8一次写脚本跑完整个遍历长任务占用主线程,中断后从头再来用游标断点续跑,分批次执行

扩展方面,如果你觉得在同一个 key 上做亿级遍历始终不踏实,还可以考虑在业务设计上就规避大 Set:

  • 提前拆分 key,比如按业务维度、按时间分桶,把每个 Set 控制在可接受的大小(比如十万以内),这样就算哪天被迫用 SMEMBERS 也不会出大事。
  • 用 HyperLogLog 替代那些只需要统计基数的场景,它能用极少的内存给出一个接近精确的基数估计,但注意它不能做真正的成员遍历。
  • 如果需要频繁取出最新的 N 个成员,不如考虑用 Sorted Set,按分数排序后可以直接用ZRANGEBYSCORE取局部数据,不必全量遍历。

我个人在实际项目中的体会是:SSCAN 是"最后一道安全网",用它来兜底处理那些已经长成巨兽的集合是没问题的,但更根本的办法是在架构层面避免长出巨兽。你在设计阶段就要评估一个 Set 会不会涨到百万、千万,如果是,最好提前把拆 key 的预案做好。真等容到亿级才想起来优化,哪怕用 SSCAN,成本也会比一开始就设计好大很多。

最后再分享一个小技巧:上线任何一段依赖 SSCAN 的遍历逻辑前,先用一个测试环境造一份和线上量级相当的数据集,人为制造扩容、写入、并发等场景,压一遍看看 SSCAN 的耗时曲线和返回条数分布。我自己每次做这种改动都会把 SSCAN 的调用耗时记入日志,持续观察几天,一旦发现单次耗时突增或者返回条数严重低于预期,就能提前定位问题,而不是像事故复盘那样被动救火。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询