☰
Redis消息队列全攻略:List、Pub/Sub、Stream与延迟队列实现
2026/10/14 6:23:05 网站建设 项目流程

搞后端的人应该都有过这种经历:项目里有个异步任务或者削峰需求,正儿八经去部署一套专门的消息队列中间件,又觉得为了这点功能搭个重家伙有点小题大做,运维成本、学习成本全算上,实在是划不来。这时候,身边那台一直在跑缓存服务的Redis,往往就是最顺手的那把螺丝刀。

Redis实现消息队列不是一个新话题,但很多人对它的认知还停留在“用List推拉消息”这一步。其实从List到Pub/Sub,再到Stream,Redis在这件事上的能力边界比大多数人想象的要宽得多。这篇文章我想把四种常见的Redis消息队列实现方案从头到尾拆一遍,包括它们的适用场景、核心命令、代码写法,还有我实际踩过的坑。无论你是刚接触Redis不久,还是已经在生产环境跑了好几年,这篇文章应该都能给你一点参考。

1. 为什么是Redis:消息队列的本质与选型逻辑

1.1 消息队列到底解决了什么问题

在聊具体实现之前,先捋清楚消息队列的本质。消息队列的核心价值就三个字:解耦。生产者把消息丢进队列,不需要关心消费者是谁、消费者处理得怎么样、消费者是不是挂了;消费者从队列里拿消息,也不需要关心消息是谁产生的、什么时候产生的。生产者不用等消费者,消费者也不用等生产者,两者在时间维度和空间维度上彻底松绑。

举个例子。你在做一个订单系统,用户下单后需要做三件事:扣库存、发短信通知、给积分系统加积分。如果这三件事全部同步执行,用户点击“提交订单”按钮之后,可能要等上好几秒才能看到成功页面。而且这三件事里面只要有一个服务进程挂掉,整个下单流程就全崩了。引入消息队列之后,下单请求只需要把“订单已创建”这条消息丢进队列,马上就返回成功。下游的库存、短信、积分服务各自异步去消费这条消息,互不干扰。

这就是消息队列的价值。而Redis能承担这个角色的根本原因,是它的几种数据结构恰好能覆盖消息队列的基本形态:List可以当先进先出的任务队列,Pub/Sub可以实现一对多的实时广播,ZSet可以用score做延迟调度,Stream更是从数据结构层面直接支持了消费者组和消息确认。而且Redis是内存操作,单线程模型下性能极高,读写延迟通常在微秒到毫秒级别,这就让它在轻量级消息场景下有了很强的竞争力。

1.2 什么时候选Redis,什么时候选专业MQ

我见过不少团队在选型的时候走极端。一种是所有消息一律上重型消息队列,搞得运维累死;另一种是无论什么场景都用Redis硬扛,结果数据丢了又一脸懵。这两种做法都不对,选型的关键在于对消息可靠性要求有多高。

Redis实现消息队列的天然短板是持久化和ack机制相对薄弱。虽然Redis有RDB和AOF持久化,但那基于整个数据集层面的快照或日志恢复,和消息队列逐条确认、逐条重放的能力不是一回事。如果你的业务场景能接受极端情况下丢几十条消息(比如非核心的统计日志、实时通知类数据),Redis完全够用;但如果是订单支付结果、资金流水这类一条都不能丢的数据,那就必须老老实实上专业MQ。

我个人的经验是几个判断标准:

  • 需要消息回溯或精确一次投递语义的,选专业MQ。
  • 需要复杂路由规则(比如根据消息内容分发到不同队列)的,选专业MQ。
  • 消费端需要动态扩缩容、且消息量有指数级增长可能性的,选专业MQ。
  • 消息量每天在几百万条以内、业务方对可靠性要求不是100%、团队不想引入额外运维组件的,Redis完全够用。

我做过一个日单量十几万的电商活动系统,异步通知、缓存刷新、积分发放全走Redis队列,跑了两年没出过大问题。Redis在这种情况下不是“将就”,而是恰到好处。

1.3 四种实现方案的横向对比

Redis实现消息队列有四条路,我把它们放在一起对比一下:

方案核心数据结构消息可靠性消费模型典型场景我给的推荐度
List队列List + BRPOP中低,依赖ack逻辑多消费者抢任务任务队列、异步处理高
Pub/SubPub/Sub频道低,消息不持久化多订阅者各收一份实时广播、通知推送中
Stream独立Stream类型高,支持消费组和ack消费组内分配生产级轻量消息场景高
ZSet延迟队列有序集合中,依赖轮询和确认单/多消费者轮询延迟任务、定时调度中

从这张表能看出来,没有哪个方案是绝对最优的,不同场景该选不同方案。接下来我一个个展开讲。

2. List方案:最经典的任务队列实现

2.1 核心命令与原理

List实现消息队列是Redis最早也是最常见的用法,核心就是两个方向的操作:生产者用LPUSH把消息从左边塞进去,消费者用BRPOP(或BLPOP)从右边阻塞弹出。一左一右,天然形成了一个先进先出的管道。

BRPOP最关键的地方是这个阻塞参数。打个比方,你去食堂排队打饭,如果窗口没饭了,你有两个选择:一是走人,隔一会儿再来看看有没有饭;二是就站在窗口前面等着,啥时候饭来了啥时候打。BRPOP就是那个站在窗口前干等的人——当队列为空时,BRPOP会阻塞连接,直到队列中有新消息或者超时。这个设计大大减少了消费者空轮询带来的CPU浪费和网络开销,这也是List方案最经典的地方。

实际命令长这样:

# 生产者:从队列左侧插入消息 LPUSH task_queue "task_001" # 消费者:从队列右侧阻塞弹出,timeout单位为秒 BRPOP task_queue 0

注意BRPOP的第二个参数是超时时间,0表示无限期阻塞。这是个很实用的特性,但也暗藏风险——后面讲坑的时候细说。

2.2 消费者代码实现

拿Python的redis-py库举例,一个简单的消费者循环长这样:

import redis r = redis.Redis(host='localhost', port=6379) def process_message(msg): # 实际处理业务 print(f"processing: {msg}") time.sleep(1) # 模拟耗时任务 while True: try: result = r.brpop('task_queue', timeout=30) if result: queue_name, msg = result process_message(msg) except redis.exceptions.ConnectionError as e: print(f"connection error: {e}") time.sleep(3) # 重连等待

这里有几个细节需要特别注意。首先,BRPOP的返回值是(queue_name, msg),不是单纯的msg。因为BRPOP支持同时监听多个队列,返回时要告诉你这个消息是从哪个队列里弹出来的。其次,while True的循环体里面必须加异常捕获——Redis连接断开了、命令超时了、消息格式异常了,任何一类异常处理不当,这个消费者进程就直接挂了,而且你很难第一时间发现。

2.3 可靠性问题与手动ack机制

List方案最大的坑在于消息确认机制。用LPUSH + BRPOP消费消息时,消息一旦被BRPOP弹出去,就从队列里彻底消失了。如果消费者拿到了消息、处理到一半进程崩溃了怎么办?这条消息就永远丢失了。

解决思路是用“备份队列”手动做ack。具体做法是:消费者先用BRPOPLPUSH(或者新版本的LMOVE)把消息从主队列弹出来,同时塞进一个处理中队列。处理成功后,再从处理中队列里LREM删掉这条消息。如果消费者崩溃了,处理中队列里会残留消息,起一个定时任务扫描处理中队列把超时的消息重新塞回主队列。

# 原子操作:从task_queue取出消息并放入processing_queue BRPOPLPUSH task_queue processing_queue 0 # 处理成功后从processing_queue删除 LREM processing_queue 1 "task_001"

但说实话,这种手动ack方案写起来比较繁琐,而且BRPOPLPUSH本身也是原子操作的一个限制——它只支持一个目标队列。Redis 6.2提供了LMOVE命令,可以指定left/right方向,灵活度更高一些。不过在真正需要这种可靠性保障的场景,我建议直接上Stream方案,Stream把ack机制做成了内置功能,省心得多。

3. Pub/Sub方案:实时广播的正确打开方式

3.1 Pub/Sub的消息分发模型

Pub/Sub和List的逻辑完全不同。List是消息“抢”着消费,一条消息只会被一个消费者拿走;Pub/Sub则是消息“广播”,一个消息发布到频道之后,所有订阅了这个频道的客户端都会各收到一份。这个差异决定了两者根本不在一个赛道上。

Redis的Pub/Sub实现非常轻量,核心数据结构就是一个频道名加一堆订阅者。发布者用PUBLISH发布消息,订阅者用SUBSCRIBE订阅频道,Redis维护着频道和订阅者之间的映射关系,消息来了直接转发给所有订阅者。

工作流程大概是这样的:

  1. 消费者A执行SUBSCRIBE order_channel,和Redis建立了一个订阅关系。
  2. 消费者B也执行SUBSCRIBE order_channel,加入同一个频道。
  3. 生产者执行PUBLISH order_channel "new_order_123"。
  4. Redis把这条消息复制两份,分别推给A和B。

这个模型非常适合实时通知类的场景。比如你在做直播弹幕功能,一条弹幕要同时出现在所有观众的页面上,用Pub/Sub天然合适,因为一条消息本来就应该发给所有人。再比如配置中心做配置变更通知,配置变了,所有服务节点都要收到消息并刷新本地缓存,Pub/Sub也是一把好手。

3.2 生产者与消费者的代码实现

用Python演示一下:

import redis import threading r = redis.Redis(host='localhost', port=6379) # 订阅者 def subscriber(): pubsub = r.pubsub() pubsub.subscribe('live_chat') for message in pubsub.listen(): if message['type'] == 'message': print(f"received: {message['data']}") # 发布者 def publisher(): for i in range(10): r.publish('live_chat', f"msg_{i}") threading.Thread(target=subscriber, daemon=True).start() time.sleep(1) publisher()

这里有一个很关键的点:pubsub.listen()返回的是一个生成器,它会持续阻塞接收消息。而且订阅者在执行SUBSCRIBE之后,收到的第一条消息是一个subscribe类型的订阅确认,不是业务消息,所以代码里要判断message['type'] == 'message'。这是我见过很多新手写Pub/Sub代码最容易忽略的地方。

3.3 Pub/Sub的致命缺陷:消息不持久化

把Pub/Sub用在关键业务上之前,必须先认清它的一个致命缺陷:Redis Pub/Sub不持久化任何消息。

什么意思呢?如果生产者PUBLISH一条消息的时候,某个消费者不在线,或者网络断开了,这条消息对于这个消费者来说就是永久丢失。Redis不会把消息存下来等它上线再补发。这和专业的MQ(比如Kafka、RocketMQ)有本质区别。

举个例子。某个消费者进程正在重启,重启花了两秒钟,刚好这两秒内生产者发布了一条订单创建成功的消息。消费者重启完成之后,它永远收不到那条消息了。订单创建这件事就无声无息地丢了。

所以我对Pub/Sub的定位是:只能用在“丢了也无所谓”的实时通知场景。比如你在微信群里发消息,谁没在线他就看不到了,不会有人因为这个消息丢了出去的事来找你——但如果是支付结果通知这种丢了就出事的类型,Pub/Sub绝对不能碰。

4. Stream方案:Redis 5.0带来的完整消息队列能力

4.1 Stream解决了什么核心问题

Redis 5.0引入的Stream类型,可以看作是官方对“用Redis做消息队列”这件事的正名。Stream从数据结构层面解决了两大痛点:消息持久化与消费者组。

Stream本质上是一个只能追加写的日志结构。消息不是简单的一个字符串,而是由field-value键值对组成的Map,类似一个小型的JSON对象。每条消息写入时会分配一个全局唯一的消息ID,这个ID是单调递增的,消费者可以根据ID来追踪处理进度。

消费者组是Stream最核心的能力,它允许一个Stream可以被多个消费者分组消费,每个组内部的消息分配给组内的消费者去处理。同一组内的消费者是竞争关系,一条消息只给组内一个消费者;不同组之间是互相独立的,每个组都会收到这条消息。这就类比你看了某个up主的视频,你关注了他的频道可以看一遍,你同事也关注了他也可以看一遍,同一份视频发给了两个频道各自接收,互不影响。

4.2 核心命令逐条拆解

Stream的核心命令比较多,我拆成几组来讲。

生产端:

# 写入消息,*表示让Redis自动生成消息ID XADD order_stream * user_id "123" product_id "456" amount "99.9"

消费端基础命令:

# 从Stream尾部读取消息,block超时时间为毫秒 # > 表示只返回从未被本消费者组消费过的消息 XREADGROUP GROUP order_group consumer_1 COUNT 1 BLOCK 5000 STREAMS order_stream >

注意这里有两个参数容易搞混:GROUP order_group consumer_1指定消费者组名和消费者名,>是特殊ID,意思是“给我这个组里还没处理过的消息”。如果你不传>而是传一个具体的消息ID,那就是回放历史消息,这是做消息回溯的基础。

消息确认:

# 确认消息处理完毕 XACK order_stream order_group 1678888800000-0

等待确认列表查询:

# 查看组内所有未确认的消息 XPENDING order_stream order_group # 查看组内某个消费者持有的未确认消息 XPENDING order_stream order_group - + 10 consumer_1

这组命令的逻辑是:消费者读消息 → 处理消息 → XACK确认。没有XACK的消息会一直留在pending列表里。如果消费者处理到一半崩了,这条消息就会一直挂在pending里,另一个消费者可以通过XCLAIM命令把它转移给自己来处理。这套机制和Kafka的offset提交思路殊途同归,但实现上Redis更精简。

4.3 消费者组的完整代码实现

下面是一个比较完整的消费者组代码,包含了正常的读取、确认和异常处理:

import redis import time import json r = redis.Redis(host='localhost', port=6379) STREAM_KEY = 'order_stream' GROUP_NAME = 'order_group' CONSUMER_NAME = 'worker_1' # 初始化消费者组,不会覆盖已有组 try: r.xgroup_create(STREAM_KEY, GROUP_NAME, id='0', mkstream=True) except redis.exceptions.ResponseError as e: if 'BUSYGROUP' not in str(e): raise while True: try: # 阻塞读取消息,最多阻塞5秒 result = r.xreadgroup(GROUP_NAME, CONSUMER_NAME, {STREAM_KEY: '>'}, count=10, block=5000) if not result: continue for stream_name, messages in result: for msg_id, msg_data in messages: process_order(msg_data) # 处理成功后立即确认 r.xack(STREAM_KEY, GROUP_NAME, msg_id) except redis.exceptions.ConnectionError: time.sleep(3) continue except Exception as e: print(f"unexpected error: {e}") time.sleep(1)

这段代码有几点值得说。第一,xgroup_create之后不用每次都判断组是否存在,用BUSYGROUP异常来兜底就行。第二,count=10和block=5000的组合可以避免单次循环处理过多消息导致其他消息饥饿,也可以让阻塞时间不会太长,进程退出时不会等太久。第三,XACK的位置必须紧跟在消息处理成功之后。如果你把XACK放在循环外面,一旦处理中间某条消息抛了异常,前面已经处理成功的消息会被重复消费。

4.4 Stream的可靠性为什么比List强

Stream的可靠性来源于几个机制叠加:

第一是消息ID的持久化。每条消息都有唯一的ID,消费者用>读新消息,用具体ID读历史消息,做消息回溯非常方便。这在List方案里是不可想象的。

第二是pending队列。XACK本质上就是消息队列里的“手动确认”,未确认的消息留在pending队列里,相当于给消息上了个“保险”。即使消费者崩了,消息也不会丢。我在一个数据同步系统里就是利用XPENDING做消息监控——每隔一段时间查一下pending里有积压多少条消息,积压超过阈值就告警,非常直观。

第三是XCLAIM的故障转移能力。如果某个消费者挂了,组里其他消费者可以接管它的pending消息。命令执行后,Redis会把指定ID的消息转移到新消费者的名下,然后继续处理。

从可靠性维度看,Stream已经非常接近专业消息队列了。Redis官方甚至专门给Stream做了持久化、主从复制、集群分片这全套的配套能力,可以说Stream就是Redis对“消息队列”这个需求给出的终极答案。

5. ZSet延迟队列:用score实现精密的时间调度

5.1 延迟队列的适用场景

做过后台任务的人应该都遇过这种需求:订单支付超时30分钟自动关闭、用户注册后48小时发一条召回短信、直播开播前5分钟给所有预约用户发提醒。这种“到了某个时间点才执行”的任务,就是延迟任务。用定时任务去扫全量数据表也行,但数据量大时效率奇差,而且时间精度很难控制。用ZSet做延迟队列是我个人非常推崇的一个方案。

原理其实很简单:ZSet的score天然适合存时间戳。把延迟任务的消息内容作为member,把任务的执行时间戳作为score。一个负责轮询的程序每隔一段时间去查一下,看看score最小的那个任务的时间戳是不是已经小于当前时间,如果是,就说明该执行了。执行完用ZREM删掉,然后再跳出下一个该执行的。

5.2 代码实现:延迟队列的完整流程

这里的核心逻辑是轮询、取最小的score、比较当前时间、执行、删除,代码写出来非常清晰:

r.zadd('delay_queue', {'task_001': int(time.time()) + 3600}) # 1小时后执行 r.zadd('delay_queue', {'task_002': int(time.time()) + 1800}) # 30分钟后执行 while True: tasks = r.zrangebyscore('delay_queue', 0, time.time(), start=0, num=1) if not tasks: time.sleep(1) continue task_id = tasks[0] # 任务执行前先ZREM,防止重复执行 if r.zrem('delay_queue', task_id): execute(task_id) # 任务已从队列移除,可以安全执行

这里的重点在于先ZREM再执行。为什么不是先执行再ZREM?因为如果先执行、执行到一半进程崩了,任务还在队列里,下次扫描的时候就会重复执行。先ZREM再执行,保证了即使执行过程中进程崩溃,顶多丢一次执行机会,不会产生重复执行的副作用。这和我们写消息队列消费逻辑时“先XACK再处理”还是“先处理再XACK”的取舍是同一个道理,要根据业务对“丢”和“重复”哪个更敏感来做权衡。

5.3 ZSet延迟队列的注意点

ZSet方案有些细节需要提前想明白。

第一是时间精度问题。ZSet的score是浮点数,存毫秒级时间戳完全没问题。但轮询的频率决定了任务的延迟误差,如果轮询间隔是5秒,理论上任务可能比预期时间最多晚5秒执行。实时性要求特别高的场景不适合这种方案。

第二是单消费者的性能瓶颈。ZSet方案一般是一个while循环在跑,如果任务量特别大,这个循环就会成为瓶颈。解决思路是把这个单循环看作一个“调度器”,它不真正执行任务,只负责把到期的任务取出来丢进List或Stream队列,由后端的worker组真正去消费执行。这实际上是一个两级的解耦结构:调度层和业务执行层分离。

第三是定时轮询的浪费问题。while循环里time.sleep(1)理论上每秒查一次ZSet,看起来有点浪费,但实际上ZRangeByScore的时间复杂度是O(logN+M),性能开销微乎其微。对于大多数延迟任务场景,ZSet方案在性能上完全不是问题。

6. 实战经验:核心注意事项与问题排查

6.1 消息重复消费怎么兜底

先泼一盆冷水:在异步消息场景下,重复消费几乎是不可避免的。网络抖动了、消费者处理超时被踢出了、XACK因为连接中断没发出去,各种小概率事件叠加在一起,重复消费的概率看起来不高,但在长时间运行的系统里,这些事件迟早会发生。

所以正确的姿势是:不要试图在消息队列层面完全杜绝重复消费,而是在业务层面做幂等。最简单的幂等方案是给每条消息生成一个唯一业务ID,消费者在落库前先查一下这个ID是否已经处理过。如果你用了Stream方案,消息ID本身就是一个天然的唯一标识,可以把它作为业务表的唯一键来去重。

我的经验是,所有用Redis做消息队列的业务,消费端代码里都应该有一行“按业务唯一ID查重”的逻辑。这行代码成本极低,但在关键时刻能救命。

6.2 连接管理与阻塞超时

用BRPOP做阻塞消费时,要特别小心Redis连接的超时设置。如果Redis服务端配置了timeout参数,空闲连接会被服务端主动断开。这意味着一个BRPOP阻塞中的连接,如果一直没消息,可能被Redis当成空闲连接关掉。消费者这边不会立刻感知到,直到下一次BRPOP超时才会重新连接。

遇到这个问题的标准解法是给BRPOP设置一个合理的超时时间(比如60秒或120秒),超时后主动重连,而不是用0做无限期阻塞。虽然0语义上是“永不超时”,但在实际生产中,网络是不可靠的,连接迟早会断。主动管理连接状态,远比被动等连接断掉再处理要好。

6.3 大消息与内存控制

Redis是内存型数据库,这个消息队列的容量受制于内存大小。如果业务消息体比较大,比如一个消息就几十KB,那Redis存不了多少条就内存告急了。我见过有人把整个业务对象序列化之后丢进Redis,一条消息大到上百KB,结果消息队列直接成了Redis的内存杀手。

对策有两个方向。第一是消息瘦身,只把必要的业务ID、关键参数丢进队列,完整的数据让消费者按ID从数据库查。第二是给消息队列设置上限,比如用LTRIM控制List的最大长度:

LPUSH task_queue "msg" LTRIM task_queue 0 9999

这样每条消息入队后立即把超过上限的旧消息截掉。注意这个方案适合那些允许丢最旧消息的场景,不能用在核心业务上。

6.4 监控:没有监控的消息队列等于裸奔

用Redis做消息队列最大的风险在于“看起来很可靠,实际没人管”。我的习惯是给每个消息队列都配一套基础监控:

  • 队列长度:List用LLEN,Stream用XLEN,ZSet用ZCARD。设置一个阈值,超过就告警,说明消费速度跟不上了。
  • 消费延迟:拿当前时间戳减去队列里最新一条消息的时间戳,能直观看到消息从产生到被消费的时延。
  • pending堆积量:Stream场景下监控XPENDING的返回长度,pending持续增长意味着有消费者卡住了。

这些监控数据可以直接通过Prometheus的redis_exporter采集,也可以写个简单的定时任务把指标推到监控系统里。不用做得太复杂,先把“队列有没有堆积”“消费有没有延迟”这两个问题盯住,就已经能避免大部分消息队列事故了。

7. 最后分享一点我的实际经验

做了这么多年后端,我的体会是:Redis消息队列这个技术方案,上限比大多数人以为的要高,但下限也比大多数人以为的要低。用得好,它能用几台机器扛住千万级消息处理;用不好,一条消息丢失引发的故障可能会让你熬几个通宵去排查。

我个人目前的选型偏好是:需要持久化、需要消费者组、需要可靠确认的场景,直接用Stream方案,尽量不要用List手动实现一套复杂的ack机制。Stream的命令虽然多,但它把消息队列的核心语义一次性做齐了,省下来的心智成本和故障排查成本远远大于学习成本。延迟任务场景优先考虑ZSet方案,调度层和业务执行层分开设计,扩展性会好很多。而Pub/Sub我就只用在“丢了无所谓”的实时通知上,其余场景一概不碰。

最后再分享一个小技巧:无论你用哪种方案实现消息队列,一定要在消费端打全量的处理日志,包括消息ID、处理结果、耗时。消息队列的问题排查最难的点就是“这条消息到底处理了没有”,有了完整的日志链路,排查问题的速度会快上十倍。这个习惯我保持了多年,每次线上出问题都靠它救命。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询