☰
Redis Set底层编码与集合运算实战:去重限购与大key避坑
2026/10/26 11:49:07 网站建设 项目流程

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 万元素总内存说明
intset4 字节约 4 MBID 未超 2^31,用 int32 存储
intset(超大 ID)8 字节约 8 MB一旦出现超过 2^31 的 ID,全数组升级为 int64
hashtable60~100 字节约 60~100 MBdictEntry 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) 2

SRANDMEMBER 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 带 count3.2之前只能一次弹一个
SRANDMEMBER 负 count3.2允许重复抽样
UNLINK 异步删除4.0大 key 删除的正确姿势
SMISMEMBER6.2批量判断存在,替代循环 SISMEMBER
SINTERCARD7.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 而非 intsetOBJECT ENCODING key检查是否有非整数成员,或调大 set-max-intset-entries
接口偶发超时SMEMBERS / SUNION 大 key 阻塞SLOWLOG GET 10改用 SSCAN 分批,或加 LIMIT
明明加了 TTL 还是长期存在每次写入都刷新了 EXPIRETTL key改用EXPIRE key ttl NX
集群报 CROSSSLOT多 key 命令跨槽CLUSTER KEYSLOT key加 hash tag 统一槽位
SINTER 返回空某个 key 不存在或已过期EXISTS key逐个确认不存在的 key 按空集处理,检查写入链路
删除大 key 卡顿用了 DEL 而非 UNLINKINFO 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 和慢查询,这个动作花不了十分钟,但救过我至少三次。

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

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

立即咨询