Redis的集合类型(Set)在日常业务中使用频率很高,比如标签系统、用户关注关系、去重统计等场景都会依赖它。当集合规模较小时,使用SMEMBERS一次性取出全部元素没有任何问题,可一旦集合里存了几百万甚至上千万个元素,这条命令就会成为隐患:Redis是单线程处理请求的,一条耗时几百毫秒甚至几秒的大命令会阻塞后续所有请求,直接表现为接口超时、上游服务雪崩。解决这个问题的正确姿势就是本文的主角——SSCAN命令,它通过游标机制实现集合的渐进式遍历,把一次大读取拆分成无数个轻量级小请求。

SSCAN命令的基本用法与返回结构
SSCAN是SCAN命令家族中的一员,专门用于遍历集合类型的数据。家族中还包括遍历哈希的HSCAN、遍历列表以外集合的SCAN(遍历数据库中所有键)以及遍历有序集合的ZSCAN。SSCAN的基本语法如下:
SSCAN key cursor [MATCH pattern] [COUNT count] [NOVALUES]
命令接收三个核心参数:cursor是遍历游标,第一次调用时传0表示从头开始;MATCH用于模式匹配过滤元素;COUNT则是单次遍历的提示值。命令的返回值是一个二元数组,第一个元素是下一次遍历要使用的新游标,第二个元素是本次取到的元素列表。当返回的游标重新变为0时,说明整个集合遍历完成。
典型的遍历流程是一个循环:以0作为初始游标发起请求,拿到一批元素和新的游标,判断新游标是否为0,不为0就继续用新游标发起下一次请求,如此往复直到游标归零。这个过程把一次性的大阻塞操作切分成了多次小操作,每次请求只占用Redis极短的处理时间,中间还可以插入其他客户端的正常请求,这就是渐进式遍历的核心价值。
游标机制与反向二进制迭代原理
很多开发者好奇,SSCAN为什么能在遍历过程中容忍元素的增删而不漏掉数据?这要从它的底层实现说起。Redis内部对集合有两种编码:当元素较少且都是整数时使用intset编码,元素增多后转为哈希表编码。SSCAN针对哈希表编码的遍历采用了反向二进制迭代算法(reverse binary iteration)。
简单来说,遍历的顺序不是简单地从下标0依次递增到桶数量,而是按照二进制加法进位的反向顺序进行。举例来说,假设哈希表有8个桶(下标0到7),普通顺序是0、1、2、3、4、5、6、7,而反向二进制迭代的顺序是000、100、010、110、001、101、011、111,也就是4、2、6、1、5、3、7这样的次序。这种设计的精妙之处在于:如果遍历期间哈希表发生了扩容(桶数量翻倍),原本一个桶的数据会被分摊到两个新桶中,而这两个新桶的下标在反向迭代顺序中恰好是连续被访问的,从而保证扩容过程中已访问的数据不会遗漏,未访问的数据也能被覆盖到。
当然,渐进式遍历并不能提供强一致性快照。遍历过程中新加入的元素可能被返回也可能不被返回,被删除的元素同理。SSCAN提供的是完整性保证——开始遍历前就存在的元素,只要没被删除,一定会被返回。对于缩容场景,算法同样能保证不漏数据,代价是可能出现元素重复返回。因此在使用SSCAN时,如果业务要求元素只处理一次,需要在客户端做去重处理。
客户端代码实战示例
下面用几种常见语言演示SSCAN的完整遍历写法。首先是Python的redis-py客户端:
import redis
r = redis.Redis(host='127.0.0.1', port=6379, db=0)
cursor = 0
all_members = set()
while True:
cursor, members = r.sscan('user:tags', cursor=cursor, count=1000)
all_members.update(members)
if cursor == 0:
break
print('共获取 %d 个元素' % len(all_members))PHP的phpredis扩展同样支持sscan方法,用法和命令行基本一致,下面演示一个带MATCH过滤的例子,只取出以vip开头的元素:
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$cursor = null;
$result = [];
while (true) {
$keys = $redis->sscan('user:tags', $cursor, 'MATCH', 'vip*', 'COUNT', 500);
if ($keys === false) {
// 空结果时部分版本返回false,需检查游标判断是否结束
if ($cursor === 0) {
break;
}
continue;
}
$result = array_merge($result, $keys);
if ($cursor === 0) {
break;
}
}
echo '匹配到 ' . count($result) . ' 个元素' . PHP_EOL;Java开发者常用Jedis,它封装了scan方法并返回ScanResult对象,通过isCompleteIteration或判断游标字符串是否为0来控制循环:
Jedis jedis = new Jedis("127.0.0.1", 6379);
String cursor = ScanParams.SCAN_POINTER_START;
ScanParams params = new ScanParams().count(500).match("vip*");
Set<String> result = new HashSet<>();
while (true) {
ScanResult<String> scanResult = jedis.sscan("user:tags", cursor, params);
result.addAll(scanResult.getResult());
cursor = scanResult.getCursor();
if ("0".equals(cursor)) {
break;
}
}
System.out.println("共获取 " + result.size() + " 个元素");COUNT参数的误区与性能调优
关于COUNT参数存在一个常见误解:很多开发者以为COUNT就是精确的返回数量,设置1000就一定返回1000个元素。实际上COUNT只是一个提示值(hint),Redis会以它为基础去检查相应数量的底层桶,最终返回的元素数量可能少于也可能略多于这个值。默认值是10,在生产环境中遍历大集合时,建议根据元素平均大小和网络往返延迟适当调大,比如设置为500到2000之间,可以在单次请求耗时和总请求次数之间取得平衡。
调优的基本思路是权衡两点:COUNT太小会导致请求次数过多,网络往返开销累积起来同样可观;COUNT太大则单次命令处理时间变长,可能重新逼近阻塞红线。一个实用的经验是先用1000测试,观察单次SSCAN的执行耗时(可以用Redis的慢日志辅助验证),确保单次耗时控制在1毫秒以内,再据此微调。另外需要注意,MATCH过滤是在服务端取回元素之后才执行的,即使模式匹配后只剩少量元素,Redis依然要扫描COUNT指定规模的桶,所以过滤条件强、命中率低的场景不要指望靠MATCH省时间。
SSCAN与SMEMBERS、KEYS的对比及大集合删除方案
从功能上看,SSCAN和SMEMBERS都能获取全部集合元素,但适用场景截然不同。下面对比两者的关键差异:
| 对比维度 | SMEMBERS | SSCAN |
|---|---|---|
| 返回方式 | 一次性全量返回 | 分批渐进式返回 |
| 对服务的影响 | 大集合时阻塞严重 | 单次耗时可控,几乎无感 |
| 数据一致性 | 命令执行瞬间的一致性快照 | 弱一致,可能重复或漏掉遍历期间新增元素 |
| 内存占用 | 客户端需一次性承载全部结果 | 按批处理,内存占用恒定 |
同样的对比逻辑也适用于SCAN和KEYS的关系:线上环境严禁使用KEYS *遍历键空间,必须改用SCAN。SSCAN还有一个高频实战用途——安全删除超大集合。直接执行DEL删除一个千万级元素的集合,同样会引发长时间阻塞,正确做法是用SSCAN分批取出元素,配合SREM批量删除,元素删空后再删掉键本身:
import redis
r = redis.Redis(host='127.0.0.1', port=6379)
cursor = 0
while True:
cursor, members = r.sscan('huge:set:key', cursor=cursor, count=1000)
if members:
r.srem('huge:set:key', *members) # 批量移除本批元素
if cursor == 0:
break
r.delete('huge:set:key') # 最后清理空键这种删除方式每一步的耗时都极短,即使中途出现异常也可以安全重试,因为元素是否被删除可以通过再次SSCAN验证,具备幂等性。总结来说,凡是面对Redis中的大集合操作,都应该优先考虑SSCAN这类渐进式方案:它牺牲了一点实现复杂度和严格一致性,换来的是服务的持续可用,这在生产环境中无疑是一笔划算的交易。
Redis SSCAN渐进式遍历集合遍历修改时间:2026-09-10 14:35:07