ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Redis List与Set选型实战:底层原理、命令对比与应用场景

2026/9/9 17:04:14 拓冰建站 浏览量
Redis List与Set选型实战:底层原理、命令对比与应用场景 我需要先说明一个情况这篇文章的价值不在命令行背诵而在“为什么同样一个需求有人用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、quicklistRedis的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与hashtableSet的内部编码有两种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只能计数一次这里用SetSADD 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主线程都卡顿几百毫秒连带影响其他请求。解决方案有几个改用SSCANList没有对应的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%的场景。判断维度ListSet是否保证顺序保证插入顺序不保证是否允许重复允许不允许两端操作支持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就创建了十多个BeanValue序列化方式有的用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的实践它把有序和去重融合在一起之后应对范围查询和排行榜又是另外一套逻辑了。