我需要先说明一个情况:这篇文章的价值不在命令行背诵,而在“为什么同样一个需求,有人用List有人用Set,还有人两种组合着用”。
先说个我自己的经历。前年做一个电商后台的运营看板,需求很朴素:运营人员要在大促期间实时看到“最近一小时用户下单的商品ID流水”,同时还要看“当前正在参与秒杀的商品去重集合”。刚开始我图省事,全用List一把梭,结果秒杀商品列表反复出现重复项,运营直接截图来质问:“这数据是不是有bug?”我解释了半天“List本来就不去重”,对方只回了一句:“那我不管,你换一个能去重的”。那次之后我就明白了,Redis五大数据类型里的List和Set,看似都是“装一堆数据”,但底层设计哲学完全不同,用错场景不是出bug的问题,是让人怀疑你专业度的问题。
这篇内容适合谁?刚接触Redis的初学者、写业务代码但没系统梳理过数据类型的老手,以及准备面试想把自己知识体系串起来的同学。我会把List和Set的底层结构、核心命令、适用场景、常见坑全部拆开讲,同时结合真实业务案例说明选型逻辑。
1. 先把List和Set的“人设”搞清楚:有序可重复 vs 无序去重
很多教程一上来就甩命令,但我觉得先搞清楚这两个类型“天生是干什么的”更重要。Redis的每个数据类型都不是凭空设计的,它们对应着实际开发中的高频需求模式。
1.1 List的设计哲学:有序、可重复、两边操作
List在Redis里的本质是一个双向链表(或者压缩列表/快速列表,后面会细说),它的核心特点是:
- 元素有序存储,插入顺序就是存储顺序
- 允许重复元素
- 支持从头部(Left)和尾部(Right)两端操作
用一句话概括:List就是一条排好队的队伍,你可以从队头加人、队尾加人,也可以从两头往外拉人,队伍中间的人保持相对顺序不变。
这个特性决定了List天然适合做:消息队列、时间线列表、最新公告、操作日志等场景。这些场景的共同诉求是“顺序很重要”,而且“重复是允许的”——比如用户可能连续两次购买了同一个商品,日志里就该有两条记录。
1.2 Set的设计哲学:无序、去重、集合运算
Set在Redis里是一个无序的字符串集合,它的核心特点是:
- 元素无序存储,不保证插入顺序
- 自动去重,重复插入只保留一份
- 支持集合运算:交集、并集、差集
用一句话概括:Set就是一个装了一堆不重复元素的麻袋,你往里扔重复的东西会自动被吐出来,而且你还能把两个麻袋倒在一起做交集、并集、差集运算。
这个特性决定了Set天然适合做:去重统计、标签系统、好友关系、权限判定、抽奖池等场景。这些场景的共同诉求是“重复没有意义”——比如一个用户对一篇帖子点过赞,再点一次不该产生两条点赞记录。
1.3 为什么说“人设”决定技术选型
我见过太多人把List当Set用,或者反过来。前者的典型症状是列表里出现大量重复项还得在业务代码里手动去重;后者的典型症状是明明想保存一个有序列表,结果每次取出来的数据顺序都是乱的。
所以选型之前先问自己三个问题:
- 我需不需要保证顺序?需要就选List,不需要就考虑Set。
- 我能不能接受重复数据?能接受就选List,绝对不能接受就选Set。
- 我后面要做集合之间的运算吗?要做交集并集差集,直接锁定Set。
这三个问题想清楚了,选型基本不会跑偏。
2. List类型:底层结构演变与命令组合的实战逻辑
2.1 三种底层编码:ziplist、linkedlist、quicklist
Redis的List不是一上来就用双向链表的。为了在内存利用率和操作效率之间找平衡,它经历了几个阶段:
| 版本 | 默认编码 | 说明 |
|---|---|---|
| Redis 3.2之前 | ziplist / linkedlist | 元素少且长度短时用压缩列表,否则用双向链表 |
| Redis 3.2起 | quicklist | 以节点为单位的压缩列表双向链表 |
| Redis 7.0起 | listpack(替代ziplist) | 进一步优化内存布局 |
为什么要有ziplist?因为双向链表的每个节点都有前驱指针、后继指针,存储开销很大。如果元素本身很短,比如存几个小数字,指针开销可能比数据本身还大。ziplist把一块连续内存里依次排列元素,通过特殊编码标记每个元素的长度,读的时候通过偏移量跳转,内存利用率高但插入删除中间元素时需要移动后续数据。
linkedlist的优势在中间插入删除,但Redis实际使用中,List的典型操作集中在两端(LPUSH、LPOP、RPUSH、RPOP),所以后来用quicklist:每个节点内部是一段ziplist,节点之间用双向指针连接。这样既保证了中间元素的操作效率,又降低了指针数量。
实操中你不需要手动选择编码,Redis会根据元素数量和单个元素的长度自动决定。但理解这一点对排查问题有帮助——如果你发现某个List的操作突然变慢了,可以先查一下它当前是什么编码:
OBJECT ENCODING mylist返回quicklist说明Redis在自动管理,数据量很大时可能每个quicklist节点里的ziplist长度过长,导致中间操作耗时增加。这也是为什么建议单个List里的元素不要无上限增长。
2.2 高频命令组合与业务映射
List的命令不少,但真正高频的就那么几组。我按业务场景来梳理:
场景一:最新消息列表 / 时间线
这个场景的核心诉求是“新数据在前,旧数据在后”,通常配合分页查询。命令组合是:
# 插入新消息到头部 LPUSH news:list "{id:1001,title:'Redis教程'}" LPUSH news:list "{id:1002,title:'MySQL优化'}" # 查询最新5条 LRANGE news:list 0 4 # 查询总共多少条 LLEN news:list注意LRANGE的两个参数都是下标,0代表列表第一个元素,-1代表最后一个元素。所以LRANGE mylist 0 -1是取全部元素,这个操作要慎用,后面我会讲为什么。
场景二:消息队列
List实现消息队列的核心优势是支持阻塞读取,这点后面单独讲。基础命令是:
# 生产者从尾部插入 RPUSH task:queue "{job:1,type:'send_email'}" # 消费者从头部取出并移除 LPOP task:queue这里有个细节:LPOP是取出并移除,如果只是“看一眼”不移除,应该用LINDEX或者LRANGE。这两个语义别混了。
场景三:操作日志 / 行为流水
有时需要记录用户最近N次操作,List配合LTRIM可以轻松实现“只保留最近N条”:
# 记录用户操作 LPUSH user:1001:actions "login" LPUSH user:1001:actions "search" LPUSH user:1001:actions "click" LPUSH user:1001:actions "logout" # 只保留最近2条 LTRIM user:1001:actions 0 1实际项目中“记录最近N条”是非常常见的需求,比如“最近浏览的商品”“最近登录的设备”,用LTRIM一条命令就解决了,不需要额外写一个定时清理任务。
2.3 阻塞队列的落地细节
List能实现消息队列,真正让它区别于Set的关键是阻塞命令:BLPOP、BRPOP。
消费者通常是一个后台线程或独立进程,它在队列为空时不应该疯狂轮询浪费CPU,而应该“睡一会,等有数据了再叫我”。BLPOP就是干这个的:
# 从队列头部取数据,最多阻塞10秒,超时返回nil BLPOP task:queue 10几个容易踩的细节:
- 不要用BRPOP/LPOP直连在Web请求线程里。阻塞命令会挂住连接,如果Web容器线程池小,请求全部卡住就悲剧了。
- 多个消费者同时BLPOP同一个Key时,Redis保证每个元素只被一个消费者取走,不会出现“惊群”导致同一个任务被多次处理。这是很多人忽略的保障。
- 超时时间别设0。0代表永久阻塞,一旦业务方忘记删除这个Key,消费者线程就永远挂那里了。实际项目里我一般设5到15秒,配合循环调用,既能及时响应新消息,又不至于把连接长时间占死。
光有BLPOP还不够,生产中需要考虑消费者宕机后消息会不会丢。List本身不提供确认机制,元素被LPOP拿走就从队列里消失了。如果消费者处理消息时崩溃,这条消息就永久丢失。所以纯List实现的消息队列适合“丢了也无所谓”的场景(比如记录日志),涉及钱的场景还是老老实实上RabbitMQ、Kafka这类专业消息中间件。
2.4 List实现分页的边界问题
List的LRANGE非常适合做类似“朋友圈时间线”的分页:
LRANGE feed:list 0 9 # 第1页 LRANGE feed:list 10 19 # 第2页但是有个隐蔽问题:如果列表里持续有新数据插入(LPUSH),那么你翻页时数据集会“滑动”。本来你要翻第2页,结果用户看到的是第1页刚插入的数据顶掉的位置,会出现“重复或遗漏”的观感。这个在实时性要求高的场景下需要接受,或者改用ZSET(有序集合)按时间戳做游标分页。这也是为什么很多时间线后端最终选了ZSET而不是List的原因。
3. Set类型:去重、集合运算与随机操作背后的原理
3.1 两种底层编码:intset与hashtable
Set的内部编码有两种,Redis根据元素类型和数量自动切换:
| 编码 | 适用条件 | 原理 |
|---|---|---|
| intset | 所有元素都是整数,且元素数量不超过512个(可通过set-max-intset-entries配置) | 有序整数数组,内存紧凑,支持二分查找 |
| hashtable | 元素不是纯整数,或数量超过阈值 | 标准的Redis哈希表,O(1)操作 |
intset这个设计很巧妙:如果存的都是数字,而且数量不多,用数组存储并保持有序,查找用二分查找,比哈希表更省内存。但当数据量变大或出现非整数元素时,自动升级为hashtable。这个转换是单向的——从intset变成hashtable之后不会自动降级。如果你往Set里误加了一个非数字元素,它就从紧凑的intset变回hashtable,内存占用会明显增加。
实操中可以通过OBJECT ENCODING myset查看当前编码,如果发现一个本来全是数字的大集合变成了hashtable,可以排查一下是不是有脏数据混进去了。
3.2 集合运算:交集、并集、差集的业务价值
Set最值钱的能力是集合运算,这是List完全不具备的。命令是三兄弟:
SINTER key1 key2交集:两个集合都有的元素SUNION key1 key2并集:两个集合合并去重SDIFF key1 key2差集:在第一个集合但不在第二个集合的元素
我带过的项目里,这三个命令能省掉大量Java/Python代码逻辑。举个例子,一个社交App的“共同好友”功能:
SADD user:1001:friends "2001" SADD user:1001:friends "2002" SADD user:1001:friends "2003" SADD user:1002:friends "2002" SADD user:1002:friends "2003" SADD user:1002:friends "2004" # 一次性算出两个用户的共同好友 SINTER user:1001:friends user:1002:friends返回结果就是"2002"、"2003"。如果用List存好友列表,你得在业务代码里嵌套循环去重,性能差且代码丑。
再比如电商的标签筛选:商品打了“手机”“5G”“拍照”三个标签,用户想筛选同时满足这三个标签的商品,SINTER一次搞定。配合SINTERSTORE(把结果存到新Key)还能做多级筛选缓存。
3.3 随机操作:SRANDMEMBER与SPOP的区别
抽奖、随机推荐这类需求,用Set非常顺手。但要注意SRANDMEMBER和SPOP的区别:
SPOP key从集合中随机取出一个元素并移除SRANDMEMBER key count随机返回count个元素,但不会移除
以抽奖为例:奖品池是一个Set,每抽中一人就移除,用SPOP;而运营想看一下“如果随机抽5个人会是哪些”,但又不能真把池子清掉,用SRANDMEMBER。
# 初始化奖池 SADD prize:pool "user_001" "user_002" "user_003" # 抽一个并移除 SPOP prize:pool # 模拟随机查看5个中奖者,不实际扣减 SRANDMEMBER prize:pool 5有一个必须注意的坑:SRANDMEMBER传入的count为负数时,允许返回重复元素。比如SRANDMEMBER myset -5可能返回5个包含重复值的元素,而正数5保证无重复。抽奖场景如果你不需要重复,一定要传正数。
3.4 Set做去重计数的陷阱
Set另一个常见用途是去重计数,比如“今天有多少个用户访问了页面”。做法是每个用户ID SADD进当天的Set,然后SCARD得到去重数:
SADD uv:20250101 "user_001" SADD uv:20250101 "user_002" SADD uv:20250101 "user_001" SCARD uv:20250101返回2,去重成功。这个方案在小规模流量下没问题,但有一个天花板:日活上涨到百万以上时,一个Set存百万个元素内存开销很大。这时候应该评估改用HyperLogLog(误差0.81%但内存占用极低)或者PFCOUNT方案。Set的去重是精确的,但在内存成本和精度之间需要做权衡。我不建议无脑上HyperLogLog,如果产品要求精确数字,Set依然是最稳的选择,只是要注意容量规划和Key的过期设置。
4. 选型不是二选一:List和Set混用的真实案例
4.1 双向关系中的List + Set组合
实际业务中,List和Set很多时候不是竞争关系,而是配合关系。我印象最深的一个需求是“帖子详情页的评论时间线 + 已读用户去重”。
评论列表需要按时间倒序展示,且允许同一用户多次评论,这里用List存评论ID顺序:
LPUSH post:1001:comments "comment_1" LPUSH post:1001:comments "comment_2"同时,需求要求统计“有多少人已经读了这个帖子”,每个人的ID只能计数一次,这里用Set:
SADD post:1001:readers "user_001" SADD post:1001:readers "user_002" SADD post:1001:readers "user_001" SCARD post:1001:readers两个类型在同一个业务里各司其职:List管顺序,Set管去重。这种“一个业务键不同维度用不同类型”的设计很常见。
4.2 用Set做List的补充索引
如果你有一个List存了最近浏览的商品,同时想快速判断“某个商品是否曾被浏览过”,挨个遍历List是O(n)的。这时候可以在维护List的同时同步维护一个Set:
# 记录浏览顺序 LPUSH user:1001:viewed:list "sku_1" # 记录去重浏览记录 SADD user:1001:viewed:set "sku_1" # 判断是否看过 SISMEMBER user:1001:viewed:set "sku_1"这种冗余存储的模式在Redis实战中非常普遍。用空间换时间,Set的SISMEMBER是O(1)的,而List查找是O(n)。代价是数据一致性需要业务层自己保证:要么在同一个事务里同时写两个Key,要么用Lua脚本保证原子性。
4.3 数据结构的组合演变为ZSET铺路
必须承认,有些场景里List和Set都不是最佳方案,比如“需要有序且去重”的数据。这时候该想到的是ZSET(有序集合),它是五大数据类型里的另外一种,把“有序”和“去重”合二为一。但理解List和Set的设计边界,恰恰是理解ZSET的前提——ZSET本质上是一个Set(元素唯一),外加一个Score(提供排序依据)。所以在讲完List和Set之后我通常会建议读者自己推导一遍:如果业务既要求元素唯一,又要求按时间排序,ZSET是不是更自然?
5. 实战中的坑与排查经验:序列化、大Key与过期策略
命令背得再熟,落地时还是会踩坑。这里列几个我在项目中真实遇到、且极具代表性的问题。
5.1 可视化工具里看到的全是乱码
用Redis Desktop Manager之类的工具看List或Set数据时,经常看到\xac\xed\x00\x05t...这类乱码。这通常不是数据坏了,而是你用Spring Data Redis存储对象时默认用了JDK序列化,把对象序列化成了一堆二进制。乱码不影响业务逻辑,但排查问题的时候非常痛苦——你根本看不清里面存的是什么。
解决方式是在RedisTemplate里显式设置JSON序列化器,配置一个StringRedisSerializer的Key序列化器,Value用GenericJackson2JsonRedisSerializer。这样存进去是JSON文本,可读性大幅提升。如果你已经存了JDK序列化的数据,只能迁移——没有优雅的原地转换办法。
5.2 LRANGE/SMEMBERS全量操作导致的性能雪崩
这是Redis使用中最常见的“大Key”问题。List的LRANGE 0 -1会一次性把全部元素取出来,Set的SMEMBERS会把整个集合的所有成员返回。当集合有几十万个元素时,这一条命令就能把Redis阻塞在这上面:内存拷贝压力大,网络传输也巨大。
我在一次大促复盘时发现,某个做标签风的Set存了一百多万个元素,而业务方为了同步全量标签到本地缓存,每五分钟执行一次SMEMBERS。结果每次执行Redis主线程都卡顿几百毫秒,连带影响其他请求。
解决方案有几个:
- 改用
SSCAN(List没有对应的SCAN,考虑分批LRANGE或者用LPOS) - 改用游标式增量同步,而不是全量拉取
- 拆Key:按业务维度把大Set拆成多个小Set
核心原则是:任何一次可能取出海量数据的命令,都要在设计阶段就禁止。
5.3 Key过期时间没设置导致的内存泄漏
Set和List如果不设置过期时间,并且业务持续往里写数据,Redis内存会被慢慢吃光。典型场景:用户浏览记录List,因为要展示“最近浏览”,每天往List里LPUSH数据,但没做LTRIM截断,也没设置EXPIRE,半年后每个用户的List里躺了几万条记录。
这里有两个坏习惯要改:
- 任何集合类型的数据,先评估它的最大合理长度。List用LTRIM限制长度,Set用定期清理或者过期时间控制生命周期。
- 过期时间要加随机偏移。如果所有Key同一时刻过期,Redis清理时会造成CPU峰值和缓存雪崩。
# 设置24小时过期,并加上随机10分钟偏移,避免同时过期 EXPIRE user:1001:actions 86400 EXPIRE user:1001:actions 86400 + RANDOM(0,600)实际应用中上面的“加随机偏移”需要在业务代码里实现,Redis的EXPIRE只接受固定秒数。
5.4 分布式锁用Set还是String?顺带讲清楚
相关热搜词里出现了“Redis分布式锁”,很多人会想到用Set的去重特性。但分布式锁更标准的做法是用String + SETNX + 过期时间:
SET lock:order:1001 "uuid_value" NX EX 30注意这里用的是String类型而不是Set。Set的SADD虽然也能实现“加锁”语义(成功返回1,失败返回0),但它天然缺少一个关键点:你不能给Set里的某一个元素单独设置过期时间。所以一般不用Set做分布式锁,除非锁对象本身就是“一群并发只能允许一个线程持有的某个ID”,这种边缘场景确实能用SADD + 业务防重实现。
6. 我在项目中沉淀下来的使用原则与排错路径
6.1 选择前的三连问与最终对照表
我在团队内部培训时,经常用下面这个表来做快速决策。它不是万能公式,但能覆盖80%的场景。
| 判断维度 | List | Set |
|---|---|---|
| 是否保证顺序 | 保证,插入顺序 | 不保证 |
| 是否允许重复 | 允许 | 不允许 |
| 两端操作 | 支持LPUSH/LPOP/RPUSH/RPOP | 不支持 |
| 阻塞读取 | 支持BLPOP/BRPOP | 不支持 |
| 集合运算 | 不支持 | 支持SINTER/SUNION/SDIFF |
| 随机取元素 | 不支持 | 支持SRANDMEMBER/SPOP |
| 中间插入/删除 | 可以但O(n) | 支持但无序,无中间概念 |
| 典型业务 | 消息队列、时间线、日志 | 去重、标签、共同好友、抽奖 |
6.2 排查问题的一个标准流程
如果你的Redis里某个List或Set性能变差了,或者数据看起来不对,我建议按这个顺序排查:
- 先看Key是否存在,以及TCL大小:
OBJECT ENCODING key查看编码类型,判断数据布局。 - 再看元素规模和单元素大小:
LLEN或SCARD拿到总数,配合MEMORY USAGE key看内存。 - 检查是否有大命令调用:通过Redis的SLOWLOG查看慢查询日志,重点看有没有LRANGE 0 -1、SMEMBERS、SINTER这种可能全量操作的命令。
- 检查过期时间:
TTL key看剩余时间,如果返回-1说明没设置过期时间。 - 检查连接和客户端:如果阻塞发生在客户端,再看Redis的client list,确认是否有客户端把连接悬在BLPOP上长时间不释放。
这个链路是通用的,不只适用于List和Set,任何一个Redis Key的异常都可以用这个思路定位。
6.3 关于数据序列化的一个选型建议
如果你在Spring Boot项目里用RedisTemplate操作List和Set,我强烈建议你统一定义一个配置类。把每个RedisTemplate的序列化器都显式指定好,不要在多个地方各写各的。我见过一个项目里光RedisTemplate就创建了十多个Bean,Value序列化方式有的用JDK,有的用Jackson,还有的直接存String。结果就是同一个Key在A服务写入是JSON,在B服务读出来反序列化直接报错。
统一配置之后,还要给团队提个醒:Redis存的是给别人用的数据,不是给你自己用的。写入方要想着读取方能不能解析,读取方要想着写入方当时存的时候用的什么格式。两个服务一起查问题的时候,先统一序列化方式再往下去查业务逻辑,效率高很多。
6.4 测试环境里经常忽略的“Key前缀污染”
最后分享一个很不起眼但很恶心的坑:测试环境里大家共用一套Redis,如果Key没有任何前缀或命名空间隔离,你往List里LPUSH的数据,可能被另一个同事的清理脚本当成垃圾数据直接删掉,更麻烦的是他的脚本也往同一个Key里写数据,导致你的列表里混入一堆莫名其妙的东西。
建议所有Key都加上业务前缀和维度信息,比如biz:module:key规范。List也好Set也好,命名规范一定要在项目启动前定好,否则后期靠“猜测+日志”来复盘数据是谁写的,成本非常高。Redis没有数据库表结构约束,你的规范就是唯一的约束。
说到底,List和Set只是Redis五大数据类型里的两块拼图。把这两块的边界和交叉场景吃透,最直接的好处是你在设计数据模型时会有意识地判断:这里该排队还是该去重?该有序还是该无序?该阻塞还是该立即返回?想清楚了再用命令,命令自然会背得牢。我在实际项目中反复验证过一句话:Redis用得好不好,不取决于你知道多少命令,而取决于你在数据落库之前做对了多少选择。后面有机会我再单独写一篇关于ZSET的实践,它把有序和去重融合在一起之后,应对范围查询和排行榜又是另外一套逻辑了。