我接手过一个老旧的统计服务,里面有个 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 500Redis 返回两个值:第一个是"下一个游标",第二个是这批返回的成员列表。如果返回的游标是 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 1000Redis 照常扫描一千个桶,照常把这些桶里的元素拿出来,然后再把不符合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 的调用耗时记入日志,持续观察几天,一旦发现单次耗时突增或者返回条数严重低于预期,就能提前定位问题,而不是像事故复盘那样被动救火。