两个方向,四种组合
List 是 Redis 里最容易让人”以为自己会了”的类型。打开文档一看:LPUSH、RPUSH、LPOP、RPOP,就四个命令嘛,左右推推。合上文档开始写代码,写到一半发现不太确定:“等等,LPUSH + RPOP 是队列还是栈来着?”
这个困惑的根源是——List 同时能做队列和栈,但方向选择不一样,容易搞混。
先把基础操作理清楚:
# 从左侧推入LPUSH mylist "a" "b" "c"# mylist: [c, b, a] ← 注意顺序!最后推的 "c" 在最左边
# 从右侧推入RPUSH mylist "x" "y"# mylist: [c, b, a, x, y]
# 从左侧弹出LPOP mylist # → "c"(最左边的)
# 从右侧弹出RPOP mylist # → "y"(最右边的)LPUSH 有个容易踩的小坑:它把最后一个参数放在最左。LPUSH mylist "a" "b" "c" 的结果是 [c, b, a],不是 [a, b, c]。想要保持顺序,用 RPUSH mylist "a" "b" "c"。
四个命令排列组合,搞出两种经典模式:
| 模式 | 推入 | 弹出 | 效果 |
|---|---|---|---|
| 队列 (FIFO) | LPUSH | RPOP | 先进先出,像排队 |
| 栈 (LIFO) | LPUSH | LPOP | 先进后出,像叠盘子 |
也可以反着来——RPUSH + LPOP 同样是队列。社区习惯用 LPUSH + RPOP,因为直觉上”从左边推进去,从右边流出来”,好记。
这个模型不复杂,看图就清楚
动画里上半部分是队列模式(LPUSH + RPOP):蓝色元素从左进、从右出,先进来的先出去。下半部分是栈模式(LPUSH + LPOP):绿色元素同侧进出,后进来的反而先出去。
看完这张图,以后再也不会搞混了。
阻塞弹出:List 最实用的功能
List 最妙的设计不是双向,是 阻塞弹出 —— BLPOP 和 BRPOP。
普通的 RPOP 在队列为空时返回 nil(空)。你只能写成死循环:
while True: task = redis.rpop("queue") if task: process(task) else: time.sleep(0.1) # 空转,又慢又浪费 CPU两个问题:一是 0.1 秒的延迟(消息来了要等最多 100ms 才被处理),二是 Redis 被无效请求轰炸。
BRPOP 解决的就是这个:
# 队列为空就挂起,有数据了立即返回task = redis.brpop("queue", timeout=5)if task: process(task[1])timeout=5 的意思是:如果 5 秒内没数据,返回 None。设为 0 就无限等。拿到数据后几乎零延迟。
一个细节值得注意: BRPOP 返回的是一个元组 (key, value),不是直接返回值。因为 BRPOP 可以同时监听多个队列:
# 优先消费 high 队列,没数据才看 low 队列BRPOP high_queue low_queue 5这在业务上很实用——比如 VIP 用户的邮件优先发送、付费任务优先处理。Redis 按参数顺序检查,哪个队列先有数据就返回哪个。
当消息队列用:够用但要知道边界
List + BRPOP 组合是一个轻量级消息队列。部署简单,不需要额外组件,适合很多场景。
生产者:
def produce(task_json): redis.lpush("task_queue", task_json)消费者:
def consume(): while True: try: result = redis.brpop("task_queue", timeout=5) if result is None: continue _, task_json = result process(task_json) except Exception as e: logger.error(f"处理失败: {e}") # 注意:消息已经丢了!但这段代码藏了一个致命问题——BRPOP 拿到消息后立即从 List 删除了。如果 process(task_json) 抛了异常或者进程崩溃,这条消息就没了。
这就是 List 作为消息队列的核心局限:没有 ACK 机制。消息一旦弹出就没有回头路。
补救:备份队列
LMOVE(Redis 6.2+)和它的阻塞版本 BLMOVE 可以在弹出消息的同时原子性地备份一份:
# 从 source 弹出,原子性地推入 backupBLMOVE task_queue backup_queue RIGHT LEFT 5消费者处理成功后,再从 backup 里删掉。如果消费者崩溃了,可以从 backup 队列里恢复未处理的消息。
但这毕竟是自己搭的轮子,生产环境对消息可靠性有要求的话,Redis 5.0+ 的 Stream 是官方推荐方案——自带 ACK、消费者组、消息回溯。下一篇介绍 Stream 的时候会细聊。
什么时候用 List 做队列,什么时候用 Stream
| 你的需求 | 推荐 |
|---|---|
| 消息丢了无所谓(比如通知类) | List 够用 |
| 单消费者、简单场景 | List 省事 |
| 消息不能丢、需要重试 | Stream |
| 需要多个消费者并行消费同一个队列 | Stream 消费者组 |
| 需要回溯历史消息 | Stream |
| 已经部署了 Kafka/RabbitMQ | 用现成的,别用 Redis |
List 内部怎么存的:Quicklist
前面聊 Hash 的时候说了 listpack 省内存。List 也用 listpack,但组织方式不一样。
List 底层是 quicklist——一个双向链表,每个节点里放一个 listpack。
head → [listpack: a, b, c, d] ↔ [listpack: e, f, g] ↔ [listpack: h, i] ← tail为什么不用一个巨大的 listpack?因为 List 经常在两端操作,如果全放在一个连续内存块里,头部插入要移动所有元素,O(n)。而 quicklist 中部插入只需要动一个节点。
为什么不用纯链表(每个节点一个元素)?每个链表节点有指针开销(prev/next),几十万个元素就是几十万对指针,内存浪费严重。quicklist 每个节点装多个元素,摊薄了指针成本。
中间不常用的节点还会被 LZF 压缩,首尾节点保持未压缩状态以保证两端操作的性能。
两个关键配置:
list-max-listpack-size -2 # 每个节点最大 8KB(-2 是默认)list-compress-depth 0 # 0=不压缩,1=首尾各 1 个不压缩一般不用改。但如果你用 List 做消息队列且消息量很大,把 list-compress-depth 设为 1 或 2 能省不少内存。
几个顺手的小技巧
固定长度的”最近 N 条”
LTRIM 可以保留 List 的指定范围,砍掉其余部分:
LPUSH recent_logs "log_entry_6"LTRIM recent_logs 0 4 # 只保留最新的 5 条配合 RPUSH 使用,天然是一个”最近 N 条记录”的存储结构——比如最近 100 条操作日志、最近 50 次登录记录。
分页查 List
LRANGE 支持范围查询,天然支持分页:
LRANGE messages 0 19 # 第 1 页(20 条)LRANGE messages 20 39 # 第 2 页但注意 LRANGE 是 O(n) 的——n 是你请求的范围大小,跟 List 总长度无关。所以翻页性能不会因为 List 变长而恶化。不过翻太深(比如 LRANGE messages 5000 5019)还是慢,考虑换数据结构。
原子转移:LMOVE
Redis 6.2 开始 LMOVE 可以原子性地把一个 List 的元素移到另一个:
LMOVE pending processing RIGHT LEFT# 从 pending 右边弹出,从 processing 左边推进去任务状态流转(待处理 → 处理中 → 已完成)用这个很干净,中间不会有”弹出成功但推入失败”的半吊子状态。
查长度
LLEN mylistO(1) 操作,Redis 在 quicklist 结构里维护了 count 字段,直接读。
总结
List 最厉害的就两点:阻塞弹出 和 双向操作。
阻塞弹出让它从”数据结构”跨到了”基础设施”——一个极简的消息队列,不需要额外组件。但消息可靠性、消费者组这些进阶需求,List 满足不了,要向上用 Stream。
双向操作让它同时胜任队列和栈,加上 LMOVE、LTRIM 这些周边命令,在日常开发里出现的频率比想象中高得多。
下一篇聊 Set——无序不重复、交集并集差集。共同好友、标签系统、随机抽奖,都藏在 Set 里。
这是 Redis 学习系列的第 4 篇。下一篇:Redis学习系列 | 共同好友原来这么算
部分信息可能已经过时





