Redis Set类型完全指南:底层编码、命令实战与场景选型
发布时间:2026/10/6 13:16:35 作者:尧图编辑部 阅读量:1,286

从标题看可能有点抽象先把范围说清楚这里的“value类型”指的是 Redis 这种 key-value 存储里key 背后的 value 到底是什么数据结构。入门的时候几乎所有人都从String开始列表用List对象映射用Hash到了需要“去重”“算交集并集”这种需求很多人第一反应并不是Set而是继续用List各种 contains 判断或者在业务代码里用 HashSet 绕一圈。我见过不止一次这样的场景需求是给用户打标签A 同学用List存储每次添加标签前先遍历一遍判断重复B 同学更直接用String拼 JSON取共同兴趣用户时在程序里两层循环。明明 Redis 专门为这种场景准备了Set类型一个SADD天然去重一个SINTER直接算出共同标签用户非要用最费劲的方式实现。这篇文章就把Set从底层编码到命令实战到场景选型完整讲一遍希望能帮你把这个“不起眼但真香”的类型彻底用明白。需要说明一点这里说的 value 和最近 LLM 圈常讲的 token 里的 value我能提供什么完全是两码事不要混淆。Redis 的 value 就是 key 对应的值而Set是这个值的类型之一。1. 一句话定位 SetRedis 五大数据类型里的“关系处理器”1.1 先看清 Redis 的 value 类型全貌Redis 的基础数据类型一共五种每个能干什么先放在一张表里看类型底层结构概念最擅长解决的事典型场景String二进制安全字符串缓存单值、计数器、标志位缓存用户信息、库存扣减、分布式锁这里用的是 SET 命令List双向链表有序、可重复、两端操作消息队列、时间线、最近浏览记录Hash哈希表 / 压缩列表对象字段映射、批量操作某个对象商品详情、购物车、用户属性Set整数集合 / 哈希表无序、唯一、集合间运算标签、去重、抽奖、好友关系ZSet跳表 哈希表带权重的有序集合排行榜、延时队列、TopNSet在这五兄弟里面核心定位就四个字关系处理。它不擅长存大文本也不擅长做队列但一旦遇到“某个集合里的元素是否出现过”“两个集合之间有什么交集并集差集”这类问题它就是最高效的选择。1.2 三大特性唯一、无序、能算Set的特性总结下来就三点理解透了基本就掌握了一半特性一元素唯一。往 Set 里重复添加同一个元素第二次添加会被忽略。这个“天然去重”能力省掉的不是一行判断代码而是整个去重流程的设计。业务上最常见的“用户是否已经参与过活动”“IP 是否已经访问过”“标签是否已经打过”全部可以交给Set。特性二无序。Set 不维护插入顺序也不能像 List 那样按下标取元素。要注意一点它在底层用 intset整数集合存储时小整数看起来像是排好序的但这只是底层编码带来的“顺带效果”Redis 官方并不保证顺序。依赖顺序的场景老老实实用List或ZSet。特性三集合运算。这是Set真正的杀手锏。交集、并集、差集都是 Redis 服务端直接算好的一条命令拿到结果而且支持把结果存到新 key 里。业务代码完全不需要把数据拉到内存里自己算。1.3 为什么很多人第一反应不是 Set我复盘过身边同事误用 List 的原因主要还是对“Set 到底能干什么”没有具体概念。一提 Set 就觉得“哦去重”但去重这事用其他手段也能做比如 Java 的HashSet、数据库的DISTINCT然后就绕过去了。直到某个晚上排查线上问题标签集合已经有几十万成员业务代码把整个 List 拉出来在 JVM 里做交集内存飙到告警线才意识到“原来集合运算是应该在 Redis 里完成的”。2. 底层编码探秘intset 和 hashtable 的自动切换规则聊完定位再往下挖一层。给 Redis 面试准备过的同学应该听过“intset”和“hashtable”这两个词但很多人只背了结论没搞懂切换的根因。这一节我们把底层机制讲透。2.1 intset当集合全是整数且规模不大时内存最省intset是 Redis 自己实现的一种紧凑整数集合结构。它的内存布局非常直接一个编码标志encoding、一个长度字段length加上一段连续排列的整数数组contents。encoding有三种取值int16_t、int32_t、int64_t。初始创建时如果元素都在 16 位整数范围内-32768 到 32767就用 16 位存储一个元素只占 2 字节。后面如果添加的元素超出了当前编码范围比如 16 位集合里突然插入一个 5 万的大整数intset 会触发一次升级整个 contents 数组从int16_t重新分配成int32_t的数组并把已有元素逐个迁移过去。为什么 Redis 要这么设计因为绝大多数标签 ID、用户 ID 关联的集合初期都是小整数用 int16 能极大压缩内存。一个 100 个元素的集合用 intset 存可能只要 200 多字节用普通哈希表存光节点开销就是好几 KB。我在本地做过对比set-max-intset-entries默认配置下存一万个小整数intset 模式的内存占用和 hashtable 模式能差一个量级。但升级这个过程是有代价的升级需要重新分配内存、拷贝现有元素。所以元素插入的均摊复杂度虽然是 O(1)但遇到一次大规模升级时会有明显的卡顿。如果集合提前就能预见会放入超大整数心里要有这个底。2.2 什么条件下触发 intset 转 hashtableRedis 选择编码只认两个硬条件集合内所有元素必须是整数包括字符串形式的整数字面量集合元素个数不超过配置项set-max-intset-entries默认是 512。只要有一个条件不满足编码立刻切换为hashtable。切换后的底层就是一张哈希表key 存集合元素本身value 统一为 NULL。这样设计的原因很简单元素千奇百怪字符串、二进制都可能出现只有哈希表能保持 O(1) 的插入和查找。我用一个例子演示这个切换过程# 初始整数集合编码为 intset SADD demo_set 1 2 3 OBJECT ENCODING demo_set # 输出: intset # 加入一个非整数元素编码立即变为 hashtable SADD demo_set abc OBJECT ENCODING demo_set # 输出: hashtable # 删除非整数元素后编码不会自动转回 intset SREM demo_set abc OBJECT ENCODING demo_set # 输出: hashtable不会自动降级这里有个关键坑留在后面避坑章节细说intset 转 hashtable 是单向的不会自动迁回。2.3 为什么纯整数小集合返回的数据“看起来有序”不少同学实测过SADD nums 5 3 9 1之后执行SMEMBERS nums返回结果却是1 3 5 9第一反应是“Redis 的 Set 不是无序吗怎么排好序了”这就是 intset 编码的“副作用”。intset 为了让二分查找效率更高内部数组始终维持升序。所以只要集合是 intset 编码遍历出来的顺序天然就是整数升序。一旦切到 hashtable 编码顺序就是哈希遍历顺序彻底无法预测。因此平时排错看到“有序”不要惊讶也不要依赖这个顺序写逻辑。2.4 查看编码与调整阈值排查别的团队留下的 Set 时我第一步习惯看编码OBJECT ENCODING key如果想知道该不该用 Set 优化内存可以临时调整set-max-intset-entries做对比测试。但线上不建议调太大原因后面避坑章节会讲。默认 512 是针对绝大多数业务场景的合理折中——超过这个数的集合用哈希表反而查找效率更高。3. 高频命令拆解从 SADD 到 SPOP 的逐条实战底层看完了命令才是日常真正敲的东西。很多教程把 Set 命令一个个列出来就完事但没有告诉你它们之间微妙的差异。这一节我按“添加删除、查询、随机取、跨集合移动”分组讲每一组都有可以直接抄的用法。3.1 添加与删除SADD 和 SREM 的返回值别忽略SADD user:1001:tags apple banana apple # 返回 2因为 apple 重复添加只算一次SADD返回的是实际新增的元素个数不是操作后的集合大小。这个返回值可以用来判断“是不是第一次添加”返回 1 说明这个元素原本不存在返回 0 说明已存在。这个特性在做“是否新用户参与活动”时特别好用省去一次SISMEMBER查询。SREM同样返回实际移除的数量移除不存在的元素返回 0不会报错。删除整个 Set 用DEL或者让它自然过期Set支持设置过期时间EXPIRE。3.2 查询操作SISMEMBER 是核心SMEMBERS key返回集合所有成员O(N)小集合随便用大集合慎用。SCARD key返回集合元素个数O(1)。SISMEMBER key member查询某个元素是否存在O(1)这是判断类场景用的最多的命令。我遇到很多人判断元素在不在 Set 里时用SMEMBERS拉回来在代码里contains这是极其错误的做法。十万成员的集合一条SMEMBERS就能把网络和内存吃满而SISMEMBER只是一次 O(1) 的哈希查找。能用SISMEMBER判断的绝不要SMEMBERS。3.3 随机取元素的两种姿势SPOP 和 SRANDMEMBER这两个命令是抽奖场景的核心但它们的语义区别经常被搞混命令是否删除元素典型场景SPOP key [count]会删除弹出后集合里不再有抽奖不放回、队列任务领取后移除SRANDMEMBER key [count]不删除只是随机查看随机推荐、抽查、抽奖可重复中奖# 抽奖5个人里抽1个抽中的移出池子 SPOP lottery:202501 1 # 随机抽查从候选名单里随机看3个不影响名单 SRANDMEMBER candidates 3还有个小知识点SPOP不带 count 时返回单个元素字符串带 count 时返回数组SRANDMEMBER带负数 count 时允许返回重复元素。这个细节在写代码时很容易踩解析返回值类型之前先确认一下自己传没传 count。3.4 SMOVE跨集合移动的原子操作SMOVE source destination member把一个元素从源集合移动到目标集合。SMOVE pending:queue processing:queue task_1001注意两点第一如果源集合里没有该元素返回 0什么都不发生第二这个操作是原子的非常适合“任务从待处理到处理中”这种状态流转场景业务方不需要再写“先删除再添加”的两步逻辑天然避免中间态。3.5 命令速查总表命令语法复杂度说到要记住的坑SADDSADD key member [member ...]O(N) N为新加元素数返回新加的数量不是集合总大小SREMSREM key member [member ...]O(N)返回移除的数量移除不存在返回0SMEMBERSSMEMBERS keyO(N)大集合慎用改用SSCANSCARDSCARD keyO(1)判断空集合用 EXISTS 也行SISMEMBERSISMEMBER key memberO(1)比 SMEMBERS 高效得多SPOPSPOP key [count]O(N) N为弹出数会删除元素SRANDMEMBERSRANDMEMBER key [count]O(N)不会删除元素负数count可重复SMOVESMOVE src dst memberO(1)原子操作4. 交并差运算一条命令解决复杂数据关系如果只允许用一个理由说服团队引入 Set我一定会说集合运算。这一节讲清楚 Redis 是怎么算的以及生产环境里怎么用才不掉链子。4.1 三个运算命令SINTER、SUNION、SDIFFSINTER key [key ...]交集返回所有集合中都存在的元素。SUNION key [key ...]并集返回所有集合的元素的去重合并。SDIFF key [key ...]差集返回第一个集合中存在、但后续集合都不存在的元素。SADD user:1001:follow user:2001 user:2002 user:2003 SADD user:1002:follow user:2002 user:2003 user:2004 # 共同关注 SINTER user:1001:follow user:1002:follow # 返回 (empty array) 或交集元素 # 1001 关注了但 1002 没关注的 SDIFF user:1001:follow user:1002:follow注意SDIFF的方向性它跟数学里的差集一样集合顺序不同结果完全不同。SDIFF A B和SDIFF B A是两回事互相把对方的差异元素给了对方。写代码时把顺序写反排查起来非常隐蔽因为命令本身不会报错返回结果还“看起来合理”。4.2 结果存盘STORE 系列命令的价值交并差还有一种带STORE的版本SINTERSTORE、SUNIONSTORE、SDIFFSTORE。它们的第一个参数是目标 key后面的参数是参与运算的集合。SINTERSTORE common:friends user:1001:follow user:1002:follow为什么需要存盘版本因为在真实场景里交集结果往往不会只被消费一次。比如“共同关注”这个结果可能要展示给前端也可能要做二次过滤还可能作为下一次运算的输入。直接对源集合反复做 SINTER每次都全量计算一遍数据量大时非常浪费。先把结果存到一个临时 key后续直接读这个 key这是典型的“用空间换时间”做法。我一般会在项目里约定所有 STORE 类命令生成的临时 key统一加前缀tmp:并且设置EXPIRE60 秒左右避免临时数据残留成垃圾 key。线上见过有人把中间结果存了不设过期一个月后 Redis 里几百个完全没用的集合白白占内存。4.3 内部计算复杂度为什么 Redis 算交集比程序里算快官方文档对SINTER的复杂度描述是O(N*M)N 是参与运算集合里最小的那个集合的元素个数M 是集合数量。这个复杂度的关键在于 Redis 内部的优化策略它不会拿所有集合的元素互相遍历比较而是先按集合大小排序从最小的集合开始依次取每个元素去和其他集合做哈希查找。集合越小待查的元素越少每查一次都是 O(1)。这个设计的好处是运算耗时主要由最小集合决定而不是最大的那个。所以如果你有一大一小两个集合做交集把小的放前面理论上查询效率是最好的虽然 Redis 内部也会排序但命令参数顺序影响不大真正影响性能的是最小集合的数据量。还有一个实用建议参与运算的集合如果长期存在且非常大可以考虑用SINTERSTORE做一次“预计算”比如每天晚上算好“热门用户共同标签”存起来白天直接查。4.4 差集的一个隐藏坑SDIFF的实现和交集不同它是按“第一个集合所有元素逐一检查是否存在于其他集合”来做的所以复杂度是O(N)N 是第一个集合的元素个数。第一个集合越大差集计算越慢。如果你要频繁计算一个大集合相对小集合的差集考虑是否能把大集合拆小或者接受预计算的方案。5. 真实场景落地标签、抽奖、关注关系与分布式锁的边界讲完命令直接上战场。以下场景都是我实际在项目里用过或者排查过的含金量不低。5.1 标签系统的双向设计标签是 Set 最经典的场景。它有两条线线一给用户打标签反查用户。SADD user:1001:tags vip sports digital想知道哪些用户有“sports”标签传统思路是扫描所有用户但在 Redis 里有一个更好的设计——倒排索引SADD tag:sports users 1001 1002 1003这样查“sports 标签下有哪些用户”就成了 O(1)。实际业务中正排和倒排常常是一起维护的用户维度方便查个人标签标签维度方便做运营筛选。维护两个方向只需要两次SADD/SREM用管道或者 Lua 脚本保证原子性即可。线二圈选人群。运营要推“VIP 且喜欢数码”的用户就是两个标签集合的交集SINTERSTORE campaign:users tag:vip tag:digital SUNIONSTORE campaign:union tag:vip tag:digital一条命令完成人群圈选还支持存盘运营后台接口的性能能好到一个量级。5.2 抽奖场景的两种随机策略抽奖需求的两种典型玩法之前提过的SPOP和SRANDMEMBER正好一一对应。不放回抽奖一人只能中一次SADD lottery:202501 user_001 user_002 user_003 SPOP lottery:202501 3弹出即移除天然保证不会重复中奖。可重复抽奖比如转发里随机抽几条做展示SRANDMEMBER lottery:202501 3不删除、不影响后续抽取多次抽可以在同一批人里反复出现。这里有个细节抽奖池如果很大比如百万级用户SRANDMEMBER带上大 count 时内部也要生成随机下标并遍历元素性能会下降。百万级以上的随机抽取建议拆池或直接用专门的抽奖服务。5.3 社交关系共同关注与“可能认识的人”社交关系是图结构但用 Redis Set 可以很优雅地解决很多“二维关系”问题共同关注SINTER user:A:follow user:B:follow双向关注后还能判断是不是互粉。我关注的人里谁关注了他SINTER user:A:follow user:B:fans。可能认识的人先取我关注的人的粉丝集合做并集再做差集去掉我已经关注的人。这正是差集的使用场景SUNIONSTORE tmp:recommend user:A:fans user:B:fans SDIFF tmp:recommend user:A:follow虽然只是最简单的模型但已经能支撑起初版“好友推荐”功能。真正要扩展到二度人脉就得引入图数据库或者专门的推荐引擎了那是另一个话题。5.4 分布式锁的边界SET 命令和 Set 类型不是一回事这里必须澄清一个高频误解。搜索“redis分布式锁”能看到大量文章里面提到的核心命令通常是这个样子SET lock:order:1001 1 NX EX 30注意这是String 类型的SET命令配合NX不存在才设置和EX过期时间实现锁的原子获取和我们本文讲的 Set 数据类型没有任何关系。动不动就有人问“Set 类型能不能实现分布式锁”。答案是不能。Set 虽然保证了成员唯一但没有NX/EX这种“不存在才写入 自动过期”的原子语义。即使你用SADD模拟“加锁”返回 1 表示抢锁成功也需要额外手段实现过期时间而且释放锁时要判断持有者身份这些都是 Set 类型做不到的。正确的做法是分布式锁用 String 类型 SET 命令的 NX EX 选项或者直接用 Redisson 这种成熟库。“SET 命令”和“Set 类型”只差一个空格概念却差了十万八千里这是我面试别人时最喜欢挖的一个点。5.5 顺带一提UV 去重用 Set 还是 HyperLogLog运营要统计某篇文章的独立访客量很多人第一反应是SADD page:uv user_id然后SCARD拿到去重后的数量。这个方案在 UV 几万时完全没问题但如果到了千万级别一个页面一个 Set内存很快顶不住。Redis 还提供了HyperLogLog类型标准误差约 0.81%用极少内存就能统计到亿级去重数。它和Set的取舍标准是只要需要精确列出访客是谁就用 Set只需要知道大概的人数用 HyperLogLog 省内存。没有一样东西在所有维度上都赢选型永远是看最核心的业务诉求。6. 踩坑记录与性能优化大key、编码陷阱和类型选型这一节是我最想分享的部分全是实操中真正出过问题的地方。6.1 SMEMBERS 全量返回的大 key 灾难之前线上有个标签集合高峰期成员数到了五十万。某个新来的同事为了判断用户是否打了某个标签写的是先把整个集合SMEMBERS拉到本地再contains。结果就是 Redis 单线程执行 O(N) 全量输出网络传输几十 MB 数据那个 Redis 实例的响应时间整体上涨。修复方案有两个方案一首选改用SISMEMBER。SISMEMBER tag:vip user_1001一次 O(1)逻辑还更简单。方案二必须遍历时用SSCAN增量迭代。SSCAN tag:vip 0 COUNT 1000SSCAN返回游标加一批数据反复迭代直到游标回到 0整个过程不会阻塞 Redis 单线程。做定时任务全量扫描集合时这条命令是保命用的。6.2 intset 转 hashtable 的单向陷阱前面已经提过 intset 转 hashtable 不可逆。这个特性带来的问题很隐蔽假设业务初期集合里存的是纯整数用户 ID一直保持 intset 编码内存很省。某天需求变更往同一个集合里插入了一个字符串形式的用户标识比如user_abc123编码立刻升级成 hashtable整个集合的底层结构彻底改变。最麻烦的是即使你之后把字符串删掉、把集合改回纯整数编码也不会迁回 intset。这就意味着内存占用永远停留在了 hashtable 的高水位。排查方法是OBJECT ENCODING key如果发现明明是小规模整数集合却是 hashtable最大的嫌疑就是之前误入过一个非整数元素。要彻底解决只能把集合导出重建让新集合重新走 intset 编码。6.3 set-max-intset-entries 调大的诱惑与风险有段时间为了省内存我把set-max-intset-entries从默认的 512 调到了 5000。短期看内存确实降了但随后发现一个问题intset 模式下添加一个大整数可能触发整体升级重建整个数组的 CPU 消耗比哈希表插入高得多。如果升级频繁发生或者集合里元素并非单调插入Redis 单线程执行升级时其他命令会被阻塞。这个配置项默认值刻意保持较低是有道理的——512 这个数字保证了 intset 的内存优势同时把升级开销控制在一个极小范围内。除非你非常确定集合是“一次性批量写入、后续只读”否则我不建议在生产环境调高它。6.4 Set 和其他类型的选型判断表很多场景踩坑的本质是类型选错。我总结了一个快速判断逻辑业务诉求正确选择错误选择及其后果去重且需要判断存在Set SISMEMBERList containsO(N) 慢且代码复杂按顺序展示/时间线List / ZSetSet 无序顺序无法保证带权重排序ZSetSet 无法存score对象字段频繁更新HashString 序列化整个文件更新一个字段要重写全部大量计数器快速自增String INCR每次先读后写原子性难保证千万级 UV 统计HyperLogLogSet 内存爆炸集合间求交并差Set业务层自己实现性能差一个量级6.5 面试里的 Set 高频考点结合Redis面试题里常见的提问我把 Set 相关的考点整理一下面试前看一眼能救急Set 和 List 的区别是否有序、是否去重、查询复杂度、集合运算能力。Set 底层实现intset 和 hashtable 两种编码触发条件是什么能否互相转换。SINTER 的时间复杂度为什么是 O(N*M)N 是最小集合大小Redis 从最小集合开始遍历做哈希查找。SPOP 和 SRANDMEMBER 的区别会不会删除元素抽奖场景分别怎么用。Set 能否实现分布式锁不能锁要用 String 类型的 SET 命令 NX EX。SMEMBERS 处理大 key 应该用什么替代SSCAN 游标遍历判断存在用 SISMEMBER。Set 和 HyperLogLog 怎么选精确要求 vs 内存节省误差容忍度。6.6 值得注意的 Redis 客户端细节最后分享一个我最常遇到的“低级但高频”的坑。用 Spring Data Redis 的RedisTemplate操作 Set 时默认的 key 和 value 序列化器是JdkSerializationRedisSerializer。这会导致一个现象明明SADD user:1001:tags sports在 Redis 里看 key 却是\xac\xed\x00\x05t\x00...一串乱码Set 里的成员也带了二进制前缀。这个问题的根源不是 Redis而是客户端序列化器选错了。我的习惯是在项目里显式配置 RedisTemplateBean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(new Jackson2JsonRedisSerializer(Object.class)); template.setHashValueSerializer(new Jackson2JsonRedisSerializer(Object.class)); return template; }如果你用的是opsForSet()操作Setkey 和成员都用 String直接换StringRedisSerializer最省事。这样在 Redis Desktop Manager 或者其他可视化客户端里看到的数据才是人话排查问题才没那么痛苦。Set 这个类型表面上就几个命令真用好了能解决一批特别头疼的关系计算需求。我对它的整体评价是内存省、命令简单、集合运算是绝活但只要牵涉有序场景就不要勉强它。下次再有“去重”“圈选”“共同关系”这类需求你先想想 Set大概率能找到比在业务代码里硬算优雅得多的解法。