平时用Redis做缓存、做分布式锁、做消息队列,很多人对value这一层的类型选择并没有仔细想过。直到某次线上出问题,比如把一个千万级的set用SMEMBERS一次性捞出来,Redis直接卡了几秒钟;或者把set和list的行为搞混,数据顺序和重复情况完全不符合预期,你才会意识到一件事:value类型不是随便选一个都行的,set的边界在哪、擅长什么、不擅长什么,心里必须有一本账。
set是Redis五种value类型里非常好用、但也容易被低估的一种。它最大的价值在于三个词:无序、唯一、集合运算。标签系统、抽奖、共同关注、人群圈选这类需求,只要数据结构选对了,代码可以少写一半。这篇内容就专门把set拆开聊,从类型定位、命令体系、底层存储、业务场景到常见坑位,一次讲透。想彻底搞懂Redis value类型的人,或者正在做社交、电商、运营系统被集合类需求折腾的开发同学,都可以直接照着参考。
1. Redis的value类型全景与set的定位
1.1 五种value类型怎么看
Redis的value类型一共五种:string、list、hash、set、zset。很多人学的时候是逐个背命令,背完就忘,因为脑子里没有一张“什么场景该用什么类型”的地图。
我的建议是按数据特征来分:string是单体数据,一个key对应一个值,适合存用户token、配置项、计数器;list是序列数据,元素有顺序、可以重复,适合做消息队列、时间线这类需要按顺序读写的场景;hash是对象数据,key对应一组field-value对,适合存一个实体的多个属性,比如用户信息、商品详情;set是无序集合,元素唯一,天然支持交并差运算;zset是带分数的有序集合,适合排行榜、延时队列。
这样一分就清晰了:set的核心价值不在“存数据”,而在“集合之间的关系计算”。如果你只是要存一个无序的列表,那list更合适;如果你要判断一个元素存不存在、求两个集合的重叠部分、随机弹出几个元素,那set就是最顺手的选择。
1.2 set类型到底解决了什么问题
set对应的是数学里的集合概念:无序性、互异性。无序意味着你不能指望它保持插入顺序,互异意味着同一个成员重复SADD不会产生两条数据。这两个特性放在业务里,天然对应三类问题。
第一类是去重。比如一个用户当天点击过哪些商品ID、一个活动有哪些用户参与过,用list存就得每插入一次查一遍是否已存在,要么就允许重复再事后去重,都很别扭。用set,SADD天然去重,SCARD直接得出不重复数量。
第二类是关系运算。求两个集合的共同部分、差集、并集,比如赛选“既买了A商品又买了B商品的用户”、圈定“关注了你但你没关注他的用户”,直接SINTER、SDIFF一条命令出结果,不需要拉数据到业务层用代码算。这一点是set区别于其他类型最核心的优势。
第三类是随机操作。从集合里随机取一个、随机弹出几个,SPOP和SRANDMEMBER就是为这种场景准备的,抽奖系统、随机推荐、名额分配都可以直接用。
2. set类型的核心命令体系拆解
2.1 基础维护命令:增删查与计数
set的基础命令不多,但每个都有讲究。先说最常用的几个。
SADD key member [member ...] # 向集合添加一个或多个成员 SREM key member [member ...] # 移除一个或多个成员 SMEMBERS key # 返回集合全部成员 SISMEMBER key member # 判断成员是否存在 SCARD key # 返回集合成员数量 SISMEMBER key member [member ...] # 批量判断(Redis 6.2+)SADD的返回值得注意:返回的是“实际新增了多少个成员”。如果你往set里添加一个已经存在的成员,返回0;添加三个其中有俩重复,返回1。这个返回值在做去重判断时很好用,比如记录用户首次参与活动,SADD返回1说明是第一次,返回0说明他已经在集合里了,不用再额外做一次SISMEMBER。
SISMEMBER是最常用的O(1)操作。判断一个用户是不是VIP、一个IP是不是在白名单里,底层就是哈希查找,毫秒级响应。如果是Redis 6.2以上版本,想批量判断多个成员是否在一个set里,用SMISMEMBER比循环调用SISMEMBER省好几轮网络往返。
这里提醒一点:SMEMBERS是返回全体成员,复杂度O(N)。小集合随便用,一旦集合里成员数量很大,返回数据会撑大网络包,阻塞Redis。后面第5章会专门讲怎么替代。
2.2 集合运算命令:交集、并集、差集
set真正让其他数据结构羡慕的,是集合运算能力。
SINTER key [key ...] # 交集:返回多个集合共同存在的成员 SINTERSTORE destination key [key ...] # 交集并存储到新集合 SUNION key [key ...] # 并集:合并多个集合的成员 SUNIONSTORE destination key [key ...] # 并集并存储 SDIFF key [key ...] # 差集:在第一个集合中但不在其他集合中 SDIFFSTORE destination key [key ...] # 差集并存储最常用的业务场景是交集。比如电商后台要圈选“近7天加购但没下单的用户”,那就维护两个set:加购用户集合、下单用户集合,一条SINTER命令就出来了。再比如社交平台求“我和他共同关注了谁”,也只需要把两个关注集合做交集。
差集SDIFF的语义要掰扯清楚:SDIFF返回的是“存在于第一个集合,但不存在于其余所有集合”的元素。也就是说集合顺序会影响结果。求“A有B没有”就写SDIFF A B,反过来就是SDIFF B A。这两个结果不是对偶的,千万别搞反。
另外,SINTERSTORE这类带STORE的命令,是把计算结果存到新key里。适合计算结果很耗时的场景——比如冷门用户每天凌晨算好存下来,白天直接读取,避免高峰时段现场计算。
2.3 随机与移动命令:抽奖和转移
SPOP key [count] # 随机移除并返回count个成员 SRANDMEMBER key [count] # 随机返回count个成员(不移除) SMOVE source destination member # 把member从一个集合移到另一个集合SPOP和SRANDMEMBER最大的区别就是“移不移除”。SPOP返回成员的同时会把它从集合里删掉,适合抽奖——奖品发出去一个名额就少一个。SRANDMEMBER只读不删,适合“随机推荐一个用户”这种场景:抽完一轮,下轮还能再抽到他。
count参数也有细节:SRANDMEMBER key 2 返回两个不重复的随机成员;如果写SRANDMEMBER key -2,允许重复,可能返回两个一样的成员。SPOP count的行为和SRANDMEMBER不太一样,它移除的是不重复的随机成员。做抽奖时要特别留意这个差异,后面第5章会展开讲一个踩坑案例。
SMOVE是原子操作,把集合A中的一个成员移动到集合B中。如果成员在A中不存在,返回0,B保持不变;如果B里本来就有同名的成员,SMOVE不会报错,B还是只有一个该成员。这个命令在做状态流转场景时很好用,比如“把用户从待处理队列移到处理中队列”,两个set之间成员挪动,一步到位。
3. set的典型应用场景与业务案例
3.1 标签系统与人群圈选
标签系统是set的经典应用。用户身上可能同时有很多标签,比如“新用户”“高活跃”“偏好美妆”“618下单用户”,每个标签维护一个set,set里存用户ID。要判断一个用户是否有某个标签,SISMEMBER搞定;要统计某个标签下有多少人,SCARD搞定;要圈选同时满足多个标签的人群,比如“新用户且高活跃且偏好美妆”,一条SINTER直接出来。
我在实际项目里见过一个很漂亮的用法:标签筛选用SINTERSTORE把结果落到临时key,然后交给运营后台做分页展示。这样做的好处是避免每次筛选都把大量用户ID拉到应用端,同时临时key设置expire,第二天自动过期清理。
但要注意,标签集合的成员是用户ID,几千、几万、几十万的规模,set都能扛得住。如果到了千万级别,每个标签set会变得很大,SINTER的计算开销也随之上升,这时候就要考虑分片或者换引擎,而不是继续硬扛。
3.2 抽奖、抢名额与随机分配
抽奖系统用set非常顺手。活动开始前,把所有参与用户SADD进一个set;开奖时用SPOP随机弹出中奖用户,一次弹出一个,弹出来的就不在了,天然避免同一个人中多次奖。
如果是“先到先得”的秒杀型名额分配,set也能帮上忙。比如只有100个内测名额,用户点击申请就把用户ID SADD进集合,SADD返回1说明抢上了,返回0说明他已经在集合里或者名额满了,SCARD到100就关闭入口。整个过程是原子的,不会有并发问题。
还有一类场景是随机分配,比如“把一批任务随机分给一台机器执行”,用一个set存所有机器节点,SRANDMEMBER随机取一台;如果要取一台并从候选池里剔除,就用SPOP。不同的随机需求对应不同的命令,唯一要注意的是业务上到底需不需要剔除。
3.3 社交关系与好友推荐
社交产品里,set几乎是必然选择。我关注了谁、谁关注了我、我和其他人的共同关注量,这些用set表达非常自然。互相关注关系可以理解成两个集合的交集运算:我关注的人集合,他关注的人集合,SINTER求出的就是双方都关注的人。
差集在“好友推荐”里更常用。推荐给用户“可能认识的人”,逻辑是:他的好友集合中,去掉我关注过的人,剩下的就是潜在的社交关系。这个逻辑用SDIFF一条命令就能算出来。如果想让推荐结果更精准,还可以叠加多层交集、差集,组合出更复杂的规则。
社交关系场景的另一个特点是数据量大。一个百万粉丝用户的关注集合会非常大,这时候SMEMBERS拉全量就很危险,要么用SSCAN迭代处理,要么拆分成多个set按粉丝ID区间分片。
3.4 在线状态与轻量去重(set的适用边界)
在线状态是set的一个轻量用法:在线用户ID集合,用户上线SADD,下线SREM,在线人数SCARD,判断某人在不在线SISMEMBER。这个方案在小规模场景下简单可靠。
但这里必须说清楚set的边界。如果用set统计一个超大活动的独立访客数(UV),每个用户ID作为成员存在set里,一个用户ID几十字节,百万级UV就是几十MB内存,千万级就是几百MB甚至几GB,内存压力非常大。对这种场景,更适合用HyperLogLog这种概率数据结构——占用固定内存,误差在可接受范围内。set能存,但不该这么存。
所以选set之前先问自己三个问题:要不要保证100%精确?是的话set合适。集合规模大概在什么量级?百万以内set很舒服,超过就得想替代方案。是不是需要集合运算?如果需要交并差,那set就是最优解,哪怕规模大一点也只能想办法分片。
4. set类型的底层结构与内存特性
4.1 intset编码:教科书级别的紧凑结构
Redis的set底层有两种编码:intset和hashtable。intset是一个紧凑的整数数组,按从小到大排序存储所有元素。之所以用这种结构,是因为查找可以用二分查找做到O(logN),而且内存占用非常小——每个成员只存一个数字本身,没有指针、没有哈希表的额外开销。
举个例子,一个存了100个用户ID(都用数字ID)的set,如果走intset编码,内存就是连续数组,非常紧凑。换成hashtable后,每个成员还要存一个dictEntry、一个key对象引用、一个NULL的value指针,内存开销瞬间大好几倍。所以Redis默认会先把整数型set做intset编码,这是为了省内存而做的优化。
4.2 hashtable编码与转换条件
当set不满足intset的条件时,Redis会把底层改成hashtable编码。转换条件有两个:一是set里出现了非整数元素,比如字符串类型的成员,intset只能存整数,存不了就升级;二是整数元素数量超过了set-max-intset-entries的配置值,默认是512。超过这个值,就算全是整数,Redis也会把它变成hashtable,因为intset的二分查找在数据量变大之后性能不如哈希查找。
这个转换是“不可逆”的。一旦从intset升级成hashtable,即使你把元素删到512以下,Redis也不会自动转回去。所以写代码时不要以为用intset就能永远省内存——如果业务上会给set添加字符串成员,就要接受最终会全部转成hashtable的结果。
4.3 为什么set的查找是O(1)
hashtable编码下,Redis的set本质上就是一个value为NULL的哈希表。查找一个成员是否存在,直接哈希定位,O(1)时间复杂度。这解释了为什么SISMEMBER在千万级key上依然很快。哈希碰撞的时候会退化成链表遍历,但Redis的哈希表有渐进式rehash机制,碰撞率被控制得很低,日常使用基本感受不到性能波动。
这里有个实操心得:判断“一个成员在不在集合里”是set最高频的操作场景。配合expire,可以做出很多“XX时间内只能操作一次”的限流逻辑。比如SADD返回1才能继续流程,配合EXPIRE设置有效期,就能实现“10分钟内每个用户只能提交一次申请”这类需求,既简单又原子,不用考虑并发覆盖。
5. 实际运维中的常见问题与排查实录
5.1 SMEMBERS把Redis拖到阻塞,用SSCAN分批取
这是set使用中最容易踩的坑。某个运营系统上线了一个新功能,页面要展示一个标签下的全部用户ID,开发直接用了SMEMBERS。当时标签集合里已经积累了两百多万个用户ID,一条SMEMBERS命令把两百多万个字符串在几毫秒内打包返回,网络包巨大,Redis主线程长时间忙于序列化输出,其他读写请求全部排队。线上立刻出现大面积超时。
排查时先用redis-cli --bigkeys扫了一遍,发现这个key非常大。然后看慢查询日志,SMEMBERS的执行时间长达数秒。解决方案是改用SSCAN命令,游标方式增量迭代,每次取几百个成员,循环读完整个集合。下面是一个Python伪代码示例:
import redis r = redis.Redis(host='localhost', port=6379, db=0) cursor = 0 while True: cursor, members = r.sscan('tag:user:2023', cursor=cursor, count=500) for member in members: # 处理单个成员 pass if cursor == 0: breakSSCAN是增量迭代命令,每次返回一批成员和一个新的游标,不会一次性把所有数据加载到内存,也不会长时间阻塞Redis。对小集合,SMEMBERS没问题;对可能增长到几十万以上的集合,一开始就养成用SSCAN的习惯,这是避坑的第一原则。
5.2 SPOP和SRANDMEMBER的坑被踩得最深
我在一个活动项目里见过一次数据丢失事故。运营搞抽奖,开发用SRANDMEMBER取出中奖用户,然后把用户从集合里移除。逻辑看起来没错,但出问题出在并发上:两个线程同时SRANDMEMBER,可能取到同一个用户,导致奖品重复发放。
后来改成SPOP。SPOP是原子操作,取出成员的同时就从集合里删掉了,并发场景下不会重复。这个案例给我们的教训是:抽奖这类“选完即止”的需求,要选SPOP而不是“SRANDMEMBER+手动删除”两步操作。
另一个常见误区是混淆count参数“可重复”和“不可重复”的行为。SRANDMEMBER key 5 返回5个不重复成员,但如果集合里一共只有3个成员,也只会返回3个;SRANDMEMBER key -5 则可能返回5个成员,同一成员可能多次出现。抽奖要求不重复,必须用正数;模拟重复抽样时才用负数。这个细节很多人要么不知道,要么用错了之后一脸懵。
5.3 类型误用引发的WRONGTYPE报错
Redis会根据类型分发命令,对一个string类型的key执行SADD,会直接报WRONGTYPE。这类问题大多出现在key命名不规范、多个业务共用一个key前缀时。
排查思路其实很简单。先用TYPE命令查看key的实际类型,再用OBJECT ENCODING查看底层编码。如果线上有复杂的key管理,建议把所有key登记在系统里,key前缀必须标识业务和数据类型,比如set:activity:10010:participants,一眼就能看出来这是set。命名规范看起来是小事,但线上出问题时能少掉一半排查时间。
5.4 SINTERSTORE覆盖目标key导致线上数据异常
带STORE的集合运算命令,最大的风险是覆盖已有key。有人会把一个耗时很长的交集结果写到固定的目标key,比如cache:common:friends,方便第二天直接读取。但是如果某个时刻源集合数据有误,SINTERSTORE会把原来正确的缓存结果覆盖掉,引发连锁反应。
稳妥的做法是三步走:先SINTERSTORE写到一个临时key,然后用RENAME原子地替换目标key,再让临时key过期。RENAME是原子的,不会出现“读到一半的脏数据”,这样即使源数据短暂出错,也最多是缓存更新延迟,而不是数据永久被污染。
5.5 数据量评估失误,set膨胀成big key
set作为集合,有天然的big key风险。线上出过这么一件事:运营团队为了做用户分层,把“一周内访问过首页的用户ID”全塞进一个set,日积月累这个set达到了几百万个成员,RDB持久化、主从同步、AOF重写都受到影响。
评估一个set是否安全,主要看两点:一是单个成员的平均大小,二是成员数量。如果set里存的都是长字符串(比如完整URL),几万个成员就已经很大了;如果存的是数字ID,上百万成员也可能没问题。线上必须建立big key巡检机制。定期用redis-cli --bigkeys扫描实例里最大的key,超过阈值的set要么按业务维度拆分,要么改成hyperloglog这类更省内存的结构,要么下推到离线任务计算而不是实时存储。
6. 实战习惯与收尾建议
6.1 命令习惯:先看复杂度再动手
用set之前,养成一个习惯:把命令的时间复杂度在脑子里过一遍。SADD单个成员是O(1),SISMEMBER是O(1),SCARD是O(1),SREM单个成员是O(1),这些问题不大。SMEMBERS是O(N),SINTER最坏是O(N*M),SUNION是O(N),这些就要谨慎了。
判断依据很简单:会不会随着一个集合的增大,命令的耗时线性增长?如果是,而且这个集合可能涨得很大,就要考虑分批处理或者改用存储型命令提前算好。set类型本身没毛病,出问题的都是命令用在不合适的量级上。
6.2 容量评估与监控
set的容量评估公式可以按编码分开算。intset编码下,内存大概是成员数量乘以每个整数占用的字节数(4或8字节),非常容易估算。hashtable编码下,内存会明显上浮,一个成员除了本身的字符串内容,还要负担哈希表结构、dictEntry指针、key对象的包装开销,实测下来一个十几字节的字符串成员,整体内存开销可能翻倍甚至更多。
我习惯在项目上线前做一次容量粗测:用一个临时集合,插入和线上预估量级相同的数据,用redis-cli memory usage key查看实际占用,然后乘上线上的预估量。这个值如果超过单实例可用内存的10%,就要提前做分片设计,而不是等内存涨上来才亡羊补牢。另一点是监控。big key、慢查询、内存增长趋势,这三样必须盯住。Redis本身对set的操作很快,但如果接口响应突然变慢,先查慢日志,再查那个最大的key是不是被某个命令全量扫描了。
最后分享一个实际操作中的体会:set是那种“用得好多省很多事”的类型。很多看起来复杂的业务规则,交并差一组合就变简单了。但它也是那种“用错了事很大”的类型,big key、阻塞、数据被覆盖,每一个坑都真实存在。如果你现在正在设计一个依赖集合关系计算的功能,我的建议是先想清楚数据量级,再选具体命令,最后把监控指标定好,剩下的交给set就好。