拒绝全表扫描灾难:用 SSCAN 安全遍历 Redis 亿级 Set 集合


案发场景:

你的系统里有一个全网黑名单集合 security:blacklisted_users,里面躺着 500 万个违规用户 ID。

某天,安全部门要求把这份名单全量导出来做离线分析。

你的新人同事大手一挥,在 Java 里写下了一行:Set<String> allUsers = redis.opsForSet().members("security:blacklisted_users");
毁灭瞬间:

代码上线的瞬间,Redis 实例 CPU 飙升至 100%。500 万个字符串被一次性打包塞进网卡,Redis 丧失响应长达 8 秒。

8 秒钟,足够让你们公司的核心交易链路触发雪崩报警,网关大面积 502。
破局之道:

面对大 Key 的遍历,永远、绝对不要使用 SMEMBERS**
请祭出游标遍历神器:
SSCAN**。

每次只拿 1000 条,处理完了再拿下一批,把一次性 O(N) 的核爆打击,化解为无数次 O(1) 的蒙毛毛雨。


1. 核心解剖:SMEMBERS 错在哪了?

Redis 是单线程处理命令的。
SMEMBERS 的底层逻辑极其简单粗暴:遍历底层数据结构(哈希表或整数集合),把所有元素塞进回复缓冲区,一次性发给客户端。

致命三连击:

  1. CPU 独占: 遍历 500 万数据需要耗费大量的 CPU 周期,期间 Redis 无法处理任何 GETSET 请求。
  2. 网卡打满: 几百 MB 的数据瞬间涌向网卡,极易造成网络拥塞。
  3. 客户端 OOM: 你的 Java 服务突然接收到一个包含 500 万个 String 的巨型 List,极易触发 JVM 老年代报警甚至 OutOfMemoryError。

2. 救世主 SSCAN:游标 (Cursor) 的艺术

SSCAN 的核心思想是分治法 。它不是一次性给你所有数据,而是通过一个游标 (Cursor),像翻书一样,一页一页地读取数据。

基础语法:

bash 复制代码
SSCAN key cursor [MATCH pattern] [COUNT count]

工作流:

  1. 第一步: 客户端发起 SSCAN my_set 0(游标从 0 开始)。
  2. 第二步: Redis 返回两样东西:下一个游标的值 (比如 14),以及一小批数据(默认 10 条左右)。
  3. 第三步: 客户端处理完这批数据后,拿着新游标继续请求:SSCAN my_set 14
  4. 终点: 直到 Redis 返回的新游标为 0,代表整个集合已经完完整整遍历了一遍。

3. 深水区:SSCAN 的三大底层"潜规则"

很多开发者以为 SSCAN 就像 SQL 里的 LIMIT offset, size,其实完全不同!不了解底层的潜规则,必定踩坑。

潜规则一:COUNT 只是个"建议",不是绝对限制

如果你写了 COUNT 1000,Redis 并不保证一定返回 1000 条数据。它可能返回 900 条,也可能返回 1200 条,甚至可能返回空数组(但游标不为 0)

  • 原因: Redis 是基于底层 Hash 表的"槽位(Bucket)"进行遍历的。如果某个槽位碰巧有一长串哈希冲突的链表,Redis 会把这条链表上的所有元素一次性全给你。
  • 极端情况: 如果你的 Set 底层使用的是 IntSet(整数集合,内存极度紧凑),Redis 会无视 COUNT 参数,在第一次扫描时就把全量数据一次性返回!
潜规则二:逆序高位进位算法 (高深魔法)

在遍历过程中,如果有其他线程在疯狂对这个 Set 进行增删,甚至导致了底层的 Hash 表扩容(Rehash)。普通的数组下标遍历必定会漏掉数据或者大量重复。

Redis 采用了极其天才的反向二进制迭代算法。它保证了即使在扩容期间:

  • 绝对不会漏掉在遍历开始前就已经存在的元素。
  • 但是,可能会返回重复的元素
潜规则三:客户端必须自己去重

基于上一条,SSCAN 明确在官方文档中指出:可能会返回重复元素

因此,客户端在接收到数据后,如果业务对重复敏感,必须在本地内存中(或者业务逻辑上)做好幂等去重。


4. 代码落地:Spring Boot 实战演练

在 Java 中,StringRedisTemplate 为我们封装了极度优雅的迭代器(Iterator)模式。你根本不需要自己去维护那个抽象的游标数字。

场景:全量导出并清洗千万级黑名单
java 复制代码
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.io.IOException;

@Service
public class BlacklistService {

    private final StringRedisTemplate redisTemplate;

    public BlacklistService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 安全地遍历超大 Set
     */
    public void exportBlacklistSafely(String setKey) {
        // 配置扫描选项:每次建议捞取 1000 条,匹配所有元素
        ScanOptions options = ScanOptions.scanOptions()
                .count(1000)
                .match("*")
                .build();

        // 这里的 scan 方法底层已经处理了游标循环,返回的是一个游标迭代器
        try (Cursor<String> cursor = redisTemplate.opsForSet().scan(setKey, options)) {
            
            int processCount = 0;
            while (cursor.hasNext()) {
                String userId = cursor.next(); // 自动带着新游标去 Redis 拉取下一批
                
                // --- 业务处理区 ---
                // 注意:这里需要考虑 SSCAN 可能返回重复元素的特性,如果是写文件或发 MQ,需做好幂等
                processUser(userId); 
                
                processCount++;
                if (processCount % 5000 == 0) {
                    System.out.println("已安全处理 " + processCount + " 条数据...");
                }
            }
        } catch (IOException e) {
            throw new RuntimeException("游标关闭异常", e);
        }
    }

    private void processUser(String userId) {
        // 比如将数据写入本地文件、推送到离线数仓等
    }
}

避坑细节: Cursor 实现了 Closeable 接口。虽然 Redis 的游标是无状态的(服务器不保存游标状态),但在 Spring 底层,它可能持有连接资源。务必使用 try-with-resources 确保 cursor.close() 被调用,防止连接泄漏!


5. 三大高频实战场景

场景一:无感知的热数据迁移
  • 业务: 需要把一个几 GB 的 Set 从老集群迁移到新集群。
  • 实战: 绝对不能 SMEMBERS。写一个常驻脚本,一边用 SSCAN 慢慢扫描老 Key,一边分批 SADD 到新 Key。既不影响老集群的在线业务,又能平滑完成迁移。
场景二:僵尸粉/过期数据的后台静默清理
  • 业务: 找出集合中已经注销的账号,并把它们踢出集合。
  • 实战: 开一个定时任务,用 SSCAN 每秒扫 500 个用户,拿到业务库里对比。如果是僵尸粉,就对这个 Key 执行 SREM 删除。细水长流,完全不占用 Redis 宝贵的峰值性能。
场景三:配合 MATCH 实现模糊匹配过滤
  • 业务: 找出一个包含千万个订单号的 Set 中,所有以 REFUND_ 开头的退款单号。
  • 实战: 使用 SSCAN order_set 0 MATCH REFUND_* COUNT 2000。让 Redis 在底层帮你做初步过滤,极大减少网络传输的数据量。

总结

在架构的演进道路上,敬畏生产环境是第一准则。

SMEMBERS 就像是拿着大网去海里捞鱼,很爽,但网太大容易把船拖翻。
SSCAN 则是一根精致的鱼竿,虽然需要一竿一竿地钓,但它保证了你的航船永远平稳前行。

下次再看到代码里出现 SMEMBERSHGETALLKEYS *,请毫不犹豫地给它打回重做,换成 SSCANHSCANSCAN

相关推荐
刘一说1 小时前
使用 CLion 搭建 Redis 6.x 源码调试环境:从零开始的完整指南
数据库·redis·缓存
人道领域2 小时前
苍穹外卖:菜品分页查询与删除功能(保姆级详解)
java·开发语言·数据库·后端·spring
Navicat中国2 小时前
利用 PostgreSQL 的强大力量:Supabase 简介
数据库·postgresql·navicat·supabase
菩提小狗2 小时前
第23天:安全开发-PHP应用&后台模块&Session&Cookie&Toke_笔记|小迪安全2023-2024|web安全|渗透测试|
笔记·安全·php
加密棱镜2 小时前
地址可溯源・传输可加密 IPv6 守护全流程网络安全
安全
yqzyy2 小时前
Redis 设置密码无效问题解决
数据库·redis·缓存
Neolnfra2 小时前
为什么现在需要卸载OpenClaw:它对你的系统安全做了什么?
安全·系统安全·openclaw
huangliang07032 小时前
oracle使用模版创建分区表
数据库·oracle
江不清丶3 小时前
Kafka消息积压排查与治理:从应急处理到长期优化
数据库·kafka·linq