业务开发里总有人问我:“列表数据用 Redis 怎么存?”比较常见的做法是直接把 List 集合整个塞进一个 String Key,或者拆成多个 String 再自己拼顺序。老实说,这两种我都干过,也都在某个时间点被坑过。后来我把 Redis 自身提供的 list 数据类型用熟了,才慢慢体会到 LPUSH、RPUSH、LRANGE 这一组命令到底该怎么玩,以及在“存取 list 集合”这个看似简单的需求背后,还有队列、分页、阻塞、序列化、大 Key 这些绕不开的问题。
这篇文章我会从 Redis 的 List 类型出发,把环境准备、核心命令、实战场景、踩坑记录和面试扩展点一次性讲透。适合已经会用 Redis 基础命令、但想更系统地理解 list 数据类型,或者想用 list 做消息队列、分页列表和缓存治理的小伙伴。看完之后,你至少能回答这些问题:list 和 set 有什么区别?LPUSH 和 RPUSH 存进去的顺序到底怎么回事?LRANGE 为什么越界了也不报错?以及,为什么有人用 list 做消息队列,有人却拼命劝你别用。
1. 从需求出发:List 到底在解决什么问题
很多人刚学 Redis 数据类型时,背过一句话:String 是字符串,Hash 是字典,List 是链表。然后呢?然后就没有然后了。直到真正写业务代码时才发现,链表这个抽象跟你想要的“存一个集合”并不完全是一回事,甚至经常会用错。
1.1 List 能做什么,不能做什么
Redis 的 List 是一个有序的字符串集合,可以往左往右两边插入,也能从两边弹出。它的典型场景有三个:
- 需要按时间顺序排列的数据列表,比如用户操作日志、通知消息、最近浏览记录;
- 轻量级消息队列,生产者 LPUSH,消费者 RPOP,或者反过来;
- 需要分页展示的列表数据,比如商品列表、文章列表。
但 List 也有明确做不到的事情:它不能像 Set 那样自动去重,不能像 ZSet 那样按分数排序,也不能像 Hash 那样按字段存取。如果你需要一个不重复的集合,或者需要跳过重复元素,List 不是合适选择。这一点想清楚了,后面才不会踩“为什么列表里出现重复数据”这种基础坑。
1.2 底层结构:quicklist 是怎么影响存取的
在 Redis 3.2 之前,List 底层有两种结构:元素少且长度短时用 ziplist(压缩列表),元素多时用 linkedlist(双向链表)。ziplist 省内存但读写复杂度高,linkedlist 对两端操作是 O(1) 但内存占用大。后来 Redis 3.2 引入了 quicklist,本质是“双向链表 + 多个 ziplist 节点”的组合体。
为什么要知道这个?因为底层结构直接决定了几个存取习惯:
第一,List 对两端操作是 O(1),但对中间元素操作是 O(N)。你用 LINSERT 往中间塞数据,或者 LRANGE 取一个很大 offset 的区间,性能都不会太好。
第二,quicklist 每个节点内部还是一个压缩列表,节点太长会影响读写速度,太小又浪费指针内存。所以 Redis 有 list-max-listpack-size 这样的配置(7.0 之后改名为 listpack 相关配置),你可以根据实际元素大小去调整,后面踩坑章节会再提。
第三,List 存的对象如果是字符串,长度越长,quicklist 节点的压缩收益越小,内存碎片风险也越高。所以我在实战中建议,不要往 List 里塞太大的 JSON 串,一条元素动辄几 KB,内存和序列化开销都会上来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的准备:Redis 安装、连接与可视化工具选型
既然要聊“存取 list 集合”,总不能只看文档不做实验。先把手边的环境搭起来。这一部分我分三步走:安装 Redis、选择合适的客户端工具、把连接方式和序列化策略定下来。
2.1 快速安装 Redis:Windows、Linux 与容器
Windows 用户以前比较痛苦,官方一直不提供 Windows 版本,只能去用微软移植版或者第三方的 Redis。现在常用做法有两种:
- 如果你只是本地测试,可以直接去下载 Redis 官方 Windows 移植版,比如
redis-windows这个 GitHub 项目提供的 zip 包,解压后执行redis-server.exe就能跑起来。 - 如果你在 Linux 服务器上,可以用包管理器安装:Ubuntu 下执行
sudo apt install redis-server,CentOS 下用sudo yum install redis,然后systemctl start redis。
容器环境下更简单,一条命令就能起一个单机实例:
bash复制docker run --name redis -p 6379:6379 -d redis:7.2
如果你需要模拟主从架构来测试读写分离,可以用 docker-compose 起一主一从。我本地常用一个简化版配置,主节点开 6379,从节点开 6380,从节点配置里加 replicaof 127.0.0.1 6379。这样测试的时候可以看到数据从主节点复制到从节点,List 的写操作在主节点,读操作可以分流到从节点。
2.2 可视化客户端:Redis Desktop Manager 与 Another Redis Desktop Manager
命令行用多了还是想看数据长什么样,尤其是 List 这种结构化的数据。我最早用的是 Redis Desktop Manager(RDM),老版本确实好用,界面清爽,能直接看到 key 的 type 是 list,点开之后还能按页浏览 List 里的元素。后来 RDM 收费了,社区里开始大量推荐 Another Redis Desktop Manager(ARDM),开源免费,功能上覆盖了 RDM 大部分操作,支持列表、Hash、ZSet 的可视化编辑,还有简单的命令行面板。
我的建议是:
- 只是简单看数据,用 ARDM 就够了;
- 要分析线上大 Key、慢日志,建议还是用命令行或者更专业的工具,比如
redis-cli --bigkeys。 - 连接配置上注意主机地址、端口、密码和数据库编号,Redis 默认有 16 个 db,List 存在哪个 db 里要清楚,别到时候切换了库找不到 key。
2.3 客户端连接与序列化策略:这是存取 List 的第一个大坑
Java 里用 Spring Data Redis 或者 Redisson,Python 里用 redis-py,都会有序列化问题。很多人存 List 时发现,存进去是一个对象,读出来变成一堆乱码,或者类型转换直接报错。核心原因是:Redis 本身只存字节数组,你存进去的对象必须序列化成字节,读出来也要反序列化回对象。
我在 Java 项目里一般会这么做:
- RedisTemplate 的 key 序列化器用 StringRedisSerializer;
- value 序列化器用 GenericJackson2JsonRedisSerializer;
- 存 List 时,整个 List 对象由一个序列化器统一处理。
Python 端相对简单,redis-py 的 lpush 方法接受字符串或者字节,如果你传 int、float、对象,需要自己转成字符串,或者用 json.dumps 序列化后再存。很多新手在 Python 里直接执行 r.lpush("mylist", {"name": "tom"}),结果 Redis 报错,就是因为 redis-py 默认不会自动帮你做字典序列化。
3. 存取命令详解:从入队到出队的完整操作
环境准备好了,现在开始进入最核心的部分:命令。List 的命令看起来多,其实围绕一个思路展开:往左写、往右写、从两边读、按范围读、阻塞读。
3.1 写入命令:LPUSH、RPUSH、LINSERT、LSET
先用一条命令同时向列表左侧插入多个元素:
bash复制LPUSH mylist a b c
这条命令执行完,mylist 里的顺序是 c b a。原因是 LPUSH 把每个元素都插到列表头部,后插入的排前面。所以如果你想要经典队列的“先进先出”,应该左侧进、右侧出,或者右侧进、左侧出。很多人第一步就搞混了这个顺序。
RPUSH 就是往尾部追加:
bash复制RPUSH mylist d e
此时列表顺序为 c b a d e。
LINSERT 允许你在某个基准元素前或后插入:
bash复制LINSERT mylist BEFORE a a0
LINSERT mylist AFTER e e0
这个命令在元素不存在时返回 -1,也就是说如果你基准元素拼错了,它不会报错,但也不会插入任何数据。所以使用前最好先用 LINDEX 或者 LRANGE 确认一下基准元素存在。
LSET 是按下标覆盖元素:
bash复制LSET mylist 0 new-value
如果你把下标写成负数,LSET 也能从尾部反向定位。比如 LSET mylist -1 last-value 可以覆盖最后一个元素。这个命令的优势是修改不改变 List 整体长度,适合做“更新列表里某个位置的元素”这种需求。
3.2 读取命令:LRANGE、LINDEX、LLEN、LPOP、RPOP
读取命令里,LRANGE 是使用频率最高的。它返回从 start 到 stop 的闭区间元素,而且两个参数都支持负数,-1 表示最后一个元素,-2 表示倒数第二个。
bash复制LRANGE mylist 0 -1
上面这条命令读取整个 List。注意这个命令的时间复杂度是 O(S+N),S 是偏移量,N 是返回元素个数。如果 List 很大但每次只想读前几页,不能偷懒用 LRANGE key 0 -1,否则 Redis 在快照或者 AOF 时会额外消耗内存和带宽。
LLEN 返回列表长度,复杂度 O(1),做分页时一般先拿 LLEN 再算总页数。
LPOP 和 RPOP 是从两端移除并返回元素。它们既能把 List 当作栈用,也能当队列用。比如:
bash复制LPOP mylist
RPOP mylist
从 Redis 6.2 开始,POP 命令还支持 count 参数,比如 LPOP mylist 3 一次弹出 3 个元素,这在批量消费场景里很实用。
3.3 阻塞读命令:BLPOP、BRPOP 与消息队列
阻塞读是 List 能当消息队列用的关键。BRPOP key timeout 表示:如果 key 为空,阻塞等待 timeout 秒;如果有元素,立即弹出最后一个元素;如果多个 key,从左到右检查。
bash复制BRPOP task_queue 5
当 timeout 设置为 0 时,表示永久阻塞。这里有一个细节:BRPOP 返回的是一个数组,第一个元素是 key 名称,第二个才是弹出的值。因为你可以传多个 key,所以客户端必须返回 key 名,否则你不知道是从哪个队列弹出的。
阻塞命令是消息队列的雏形,但它也有问题:如果消费者处理消息崩溃了,消息就丢了。标准的 BRPOPLPUSH 命令能在弹出后把值备份到另一个 List,实现可靠队列,Redis 6.2 之后推荐使用 BLMOVE 替代,功能更通用。后面实战章节会具体讲。
4. 实战场景:用 List 做分页、队列与缓存
命令会用只是第一步,真正能解决业务问题才是关键。我拿三个最常见的场景拆解一下:分页列表、轻量消息队列、缓存治理。这三个场景基本能覆盖“存取 list 集合”的大部分用途。
4.1 分页列表的存取设计
需求是这样的:有一个用户操作记录列表,数量可能增长到几十万条,需要按时间倒序分页展示。
我的方案是把新的操作记录用 LPUSH 写入一个名为 user:action:1001 的 List,元素内容是序列化后的 JSON 字符串。读取时用 LRANGE 分页:
bash复制LPUSH user:action:1001 "{'ts': 1700000001, 'action': 'login'}"
LPUSH user:action:1001 "{'ts': 1700000009, 'action': 'logout'}"
LRANGE user:action:1001 0 9
这段代码在 Redis 里只是两个字符串,但在 Java 里则要做对象序列化处理。我推荐存 JSON 字符串,不要直接存 Java 序列化对象,原因有两个:JSON 可读性强,方便排查问题;跨语言兼容性好,未来如果从 Java 切到 Go,不会因为 Java 序列化格式解不出来。
当页数特别深时,LRANGE 的 start 很大,比如第 10000 页 offset 是 199980,Redis 需要从头遍历链表。对百万级 List 来说,这个操作会明显耗时。优化方案是:不要让 List 无限增长。常见做法是每次 LPUSH 后,用 LTRIM 截断列表长度,只保留最近 1000 条:
bash复制LTRIM user:action:1001 0 999
LTRIM 会把 0 到 999 之外的元素全部删除,这样 List 永远不会超过 1000 条,分页查询最多只需要扫描 1000 个元素。这种“滑动窗口”思路在排行榜、通知列表等需求里很常见。
4.2 轻量消息队列:BRPOP + BLMOVE 的组合
很多人用 List 做消息队列时用的还是最原始的 BRPOP 消费,但这是有隐患的。假设我有两个消费者,都用 BRPOP 从队列里取消息。正常情况下,Redis 会按命令到达顺序把消息分配给消费者,这是可以实现简单的负载均衡的。但如果消费者在 BRPOP 返回后、还没处理完消息就崩溃了,这条消息就会永久丢失。
解决思路有两种:
一是消费者取到消息后先存一份到“处理中”列表,完成后再删除。比如:
bash复制BRPOPLPUSH task_queue processing_queue 5
# 处理完成后
LREM processing_queue 1 task_json
二是 Redis 6.2 之后使用 BLMOVE:
bash复制BLMOVE source_queue dest_queue LEFT RIGHT 5
表示从 source_queue 的左边弹出元素,推入 dest_queue 的右边,如果 source_queue 为空就阻塞等待 5 秒。这种方式比 BRPOPLPUSH 更直观,因为你可以明确指定从哪个方向弹出、往哪个方向推入。
但坦率说,如果你需要严格不丢失、需要死信队列、需要消息回溯,List 方案还是有点吃力。这种情况下我更推荐使用专业的消息队列中间件,比如 Kafka、RocketMQ,或者 Redis 官方推荐的 Stream 类型。Stream 比 List 更适合做消息队列,支持消费者组、ACK、Pending Entries List,这些特性是 List 不具备的。
4.3 List 在缓存治理中的位置
有一个热搜词叫“redis缓存治理”。List 在里面承担什么?
第一,可以缓存热点列表数据。比如首页商品列表,本来每次查询都要去数据库聚合排序,我可以在 Redis 里维护一个 List,商品上架或排序变化时更新这个 List,查询时直接 LRANGE 分页返回,减少数据库压力。更新策略要处理好:要么在写入时同步更新 List,要么用定时任务重建整个 List。
第二,可以记录缓存变化日志。比如把需要失效的 key 名称 LPUSH 到一个 cache:invalidate:queue,后台消费者用 BRPOP 取出这些 key 名,再执行批量删除。这种异步失效可以避免在请求线程里逐条删除大量缓存导致的延迟。
第三,用 List 做简单的请求限流滑动窗口,其实不太推荐,因为 List 元素数量大时内存占用高。限流更适合用 INCR + EXPIRE 或者专门的令牌桶实现。
我在项目里更常用的组合是:用 Hash 存对象字段,用 List 存有序 ID 集合。比如商品详情用 Hash 存,商品 ID 列表用 List 存,这样既能按 ID 顺序分页,又能批量取出 Hash 详情。这种“List + Hash”的组合拳,在缓存治理里非常实用。
5. 踩坑实录:存取 List 时我遇到的五个经典问题
前面讲了很多理想情况,现在来说说真实世界里踩过的坑。这些都是我实际项目中遇到并排查过的,希望你不用重蹈覆辙。
5.1 序列化不一致导致读不出数据
现象:Java 项目里用 StringRedisTemplate 存数据,存进去之后用 RedisDesktopManager 能看见字符串,但用另一种客户端读取时变成了一堆十六进制和乱码。
原因:Spring Data Redis 不同类型的 RedisTemplate,key 和 value 的序列化器不一样。如果你用 StringRedisTemplate 存了 {"name":"tom"},再用默认的 RedisTemplate 去读,RedisTemplate 会把 value 当作 JDK 序列化字节处理,读出来自然不对。
解决办法:全项目统一序列化策略。检查所有 RedisTemplate 的初始化代码,确保同一个 key 在读和写时使用同一个序列化器。更保险的做法是,在生产环境用客户端工具随机抽查几个 key,确认存储形态符合预期,而不是等到线上报错才排查。
5.2 LRANGE 索引边界理解错误
很多时候我们需要读取最后 10 条数据,但 LRANGE 的 stop 是闭区间,而且 start 和 stop 都可以是负数。我用过一个经典错误:
bash复制LRANGE mylist -10 -1
这个命令本意是取最后 10 条,但实际逻辑是“从倒数第 10 个元素开始,到倒数第 1 个元素结束”,确实没错。可是另一个错误是:
bash复制LRANGE mylist -1 -10
start 大于 stop,结果是空列表,不会报错。遇到这种情况,不仔细看以为是 Redis 出错了,实际是你把顺序写反了。
还有一个边界:当 start 超过 List 末尾时,LRANGE 返回空;当 start 是 -1000000 时,Redis 会把它当作 0 处理。所以分页时要自己做边界判断,不要依赖 Redis 给你错误提示。
5.3 大 Key 导致阻塞与内存膨胀
List 变成大 Key 后有多危险?我遇到过这样一个场景:运营人员在后台看到一个列表模块渲染很慢,我们排查发现有一个 List key 里存了上百万条数据,每次 LRANGE 都会复制大量内存,加上这台实例还有多个大 key,导致 Redis 主线程阻塞,所有请求延迟飙升。
解决办法:用 redis-cli --bigkeys 找出大 key,然后用 LTRIM 分批裁剪。最粗暴的方式是:
bash复制LTRIM big_list 0 999
但注意这是删除除了前 1000 条之外的元素,如果该 key 还在被业务持续写入,LTRIM 操作也可能造成一瞬间的阻塞。更好的方案是新建一个 List,用 LRANGE 分页读旧数据,再 LPUSH 到新 List,写完之后原子地修改 key 名称,或者用 RENAME 切换。
5.4 阻塞命令超时设置不合理
BRPOP 的 timeout 参数,很多人直接传 0,表示永久阻塞。这在本地测试没问题,但线上如果消费者线程被永久阻塞,Redis 连接池会慢慢耗尽,新增请求找不到连接,整个服务就白屏了。
我的建议是:客户端连接超时和读取超时都要设置合理数值,BRPOP 的 timeout 不要设置为 0,而是根据业务要求设置为 5 秒或 10 秒。如果超时返回,再进入下一次循环。同时,要保证消费线程捕获超时异常,不能让异常把线程跑死。
5.5 并发写入导致的数据错乱
List 本身是线程安全的,每条命令都是原子操作。但如果你在业务逻辑里用了“先 LRANGE 读出来,再修改,再 LSET 写回去”这种模式,并发下就可能丢数据。两个线程同时读到同一个 List 片段,各自修改后写回,后写的覆盖先写的。
解决思路:把这种“读-改-写”逻辑放到 Lua 脚本里执行,Redis 的 Lua 脚本是原子执行的。比如你要从 List 尾部取出元素并把它移动到另一个 List,用一条 Lua 脚本封装,就不会出现中间状态被其他客户端读到的问题。
5.6 忘记设置过期时间
List 不像普通临时数据那样有天然的 TTL。我在早期项目里建了很多用户通知列表,写完忘了设置 EXPIRE,结果这些 key 越堆越多,内存一直涨。现在我的习惯是:凡是 List 类型的缓存 key,只要数据不是永久业务数据,都统一设置过期时间,比如 7 天或者 30 天。用命令:
bash复制EXPIRE user:action:1001 604800
或者创建时直接:
bash复制LPUSH user:action:1001 "data"
EXPIRE user:action:1001 604800
如果担心每次写入都会刷新过期时间,那就别在每次写入时调用 EXPIRE,而是在创建 key 后只设置一次。
6. 面试题与进阶:List 相关的常考点
写完实战和避坑,最后聊聊面试和扩展。Redis 的 List 是面试高频考点,但面试官真正考察的通常不是你会不会用 LPUSH,而是你到底理解不理解它的边界。
6.1 List 与 Set、ZSet 的区别
很多人被问到“List 和 Set 有什么区别”时,只回答“List 有序可重复,Set 无序不可重复”。这个答案对,但不够深入。
面试官更想听的是底层差异和场景差异:
- List 底层是 quicklist 和 listpack,支持两端 O(1) 操作,适合队列、栈、分页列表;
- Set 底层是哈希表和整数集合,支持 O(1) 判断成员是否存在,适合去重、标签、关系运算;
- ZSet 底层是跳表和哈希表,支持按分数排序、范围查询,适合排行榜、优先级队列。
如果问到你“限流怎么实现”,你可以回答:单纯用 List 做限流不如 INCR + EXPIRE,如果想要更复杂的滑动窗口,可以用 ZSet 的 ZREMRANGEBYSCORE 和 ZCARD。这里能看出你对数据类型的边界把握是否到位。
6.2 如何实现延迟队列
延迟队列也是 List 面试题变种。比如订单超时未支付,需要在 30 分钟后取消。如果用 List 做,一个经典方案是:生产者把消息体写到 List,但不立即让消费者读,而是用一个定时任务每隔一段时间扫描列表,把所有“到期时间 <= 当前时间”的元素取出来处理。
这个方案的问题很明显:扫描间隔决定了延迟精度,如果 List 很大,扫描开销也大。更常见的做法是用 ZSet,score 存到期时间戳,后台任务用 ZRANGEBYSCORE 取出到期的元素,再删掉。你可以在回答里对比这两个方案,并且说明:List 适合严格先进先出的队列,ZSet 适合按时间排序的延迟队列。
6.3 为什么分布式锁不用 List
热搜词里有“redis分布式锁”,偶尔有面试官会故意拿 List 问:能不能用 List 实现分布式锁?理论上是可以的,比如 LPUSH 一个锁标识,消费完 RPOP,但问题在于:这是一个不可靠的锁,因为没有过期时间、没有自动释放机制、没有重入性。如果持有锁的客户端崩溃,锁永远不会释放,其他客户端全部阻塞。
真正的 Redis 分布式锁用 String + SETNX + EXPIRE,或者使用 Redisson 的看门狗机制。所以你要能回答:List 的数据结构本身不是设计来做互斥控制的,用 String 的 SET NX EX 才是正路。
6.4 Redis 7 对 List 的优化
Redis 7.0 之后,List 底层有一部分默认从 ziplist 切换到了 listpack,主要是为了解决 ziplist 在极端情况下连锁更新(cascade update)导致的性能抖动。listpack 的每个节点不再保存前一个节点长度,从而避免了连锁更新问题。
作为使用人员,你不需要改代码,但知道这个变化能体现你在持续关注 Redis 版本演进。还有一个扩展点:LPOS 命令从 Redis 6.0.6 开始可用,用来查找元素在 List 中的位置,类似 LINDEX 的反向操作。如果你曾经为了判断元素是否在 List 里而遍历整个列表,用 LPOS 会更高效。
最后分享一个我自己沉淀下来的小技巧:存取 List 时,先在客户端封装一个统一的 ListCache 工具类,所有业务代码通过它读写,工具类内部统一处理序列化、唯一 key 前缀、默认过期时间。这样做的好处是,将来要做大 key 治理、加缓存监控、切换存储格式,只需要改这一个类,不会散落得到处都是。我做过的几个项目里,这个习惯帮我节省了大量排查时间,也希望对你有点帮助。
