Redis 的五大基本类型里,Set 大概是被误解最深的一个。String 用来做缓存、Hash 用来存对象、List 用来做队列、ZSet 用来做排行榜,这些用法大家心里都有数;可一提到 Set,很多人的印象还停留在“去重用的”,然后就没了。真实情况是,Set 承担了大量业务里的集合运算、随机选取、幂等占位和关系推导,用得好能省掉一整张 MySQL 中间表,用不好就是一次线上 SMEMBERS 把主线程卡住三秒的事故。这篇内容把 Redis Set 命令从底层编码一路讲到业务落地,中间穿插我自己踩过的坑和参数计算过程,适合刚接触 Redis 想系统梳理命令的同学,也适合已经用了一两年、但没细究过编码和复杂度的朋友拿来对照排查。
1. 先把 Set 这件事想明白:底层存的是什么
1.1 三种编码:intset、listpack 和 hashtable
很多人用SADD用得飞起,却从没执行过一次OBJECT ENCODING。这个命令是理解 Set 性能和内存的第一把钥匙,跑一下就能看到三种可能的返回值:intset、listpack、hashtable。它们不是三个不同的数据类型,而是同一份逻辑数据在内存里的三种物理形态,Redis 会根据元素特征自动切换。
intset是最省内存的一种,本质是一个有序的整数数组。所有元素必须都是整数,并且元素个数不能超过set-max-intset-entries(默认 512)。因为有序,查找走二分,复杂度 O(log N);但也因为是有序数组,插入一个元素要把后面所有元素往后挪一格,最坏是 O(N) 的内存搬移。这个特性决定了 intset 只适合“小而稳”的集合。
listpack是 Redis 7.2 才引入给 Set 用的紧凑编码,触发条件是元素个数不超过set-max-listpack-entries(默认 128)且单个元素长度不超过set-max-listpack-value(默认 64 字节)。它的特点是连续内存、无指针开销,查找是顺序扫描 O(N),所以只能用在极小集合上。
剩下的全部落到hashtable,也就是一个 value 恒为 NULL 的字典。查找、插入、删除都是 O(1) 均摊,代价是每个元素都要付出 dictEntry 指针、SDS 字符串头和哈希桶数组三部分开销。
这里有个非常容易被忽略的点:编码升级是单向的,不会自动降级。一个 intset 因为加进了一个非整数元素(比如把用户 ID 换成了"u_1001"这种带前缀的字符串)而升级成 hashtable 之后,就算你后来把那个字符串删掉,集合也不会退回 intset,除非你把整个 key 删了重新建。线上排查内存异常时,OBJECT ENCODING显示 hashtable 但SMEMBERS出来全是数字,这种“历史遗留”情况我见过不止一次。
127.0.0.1:6379> SADD nums 1 2 3 4 5 (integer) 5 127.0.0.1:6379> OBJECT ENCODING nums "intset" 127.0.0.1:6379> SADD nums hello (integer) 1 127.0.0.1:6379> OBJECT ENCODING nums "hashtable" 127.0.0.1:6379> SREM nums hello (integer) 1 127.0.0.1:6379> OBJECT ENCODING nums "hashtable"最后一个返回值得盯住看——删掉了非整数元素,编码纹丝不动。
1.2 一个能省下几十 GB 的容量估算
光讲概念没感觉,我们算一笔具体的账。假设要存 100 万个用户 ID,取值范围在 1 到 2^31 之间,看看不同编码下的内存差异。
| 编码 | 单元素开销估算 | 100 万元素总内存 | 说明 |
|---|---|---|---|
| intset | 4 字节 | 约 4 MB | ID 未超 2^31,用 int32 存储 |
| intset(超大 ID) | 8 字节 | 约 8 MB | 一旦出现超过 2^31 的 ID,全数组升级为 int64 |
| hashtable | 60~100 字节 | 约 60~100 MB | dictEntry 24B + SDS 头 + 桶数组 + 内存对齐 |
差距是 15 到 25 倍。这就是为什么有些业务一个 Set 吃了几十 GB 内存,而换个写法只占几百 MB。
但这里有个陷阱,前面提到过:默认配置下 intset 上限只有 512 个元素。也就是说你存 100 万个整数 ID,第 513 个元素进来的时候它就已经变成 hashtable 了,那 4 MB 的美好估算在默认配置下根本不会发生。想吃到 intset 的红利,必须调大set-max-intset-entries。
那是不是可以无脑调到 100 万?不行。前面说过 intset 插入是 O(N) 的内存搬移。100 万元素时插入一个数,平均要搬 50 万个元素,按 4 字节算就是 2 MB 的内存拷贝,单次SADD可能耗时几毫秒甚至十几毫秒,而 Redis 单线程处理命令,这期间所有其他请求都在排队。我做过的压测里,set-max-intset-entries设成 100 万、批量写入 100 万整数,整体耗时比默认配置慢了一个数量级。
我的经验值是这样:写多读少的集合,set-max-intset-entries保持默认 512 或者放到 4096 就够;一次性灌数据、之后基本只读的集合(比如配置项白名单、城市 ID 表),可以放到 10 万级别,配合 Pipeline 批量写入把单次搬移的痛感摊薄。
# redis.conf 中的相关配置 set-max-intset-entries 4096 set-max-listpack-entries 128 set-max-listpack-value 64改完记得用CONFIG SET热生效做验证,CONFIG REWRITE落盘,别直接改文件重启,生产环境重启的代价比你想的大。
2. 高频命令拆解:什么时候用哪个
2.1 增删判存四件套:SADD、SREM、SISMEMBER、SMISMEMBER
SADD key member [member ...]的返回值是实际新增的元素个数,这个返回值是整个 Set 用法的基石。很多人只把它当“写入成功”的布尔值看,其实它是天然的幂等开关:返回 1 说明这个元素之前不存在,业务可以继续往下走;返回 0 说明已经存在过,说明重复请求,直接短路返回。
127.0.0.1:6379> SADD order:limit:1001 u_9527 (integer) 1 127.0.0.1:6379> SADD order:limit:1001 u_9527 (integer) 0两次调用,第二次返回 0,一个字都不用多写就实现了“同一用户同一活动只能参与一次”。这套逻辑我在秒杀、签到、领券、抽奖、投票五个场景里都用过,区别只是 key 的设计不同。
SISMEMBER key member返回 1 或 0,单次只能判断一个元素。SMISMEMBER key member [member ...]是 6.2 版本才加的批量版本,返回一个数组。如果你的 Redis 版本够,批量判断一定要用SMISMEMBER,别在客户端 for 循环调SISMEMBER。
127.0.0.1:6379> SMISMEMBER user:1001:follow 2001 2002 2003 1) (integer) 1 2) (integer) 0 3) (integer) 1当然,批量判断还有另一种写法——Pipeline。这两种方式怎么选?SMISMEMBER只发一次网络往返,Pipeline 要攒批再发,但 Pipeline 不挑版本。如果待判断的 ID 列表超过一两百个,SMISMEMBER的单命令参数会变得很长,超过proto-max-bulk-len相关的限制倒不至于,但会撑大慢查询日志里的记录,这时候用 Pipeline 分组更稳。
import redis r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True) user_ids = [f"u_{i}" for i in range(1, 501)] # 方式一:SMISMEMBER 一次搞定 flags = r.smismember('user:1001:follow', user_ids) # 方式二:Pipeline 分批 pipe = r.pipeline(transaction=False) for uid in user_ids: pipe.sismember('user:1001:follow', uid) flags2 = pipe.execute() # 方式三:先拿全量再本地比对(元素少时反而最快) all_follow = r.smembers('user:1001:follow') flags3 = [uid in all_follow for uid in user_ids]第三种写法很多人不敢用,怕SMEMBERS太慢。确实,集合很大的时候它是灾难,但如果集合只有几百个元素,一次SMEMBERS拉回来在本地做集合判断,比发 500 次命令快得多。判断标准很简单:SCARD结果小于 1000,直接全量拉;大于 1000,用SMISMEMBER或 Pipeline。这个阈值不是死的,按你的网络延迟和客户端处理能力调。
SREM key member [member ...]删除元素,返回实际删除个数。这里有个行为要记住:如果 SREM 之后集合变空了,Redis 会自动把这个 key 删掉,EXISTS会返回 0。很多人写代码时假设“删空了集合还在”,然后SCARD返回 0 就觉得是 bug,其实是设计如此,好处是不会留下大量空 key 占内存。
2.2 集合运算:SINTER、SUNION、SDIFF 与它们的 STORE 变体
这三个命令是 Set 真正的杀手锏,也是它能替掉一整张关系表的原因。
SINTER key [key ...]求交集,返回所有集合共有的元素。SUNION求并集,SDIFF key [key ...]求差集。差集要注意顺序:SDIFF A B C算的是 A 减去 B 再减去 C,第一个 key 是基准,后面的都是“要减掉的”。这个顺序搞反了结果完全不同,我在 Code Review 里至少见过三次。
127.0.0.1:6379> SADD tag:redis a b c d (integer) 4 127.0.0.1:6379> SADD tag:cache a c e (integer) 3 127.0.0.1:6379> SINTER tag:redis tag:cache 1) "a" 2) "c" 127.0.0.1:6379> SDIFF tag:redis tag:cache 1) "b" 2) "d" 127.0.0.1:6379> SDIFF tag:cache tag:redis 1) "e"复杂度这块必须讲清楚,因为它直接决定你能不能在线上随便用。SINTER的时间复杂度是 O(N*M),其中 N 是所有参与集合里基数最小的那个,M 是集合个数。也就是说交集算得快不快,取决于你有没有一个很小的集合参与。而SUNION和SDIFF是 O(N),N 是所有集合元素总数,元素越多越慢,没有小集合能救你。
这个差异在实践里非常关键。比如做“共同关注”功能,两个大 V 各有一百万粉丝,求交集理论上限是 100 万次哈希查找,实际 Redis 会遍历较小的那个集合。但如果你要做“我的粉丝里有多少人也关注了某冷门账号”,冷门账号只有 50 个粉丝,那这次交集就是 50 次查找,快得飞起。所以交集运算的设计原则是:想办法让参与运算的集合里至少有一个足够小。
SINTERSTORE destination key [key ...]把结果存到另一个 key,SUNIONSTORE、SDIFFSTORE同理。什么时候用 STORE 版本?两个场景:一是结果要反复读,算一次缓存起来;二是要配合EXPIRE做过期,把中间结果的生命周期管起来。
127.0.0.1:6379> SINTERSTORE tmp:common:1001:1002 user:1001:follow user:1002:follow (integer) 37 127.0.0.1:6379> EXPIRE tmp:common:1001:1002 300 (integer) 1注意SINTERSTORE的返回值和空结果行为:如果交集为空,目标 key 会被删除(或者压根不创建),命令返回 0。这点很重要,意味着你不能靠“key 存在”来判断计算是否执行过,得看返回值。
Redis 7.0 补了一个SINTERCARD,专门解决“只想知道交集有多少个,不关心具体是哪些”的场景,而且支持LIMIT提前终止。
127.0.0.1:6379> SINTERCARD 2 user:1001:follow user:1002:follow LIMIT 10 (integer) 10上面这个返回 10,不代表交集真有 10 个,而是“至少 10 个,到这里我就停了”。做“是否有共同好友”这种布尔判断时,LIMIT 1就够了,性能收益巨大。
2.3 随机取数三兄弟:SPOP、SRANDMEMBER、SSCAN
这三个都跟“取元素”有关,但语义完全不同,混用会出事故。
SPOP key [count]是弹出,取走的同时把元素从集合里删掉。不带 count 返回一个随机元素,带 count 返回最多 count 个。这是抽奖场景的正解——抽一个少一个,绝对不会重复中奖。如果 count 大于等于集合现有元素数,会返回全部元素并把 key 删掉。
127.0.0.1:6379> SADD lottery:2024 u1 u2 u3 u4 u5 (integer) 5 127.0.0.1:6379> SPOP lottery:2024 "u3" 127.0.0.1:6379> SCARD lottery:2024 (integer) 4 127.0.0.1:6379> SPOP lottery:2024 2 1) "u1" 2) "u5" 127.0.0.1:6379> SCARD lottery:2024 (integer) 2SRANDMEMBER key [count]是随机读,元素还在集合里。不带 count 返回一个,带正数 count 返回 min(count, 集合大小) 个不重复元素,带负数 count(比如 -5)返回 5 个元素,允许重复。这个负数用法是 3.2 版本加的,做“抽五次,每次都可能抽到同一个奖品”这种带放回抽取特别顺手。
127.0.0.1:6379> SRANDMEMBER lottery:2024 2 1) "u2" 2) "u4" 127.0.0.1:6379> SRANDMEMBER lottery:2024 -3 1) "u2" 2) "u2" 3) "u4" 127.0.0.1:6379> SCARD lottery:2024 (integer) 2最后SCARD还是 2,证明SRANDMEMBER一个都没删。
SSCAN cursor [MATCH pattern] [COUNT count]是游标遍历,用来替代SMEMBERS。它不保证返回全部元素的同时也不阻塞,适合大集合的离线清理、灰度对账、数据迁移。用法在下一节结合大 key 问题详细讲。
2.4 版本差异与命令兼容性清单
排查线上问题时最怕的就是“我本地能跑,线上报错”,原因往往是版本不一致。下面这张表我建议直接存到团队的 Wiki 里,切环境前扫一眼。
| 命令 / 特性 | 最低版本 | 说明 |
|---|---|---|
| SPOP 带 count | 3.2 | 之前只能一次弹一个 |
| SRANDMEMBER 负 count | 3.2 | 允许重复抽样 |
| UNLINK 异步删除 | 4.0 | 大 key 删除的正确姿势 |
| SMISMEMBER | 6.2 | 批量判断存在,替代循环 SISMEMBER |
| SINTERCARD | 7.0 | 只算交集的基数,支持 LIMIT |
| Set 的 listpack 编码 | 7.2 | 小集合内存进一步下降 |
| SSCAN 的 NOVALUES 参数 | 7.4 | 对 Set 无影响,这里是提醒别记串 |
版本不满足的时候怎么降级?SMISMEMBER没有就退到 Pipeline +SISMEMBER;SINTERCARD没有就退到SINTER数结果长度(大集合慎用),或者用SINTERSTORE存到临时 key 再SCARD,最后UNLINK掉。
def s_inter_card(r, keys, limit=None): """兼容 7.0 以下版本的 SINTERCARD 实现""" try: return r.sintercard(len(keys), keys, limit or 0) except redis.ResponseError: tmp_key = f"tmp:sinter:{uuid.uuid4().hex[:8]}" r.sinterstore(tmp_key, keys) cnt = r.scard(tmp_key) r.unlink(tmp_key) return cnt这种带 fallback 的封装写一次,全项目复用,比每次在业务代码里判断版本干净得多。
3. 四个业务场景完整落地
3.1 场景一:标签去重与交集推荐
内容系统里最典型的用法:一篇文章挂多个标签,要查“带标签 A 和标签 B 的所有文章”。传统做法是一张article_tag关联表加GROUP BY ... HAVING COUNT(*) = 2,数据量上百万之后这条 SQL 能跑出几百毫秒。
用 Set 换个建模方式:正向存tag:{tagName}:articles(这个标签下有哪些文章),反向存article:{id}:tags(这篇文章有哪些标签)。查“标签 A 和 B 的交集”就变成一次SINTER。
127.0.0.1:6379> SADD tag:redis:articles 1001 1002 1003 1004 1005 (integer) 5 127.0.0.1:6379> SADD tag:cache:articles 1002 1003 1005 1006 (integer) 4 127.0.0.1:6379> SINTER tag:redis:articles tag:cache:articles 1) "1002" 2) "1003" 3) "1005"写入侧要保证双写一致,建议用 Lua 脚本把两次SADD包在一个原子操作里:
-- KEYS[1] = tag:{tag}:articles, KEYS[2] = article:{id}:tags -- ARGV[1] = articleId, ARGV[2] = tag redis.call('SADD', KEYS[1], ARGV[1]) redis.call('SADD', KEYS[2], ARGV[2]) return 1删除文章时同理,两个集合都要SREM。这里有个坑我踩过:只删了正向没删反向,导致某篇文章从列表页消失了,但从标签页还能点进去,点进去 404。原因就是反向索引残留。解决方式是所有写操作统一收口到一个 Repository 层,禁止业务代码直接调SADD。
多标签组合(A AND B AND C)时,SINTER支持任意多个 key,直接把三个都传进去就行。但要注意参与运算的 key 数量——每多一个 key,复杂度里的 M 就加一,而且网络传输的参数也变长。我的经验是三个以内直接SINTER,超过三个考虑先在应用层做一轮粗筛,或者用SINTERSTORE把结果落下来做二次运算。
还有一个细节:空集合的处理。如果标签 A 下没有任何文章,那这个 key 压根不存在,SINTER遇到不存在的 key 会当成空集处理,结果就是空,返回空数组,不会报错。这是好事,但如果你用SINTERSTORE,行为是删除目标 key。所以“缓存交集结果”的逻辑里,必须能区分“算出来是空”和“还没算过”,办法是额外存一个空标记,或者干脆不缓存空结果让下次重算。
3.2 场景二:共同关注与关系推导
社交场景里 Set 的出场率最高。“我关注的人里有多少也关注了 TA”“我们俩的共同好友”“推荐可能认识的人”,底层全是集合运算。
# 关注关系:user:{uid}:follow 存这个用户关注了谁 127.0.0.1:6379> SADD user:1001:follow 2001 2002 2003 2004 2005 (integer) 5 127.0.0.1:6379> SADD user:1002:follow 2002 2003 2005 2006 2007 (integer) 5 127.0.0.1:6379> SINTER user:1001:follow user:1002:follow 1) "2002" 2) "2003" 3) "2005"共同关注列表出来了。但产品经理通常还要“共同关注数量”,这时候别用返回数组再取长度,直接上SINTERCARD:
127.0.0.1:6379> SINTERCARD 2 user:1001:follow user:1002:follow LIMIT 100 (integer) 3加LIMIT 100是为了防止两个超级大 V 求交集时把 CPU 打满——反正前端最多也就展示 100 个,算多了纯浪费。
“可能认识的人”稍微绕一点:先在应用层拿到我关注的人 A、B、C,然后算 A、B、C 各自关注的人,减去我已经关注的人。这个逻辑用SUNIONSTORE加SDIFFSTORE两步就能搞定:
# 第一步:朋友的朋友 127.0.0.1:6379> SUNIONSTORE tmp:fof:1001 user:2001:follow user:2002:follow user:2003:follow (integer) 48 # 第二步:减去已经关注的和自己 127.0.0.1:6379> SDIFFSTORE tmp:rec:1001 tmp:fof:1001 user:1001:follow (integer) 41 127.0.0.1:6379> SREM tmp:rec:1001 1001 (integer) 1 127.0.0.1:6379> EXPIRE tmp:rec:1001 600 (integer) 1这里有三点必须提醒。第一,别把“自己”忘了减掉,用户的关注列表里通常没有自己,但朋友的朋友可能包含自己,漏掉这一步就会出现“推荐关注你自己”的尴尬。第二,中间 key 一定要设过期,SUNIONSTORE产生的临时集合如果不清理,内存增长会非常快,我见过一个推荐服务因为忘了设 TTL,一天多出 8 GB 临时 key。第三,SDIFFSTORE的目标 key 和源 key 不能同名,否则行为不确定。
反方向的关系(谁关注了我)用另一个 keyuser:{uid}:fans,粉丝列表和关注列表分开存。这样既能算“互相关注”(SINTER follow fans),也能算“我关注了但对方没回关”(SDIFF follow fans),一次查询解决。
数据量大的时候要接受一个现实:交集运算的代价跟最小集合的大小挂钩。如果一个普通用户关注了 500 人,另一个关注了 800 人,交集也就 500 次查找,几十微秒。但如果两个大 V 各关注了 5 万人,交集就是 5 万次查找,虽然还是毫秒级,可 QPS 一高就顶不住了。这种场景的正确做法是改用“关注关系走 DB 分页,共同关注数量走缓存,缓存 10 分钟”的降级策略,不要指望 Redis 扛住所有实时计算。
3.3 场景三:抽奖、发券与不重复选取
抽奖的核心诉求是“不重复”,这跟SPOP的语义天然匹配。把奖池提前灌进 Set,每次抽奖SPOP一个,抽走就没了,逻辑上不可能重复。
127.0.0.1:6379> SADD pool:activity:2024 prize_001 prize_002 prize_003 prize_004 prize_005 (integer) 5 127.0.0.1:6379> SPOP pool:activity:2024 "prize_003"但直接的解决方案往往在工作中不适用,因为真实抽奖有“概率权重”。这时候 Set 只是奖池容器,权重逻辑在应用层处理:先按权重随机决定抽中哪一档,再从对应档位的 Set 里SPOP一个具体奖品。
-- 从指定奖池抽一个,保证不重复且原子 -- KEYS[1] = 奖池 key -- 返回抽中的元素,池子空了返回 nil local item = redis.call('SPOP', KEYS[1]) return item有人会问,SPOP和SRANDMEMBER到底该选哪个。我总结一个简单的判断:
| 需求 | 选择 | 原因 |
|---|---|---|
| 抽走就不再有,绝不重复 | SPOP | 原子弹出,天然去重 |
| 抽完还在,下次还能抽到 | SRANDMEMBER | 只读不删 |
| 抽 N 次,允许重复命中 | SRANDMEMBER -N | 负 count 允许重复 |
| 抽 N 个,本次内不重复 | SRANDMEMBER N 或 SPOP N | 看是否需要保留 |
发券场景跟抽奖相反,它需要“每人限领一张”而不是“券不重复”。这时候 Set 的角色变成用户去重集合:SADD coupon:{couponId}:received {userId}返回 1 才允许发券。这个用法在 3.4 节还会展开。
还有个细节容易被忽略:SPOP在高并发下是原子的,但“抽奖”这个业务动作往往不止一步。比如抽中之后要扣库存、写流水、发消息。这几步如果分开发命令,中间挂掉就会不一致。正确做法是整个动作包进 Lua 脚本,或者用SPOP拿到结果后,把后续动作交给可靠消息队列,用最终一致性兜底。别指望 Redis 事务(MULTI/EXEC)能解决这个问题,它只保证命令连续执行,不保证失败回滚。
3.4 场景四:限购去重与原子占位
这是 Set 在电商里最高频的用法。活动限购、秒杀资格、签到判断,本质都是“同一个用户只能有一次”。
-- 限购占位:返回 1 表示占位成功,0 表示已参与过 -- KEYS[1] = activity:{id}:participants -- ARGV[1] = userId, ARGV[2] = 过期秒数 local added = redis.call('SADD', KEYS[1], ARGV[1]) if added == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) return 1 end return 0为什么要用 Lua 而不是直接SADD然后EXPIRE?因为两条命令之间如果客户端崩溃,key 就永远没有过期时间,活动结束后这个集合会一直占着内存。用 Lua 把两步绑在一起,要么都执行,要么都不执行。这是我在一个活动系统里踩过的真实坑——20 多个活动 key 忘了设 TTL,半年后清理时发现有 3000 多万条残留记录。
EXPIRE每次占位都刷一遍会不会有问题?会有。如果活动持续 7 天,每次都刷新 TTL,那这个 key 的过期时间永远是“最后一次参与 + 7 天”,可能导致它比预期多留很久。更稳的写法是用EXPIRE ... NX(Redis 7.0+),只在没有过期时间时设置:
local added = redis.call('SADD', KEYS[1], ARGV[1]) if added == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2], 'NX') return 1 end return 0版本不够就用TTL先查再决定是否设,稍微多一次往返,但逻辑正确。
这个模式的扩展性很好。比如“每日签到”,key 设计成sign:{yyyyMMdd}:{activityId},每天一个新 key,EXPIRE设成 3 天自动清理。比如“每人每天限领 1 张”,key 里带上日期。再比如“IP 黑名单”,SADD blacklist:ip {ip},SISMEMBER判断,简单粗暴。
需要强调的是,这个方案只能保证“Redis 层面”的幂等。如果 Redis 因为主从切换丢了几秒的数据,理论上存在重复占位的可能。对一致性要求极高的场景(比如涉及资金),必须在 DB 层再落一道唯一索引兜底,Redis 只做前置拦截。我在实际项目里的做法是:Redis 挡住 99.9% 的重复请求,剩下 0.1% 交给 DB 的唯一索引,捕获DuplicateKeyException后返回友好提示。两层防护,成本可控。
4. 踩坑实录:那些文档不会写的问题
4.1 大 key 与 SMEMBERS 的阻塞
SMEMBERS是 Set 里最危险的命令,没有之一。它的复杂度是 O(N),N 是集合大小,而且 Redis 是单线程处理命令,这个 O(N) 期间所有其他请求全部排队。
我经历过一次:某个运营后台做了一个“导出全部标签”的功能,标签集合有 40 万个元素,SMEMBERS一次返回几十 MB 数据,网络传输加上序列化,单次命令耗时 2 秒多。那两秒里整个实例的所有业务请求都卡住了,监控上看到 P99 直接从 3ms 飙到 2000ms。
正确的替代方案是SSCAN分批遍历:
127.0.0.1:6379> SSCAN tag:all:tags 0 COUNT 500 1) "13312" 2) 1) "redis" 2) "cache" 3) "mysql" ... 127.0.0.1:6379> SSCAN tag:all:tags 13312 COUNT 500 1) "26624" 2) ...SSCAN的特点是每次只返回少量元素和一个新游标,游标返回 0 表示遍历结束。COUNT是个提示值不是精确值,Redis 可能返回比它多也可能比它少,别拿它当分页大小用。
def scan_all_members(r, key, batch=500): cursor = 0 result = [] while True: cursor, items = r.sscan(key, cursor, count=batch) result.extend(items) if cursor == 0: break return result要注意SSCAN的弱一致性保证:遍历期间如果有元素增删,可能重复返回某个元素(客户端要自己用 set 去重),也可能漏掉遍历开始后新增的元素。这是设计上的取舍,换来的是不阻塞。做数据迁移和对账时接受这个特性,别指望它给出严格快照。
除了SMEMBERS,还有几个命令在大集合上同样危险:SUNION、SDIFF、SINTERSTORE、SPOP key count(count 很大时)、SRANDMEMBER key count。上线前用redis-cli --bigkeys扫一遍,看看哪些 Set 元素数超过 5000,逐个评估。
redis-cli -h 127.0.0.1 -p 6379 --bigkeys redis-cli -h 127.0.0.1 -p 6379 MEMORY USAGE tag:all:tags SAMPLES 0--bigkeys是按类型统计最大的 key,MEMORY USAGE给出具体字节数。两个配合看,能快速定位内存大户。
如果真的已经存在超大 key 需要删除,千万不要用DEL。DEL是同步的,删一个 40 万元素的 Set 会阻塞几百毫秒甚至更久。用UNLINK(4.0+),它把 key 从 keyspace 摘掉后交给后台线程慢慢释放内存:
127.0.0.1:6379> UNLINK tag:all:tags (integer) 1如果版本低于 4.0,那就只能SSCAN+SREM分批删,一边删一边 sleep 几毫秒,把压力摊开。
4.2 成员级 TTL 的幻觉
这是新手最容易想当然的一点:Redis 的过期时间只能设在 key 上,不能设在单个 member 上。你没法让 Set 里的某个元素 5 分钟后自动消失。
我见过有人设计“用户最近 7 天访问过的商品”用 Set 存,然后问怎么让 7 天前的自动过期。答案是做不到,除非你自己维护时间信息。
有三种解法,各有代价:
第一种是分桶 + 多 key。按天建 key,view:{userId}:20240101,每天一个 Set,查最近 7 天就SUNION7 个 key。好处是过期自然,坏处是查询要合并 7 个 key,而且 7 天前的 key 靠EXPIRE自己消失。
127.0.0.1:6379> SADD view:1001:20240101 item_a item_b (integer) 2 127.0.0.1:6379> EXPIRE view:1001:20240101 604800 (integer) 1第二种是换成 ZSet。用 score 存时间戳,ZREMRANGEBYSCORE清理过期数据。这是最灵活的方案,代价是内存比 Set 大(ZSet 每个元素多一个 score,跳表结构也更重)。
第三种是接受不精确。业务上“最近访问”本来就是模糊概念,用定时任务每天凌晨扫一遍全量 Set,逐元素判断时间戳,超期的SREM掉。这个方案在数据量小时最省事,但集合大了之后清理任务本身也是个负担。
我的选择顺序是:数据量小且查询简单用分桶,需要精确排序和范围查询用 ZSet,实在懒得改就用定时清理。别在设计阶段假设 Set 能处理成员级过期,这个幻觉会在上线一个月后变成技术债。
4.3 集群下的跨槽事故
单机跑得好好的代码,上了集群突然报CROSSSLOT Keys in request don't hash to the same slot,这是所有多 key 命令的通病,Set 里的SINTER、SUNION、SDIFF、SMOVE、SINTERSTORE全军覆没。
原因很简单:集群把数据按 key 的 CRC16 分成 16384 个槽,多 key 命令要求所有 key 落在同一个槽里,否则没法在单个节点上完成运算。而user:1001:follow和user:2002:follow哈希出来的槽大概率不同。
解决办法是hash tag:用花括号包住 key 里需要参与哈希计算的那部分,Redis 只对花括号内的内容做哈希。
# 改造前,两个 key 可能在不同槽 user:1001:follow user:2002:follow # 改造后,用 hash tag 强制同槽 {user:1001}:follow {user:1001}:fans {user:1001}:profile花括号里的user:1001决定槽位,所以同一个用户的所有相关 key 都会落在同一个节点上。代价是热点风险——如果某个大 V 的 ID 是user:1,他的所有数据都在一个节点上,流量打爆那台机器其他槽位的数据也跟着遭殃。所以 hash tag 的选择要平衡:按用户分能保证单用户的所有操作原子,但超级用户会成热点;按业务分能分散压力,但跨用户运算就没法做了。
改造时机很关键。上线前设计 key 命名规范时就把 hash tag 规划好,成本几乎为零;上线后再改,要做全量数据迁移,几千万个 key 的 rehash 得停机几个小时。这是我经历过的教训,一个社交项目在 QPS 破万之后才发现共同关注功能在集群下跑不了,最后靠加了一层代理服务在应用侧做集合运算才勉强撑住,性能比原生SINTER差了五六倍。
还有一个细节:SMOVE也是多 key 命令。想做“从待处理队列移到已完成队列”这种操作,两个 key 必须同槽,否则同样报错。改造方式是{task:batch_001}:pending和{task:batch_001}:done。
4.4 常见问题速查表
| 现象 | 大概率原因 | 排查命令 | 解决思路 |
|---|---|---|---|
| 集合内存暴涨 | 编码是 hashtable 而非 intset | OBJECT ENCODING key | 检查是否有非整数成员,或调大 set-max-intset-entries |
| 接口偶发超时 | SMEMBERS / SUNION 大 key 阻塞 | SLOWLOG GET 10 | 改用 SSCAN 分批,或加 LIMIT |
| 明明加了 TTL 还是长期存在 | 每次写入都刷新了 EXPIRE | TTL key | 改用EXPIRE key ttl NX |
| 集群报 CROSSSLOT | 多 key 命令跨槽 | CLUSTER KEYSLOT key | 加 hash tag 统一槽位 |
| SINTER 返回空 | 某个 key 不存在或已过期 | EXISTS key逐个确认 | 不存在的 key 按空集处理,检查写入链路 |
| 删除大 key 卡顿 | 用了 DEL 而非 UNLINK | INFO commandstats | 换UNLINK,低版本用 SSCAN+SREM |
| SPOP 返回 nil | 集合已空或 key 不存在 | EXISTS/SCARD | 抽奖前先判空,或接受 nil 做兜底文案 |
| 编码一直升级不降级 | 单向升级机制 | OBJECT ENCODING | 只能删 key 重建,或接受现状 |
| 交集运算很慢 | 参与集合都很大 | SLOWLOG+SCARD | 加LIMIT,或改异步预计算 |
5. 内存与性能的最后一公里
5.1 该不该用 Set:三个替代方案对比
不是所有“去重”都该用 Set。判断标准只有一个:你到底需要精确结果还是近似结果。
如果只需要“大概有多少人去重”,比如统计 UV、页面访客数,HyperLogLog 是碾压级的存在。它的误差率约 0.81%,但固定只占 12 KB 内存,无论存 10 万还是 1 亿个元素。用 Set 存 1 亿个用户 ID,hash 编码下要 6 到 10 GB,两者差了三四个数量级。
127.0.0.1:6379> PFADD uv:20240101 u1001 u1002 u1003 (integer) 1 127.0.0.1:6379> PFCOUNT uv:20240101 (integer) 3如果只需要判断“这个元素可能存在还是一定不存在”,比如爬虫 URL 去重、黑名单前置过滤,布隆过滤器更合适。它的误判是单向的——说不存在就一定不存在,说存在可能有假阳性。代价是不能删除元素(除非用计数型布隆过滤器),也不能列出所有元素。
如果既要精确又元素极多(千万级以上),那就得重新审视业务需求:真的需要 Redis 存全量吗?能不能改成 Bitmap(前提是 ID 连续且范围可控)?能不能把冷数据落到 DB,Redis 只存热数据?
我整理了一个选型对照,放在这里方便直接抄:
| 方案 | 精确性 | 内存(1 亿元素) | 支持删除 | 支持列举 | 典型场景 |
|---|---|---|---|---|---|
| Set | 精确 | 6~10 GB | 支持 | 支持 | 关注关系、标签、限购 |
| HyperLogLog | 约 0.81% 误差 | 12 KB | 不支持 | 不支持 | UV 统计、活跃数估算 |
| Bitmap | 精确 | 12.5 MB(ID 连续时) | 支持(置 0) | 可遍历 | 签到、在线状态 |
| 布隆过滤器 | 有假阳性 | 按容量配置,通常几十 MB | 不支持 | 不支持 | URL 去重、缓存穿透拦截 |
选型时还有一个维度容易被忽略:运维成本。HyperLogLog 和 Bitmap 都是单命令操作,好维护;布隆过滤器在 Redis 里通常靠模块实现,要考虑模块加载、版本兼容、集群部署的额外复杂度。有些团队为了省几 GB 内存引入了模块,结果运维复杂度翻倍,得不偿失。
5.2 上线前的自检清单
把这套东西整理成一个清单,每次涉及 Set 的新功能上线前过一遍,能避掉大部分坑。
命名规范上,key 必须带业务前缀和用途后缀,格式统一为业务:实体:标识:用途,比如activity:2024:participants、user:1001:follow。这样在--bigkeys的输出里一眼能看出是哪个业务,也方便批量清理。集群环境下别忘了把需要参与多 key 运算的部分包进花括号。
容量评估上,上线前必须估算每个 Set 的预期规模。方法是:预期元素数 × 单元素开销(intset 按 4~8 字节,hashtable 按 60~100 字节),算出总量,再看单实例内存水位。超过 5000 元素的集合要在设计文档里标注“大 key 预警”,并明确所有读取路径都必须走SSCAN或带LIMIT。
过期策略上,所有临时集合(中间结果、活动数据、推荐缓存)必须有 TTL,且用EXPIRE key ttl NX避免反复刷新。永久集合(用户关注、标签索引)要在文档里写明数据来源和重建方式——万一被误删,怎么从 DB 恢复。
监控告警上,SLOWLOG阈值调到 10ms 并接入告警,used_memory按 70% 水位告警,--bigkeys做成每日巡检任务。这三项配齐,基本能在问题扩大前发现苗头。
我在实际项目里最深的体会是,Redis Set 的复杂度不在命令本身——十几个命令一下午就能背完,而在于你对自己数据的规模是否有清醒认知。同一个SINTER,在 100 个元素的集合上是微秒级操作,在 10 万个元素的集合上就是潜在的线上事故。写代码时多问一句“这个集合最大能有多大”,比事后加监控、改代码要省力得多。最后再分享一个小习惯:任何涉及 Set 的新接口,我都会在测试环境造一个 10 万元素的集合压一遍,看 P99 和慢查询,这个动作花不了十分钟,但救过我至少三次。