消息队列八股文
什么是消息队列?
消息队列(Message Queue,MQ)是一种用于服务之间异步通信的中间件,生产者把消息发送到消息队列,消费者从消息队列中取消息并处理。生产者和消费者不需要直接相互调用,而是通过消息队列完成解耦
1 | Producer -> MQ -> Consumer |
消息队列主要解决三个问题:
解耦
例如订单系统创建订单后,需要通知库存、优惠券、积分、短信等系统。如果订单系统直接调用这些系统,那么每增加一个下游,订单系统都要改代码。
使用MQ后,订单系统只负责发送
order.created消息,下游系统自己订阅消息即可异步
有些操作不需要同步等待结果,例如发送短信、发送邮件、写操作日志。主流程只需要把消息投递到MQ,后续由消费者异步处理,可以降低接口响应时间
削峰填谷
流量高峰时请求瞬间打到后端,数据库可能扛不住。可以先把请求写入MQ,消费者按照自身处理能力慢慢消费
1
瞬时高并发 -> MQ堆积 -> 消费者匀速处理
缺点也很明显:
- 系统复杂度增加,需要考虑消息丢失、重复消费、消息积压、顺序性等问题
- 数据一致性更难保证,同步调用变成异步最终一致
- MQ本身也需要高可用和监控
RabbitMQ 和 Kafka 的区别
RabbitMQ是传统消息队列,基于AMQP协议,强调灵活路由、可靠投递和任务分发
Kafka更像分布式日志系统,消息会追加写入分区日志,强调高吞吐、可回放、流式处理
| 对比 | RabbitMQ | Kafka |
|---|---|---|
| 核心模型 | Exchange + Queue | Topic + Partition |
| 消息保存 | 消费后通常从队列删除 | 按时间或大小保留,可重复消费 |
| 吞吐量 | 中等偏高 | 很高 |
| 延迟 | 低延迟 | 吞吐优先,延迟也可以较低 |
| 路由能力 | 很强,支持多种Exchange | 主要按Topic和Partition |
| 顺序性 | 单队列内有序,但多消费者会破坏处理顺序 | 单Partition内有序 |
| 典型场景 | 业务异步、任务队列、延迟重试 | 日志采集、埋点、流式计算、大数据 |
选型:
- 业务系统解耦、异步任务、延迟重试、复杂路由,优先RabbitMQ
- 日志、埋点、行为数据、需要高吞吐和消息回放,优先Kafka
RabbitMQ 整体架构
RabbitMQ中几个核心概念:
- Producer:生产者,发送消息
- Consumer:消费者,消费消息
- Broker:RabbitMQ服务节点
- Exchange:交换机,接收生产者消息,并根据规则路由到队列
- Queue:队列,真正存储消息的地方
- Binding:绑定关系,把Exchange和Queue绑定起来
- RoutingKey:路由键,生产者发送消息时携带,用于决定消息进入哪个队列
RabbitMQ不是生产者直接把消息发给队列,而是先发给Exchange,再由Exchange根据Binding和RoutingKey路由到Queue。
1 | Producer |
Connection 和 Channel
Connection是客户端和RabbitMQ之间的一条TCP连接。
Channel是在Connection上的虚拟连接,一个Connection可以创建多个Channel。
为什么要设计Channel?
- TCP连接创建和销毁成本较高
- 多个线程 / 多个业务操作如果都新建TCP连接,会浪费资源
- Channel复用同一个TCP连接,可以降低开销
实际开发中通常是一个进程维护少量Connection,每个线程或每个业务操作使用独立Channel。Channel不是线程安全的,一般不要多个线程共用同一个Channel。
一条消息从发送到消费的流程
大致流程:
1 | Producer创建Connection和Channel |
如果消费者处理失败,可以发送Nack,消息可以重新入队,也可以进入死信队列。
Exchange 类型
Exchange决定消息如何路由到队列,常见类型有四种:
Direct Exchange
精确匹配RoutingKey。
只有消息的RoutingKey和BindingKey完全相等,消息才会进入对应队列。
1 | RoutingKey = order.pay |
适合明确类型的消息分发,例如订单支付、订单取消等。
Fanout Exchange
广播模式,不看RoutingKey。
只要队列绑定到该Exchange,消息就会被投递到所有绑定队列。
1 | Exchange |
适合广播通知,例如配置刷新、缓存失效通知。
Topic Exchange
通配符匹配RoutingKey,是实际项目中最常用的Exchange类型。
RoutingKey通常用.分隔,例如:
1 | order.created |
Topic支持两个通配符:
*:匹配一个单词#:匹配零个或多个单词
例如:
1 | order.* 可以匹配 order.created、order.cancel |
Topic比Direct灵活,比Fanout精确,所以项目里很常用。
Headers Exchange
根据消息Header属性匹配路由,不依赖RoutingKey。
实际项目中较少使用,了解即可。
Queue 和消费者分发
Queue是真正存储消息的地方,消费者从Queue中拉取或接收消息。
一个Queue可以绑定多个Consumer。
多个Consumer消费同一个Queue时,RabbitMQ会把消息分发给其中一个Consumer处理。也就是说:同一个Queue中的一条消息只会被一个Consumer消费,不会被多个Consumer重复消费。
1 | Queue |
这就是工作队列模式,适合多个消费者一起处理任务,提高消费能力。
如果希望一条消息被多个业务方都消费,不能让多个业务方消费同一个Queue,而是应该给每个业务方建独立Queue,然后都绑定到同一个Exchange。
1 | Exchange |
这样每个Queue都有一份消息,每个业务方都能消费到。
ACK确认机制
ACK是消费者确认机制,用于告诉RabbitMQ:这条消息已经被成功处理,可以删除了。
消费模式分为自动ACK和手动ACK:
- 自动ACK:RabbitMQ把消息发给消费者后就认为成功,立即删除消息
- 手动ACK:消费者处理完业务后,主动发送ACK,RabbitMQ收到ACK后才删除消息
生产环境更推荐手动ACK,因为自动ACK下如果消费者刚收到消息就宕机,消息已经被RabbitMQ删除,会造成消息丢失。
手动确认相关方法:
Ack:确认成功,删除消息Nack:拒绝消息,可以批量 / 单条,可以选择是否重新入队Reject:拒绝单条消息,可以选择是否重新入队
ACK应该在业务处理成功之后发送。
1 | 收到消息 |
如果先ACK再处理业务,业务处理中途宕机,RabbitMQ会认为消息已经完成,从而造成消息丢失。
RabbitMQ 如何保证消息不丢失?
消息丢失一般发生在三个阶段:
1 | 生产者 -> RabbitMQ -> 消费者 |
对应的解决方案:
生产者到RabbitMQ不丢
使用Publisher Confirm机制。
生产者开启Confirm后,消息发送到Broker并被处理后,RabbitMQ会返回确认。如果没有收到确认,生产者可以重试发送。
注意:
publish方法返回nil通常只代表消息写入客户端发送缓冲区成功,不代表消息一定到达Broker。要确认到达RabbitMQ,需要看Publisher Confirm。RabbitMQ自身不丢
开启持久化:
- Exchange durable
- Queue durable
- Message persistent
只有队列和消息都持久化,Broker重启后消息才有机会恢复。
RabbitMQ到消费者不丢
使用消费者手动ACK。
消费者处理成功后再ACK,处理失败或宕机时消息不会被删除,可以重新投递。
综上,可靠投递通常需要:
1 | Publisher Confirm + 持久化 + 手动ACK |
但是持久化不等于100%不丢消息。因为消息写入磁盘也可能存在刷盘间隔,极端情况下Broker宕机仍可能丢少量消息。对强可靠场景,还要结合高可用队列、生产者重试、业务补偿和对账。
RabbitMQ事务和Publisher Confirm
RabbitMQ也支持事务,生产者可以开启事务,发送消息后提交事务。
但事务是同步阻塞的,性能很差,吞吐量会明显下降,所以实际项目中很少使用。
Publisher Confirm是异步确认机制,性能更好,生产环境更常用。
消息投递语义
常见投递语义有三种:
At Most Once:最多一次
消息最多被消费一次,可能丢消息,但不会重复。
例如自动ACK,RabbitMQ发出消息后就删除,如果消费者宕机,消息就丢了。
At Least Once:至少一次
消息至少被消费一次,不容易丢消息,但可能重复。
RabbitMQ在可靠投递设计下,通常保证的是At Least Once。
Exactly Once:刚好一次
理论上消息只被消费一次且不丢,但分布式系统中很难真正做到。实际业务中通常通过
At Least Once + 幂等性实现接近Exactly Once的效果。
为什么会重复消费?
- 消费者业务处理成功,但ACK发送失败
- 消费者发送ACK前宕机
- 网络抖动导致RabbitMQ没有收到ACK
- 生产者没有收到Confirm,于是重试发送
所以只要使用MQ,就必须考虑消费幂等。
消费失败和重试
消费者处理失败时,常见选择有三种:
直接丢弃
对不重要消息可以记录日志后丢弃,但核心业务不推荐。
重新入队
消费者
Nack / Reject时设置requeue = true,消息会回到队列继续投递。但不能无限requeue,否则会形成死循环:
1
消费失败 -> 重新入队 -> 立刻再次消费 -> 再次失败
这样会打满消费者CPU,还会阻塞正常消息。
进入重试队列 / 死信队列
更常见的设计是失败后不立即重试,而是延迟一段时间再重试,并限制最大重试次数。
常见重试设计:
1 | 业务队列 |
重试次数可以放在消息Header中,例如x-retry-count,每次失败加一,超过阈值后投递到死信队列,等待人工排查或补偿。
死信队列
死信队列(DLQ)本质上也是一个普通队列,只是用来存放无法被正常消费的消息。
死信交换机(DLX)是Dead Letter Exchange,当消息变成死信后,会被投递到DLX,再由DLX路由到死信队列。
消息变成死信的常见情况:
- 消息被
Reject / Nack,并且requeue = false - 消息TTL过期
- 队列达到最大长度,无法继续堆积
大致流程:
1 | Normal Queue |
死信队列的作用:
- 保存异常消息,避免消息直接丢失
- 方便后续人工排查
- 可以配合TTL实现延迟队列和重试队列
延迟队列
延迟队列是指消息发送后,不会立即被消费者消费,而是在指定时间之后再被消费。
适用场景:
- 订单30分钟未支付自动取消
- 退款超时检查
- 定时通知
- 失败消息延迟重试
RabbitMQ实现延迟消息常见两种方式:
TTL + DLX
给消息或队列设置TTL,让消息先进入延迟队列,不绑定消费者。
消息在延迟队列中过期后变成死信,被DLX转发到真正的业务队列,再由消费者消费。
1 | Producer |
缺点:
- 如果使用队列级TTL,一个队列只能表示一种延迟时间
- 如果使用消息级TTL,可能存在队头阻塞。例如第一条消息TTL很长,后面的短TTL消息即使过期也要等前面的消息先出队
延迟插件
RabbitMQ可以使用x-delayed-message插件实现延迟消息。
生产者发送消息时设置延迟时间,消息缓存在延迟交换机中,插件到期后再把消息路由到目标队列。
这种方式更直接,但依赖插件,需要考虑运维部署。
幂等性
幂等性是指同一个操作执行一次和执行多次,最终结果一致。
例如订单支付成功消息重复投递两次,不能给用户加两次积分,也不能扣两次库存。
唯一索引实现幂等
以点赞业务为例,可以给(点赞人id,被赞视频id)建立唯一联合索引
消费消息时需要先向表中插入一条记录,如果插入成功,说明是第一次消费,继续执行业务
如果插入失败,唯一索引冲突,说明消息已经消费过,直接ACK
消息唯一id去重
创建消息时,给消息分配一个唯一event_id,设置为唯一索引
消费消息时检查数据库表中该id的记录是否存在(能否插入成功),若不存在,说明是第一次消费,继续执行业务
若存在,说明消息已经消费过,直接ACK
消息唯一id + Redis
1 | SET consume:msg:id 1 NX EX 3600 |
本质上是利用redis代替数据库插入操作,在消费消息之前,先用SETNX抢占该消息的处理权,设置一个较长的过期时间。后续重复的消息到来时,无法重新SET,则认为该条消息已经被消费过了,直接ACK
缺点是过期时间难以控制,因此更推荐使用数据库
状态机实现幂等(CAS)
很多业务天然有状态流转,例如订单状态:
1 | 待支付 -> 已支付 -> 已发货 -> 已完成 |
消费消息时带上当前状态条件:
1 | update orders |
第一次更新成功,重复消息再次执行时,因为状态已经不是UNPAID,不会产生影响。
这种方式适合订单、支付、退款等有明确状态机的业务。
消息顺序性
RabbitMQ单个Queue内部的消息入队顺序是有序的,但这不代表业务处理一定有序。
会破坏顺序的场景:
多个消费者消费同一个Queue
RabbitMQ会把消息分发给多个Consumer,Consumer处理速度不同,完成顺序就可能乱。
1
2
3msg1 -> ConsumerA 处理慢
msg2 -> ConsumerB 处理快
最终 msg2 先完成Consumer内部多线程处理
即使只有一个Consumer,如果拿到消息后丢进线程池并发处理,也可能乱序。
消费失败重试
msg1失败后重新入队或进入重试队列,msg2可能先被成功消费,导致业务顺序被打乱。
如何保证顺序消费?
- 最严格的方式:
1 | 同一业务维度的消息 -> 同一个Queue -> 单个Consumer -> 单线程处理 -> prefetch=1 |
- 同一个用户的业务消息消费有序(非保证全局顺序):
将同一个用户的消息根据id路由到相同的队列,每个队列采用单个worker
1 | userID % N -> QueueA // A用户 -> A队列 |
同一个用户的消息永远进入同一个Queue,每个Queue用单消费者顺序处理。这样可以保证同一个用户维度有序,同时多个Queue之间还能并行
需要注意:全局有序代价很高,基本等于单队列单消费者,吞吐量很低。实际项目中通常只保证某个业务维度局部有序,例如同一个订单、同一个用户、同一个商品
- 基于版本号 + CAS
给消息分配一个版本号,例如点赞:
1 | update likes set status = 'liked', version = 3 where version < 3 |
如果版本号比较旧,说明时重复的消息,就不更新点赞状态(如果要保证消息顺序性,不要用插入,用update)
QoS 和 Prefetch
RabbitMQ默认可能会一次推送多条消息给消费者,如果消费者处理慢,这些消息会堆在消费者本地,其他空闲消费者拿不到。
QoS用于限制消费者未确认消息的数量,最常用的是prefetch_count。
1 | prefetch_count = 1 |
表示一个Consumer最多只能持有1条未ACK消息,处理完并ACK后,RabbitMQ才会继续推送下一条。
Prefetch设置过大的问题:
- 消息堆在某个消费者本地,负载不均衡
- 消费者宕机时,大量未ACK消息需要重新分配给其他消费者
- 顺序性更难保证
Prefetch设置过小的问题:
- 消费者处理能力利用不充分
- 网络往返次数增加,吞吐量下降
实际设置需要看业务处理耗时:
- 耗时任务、需要顺序性:设置小一些,比如1
- 短平快任务、追求吞吐:可以适当调大
消息积压
消息积压是指生产速度大于消费速度,队列中的消息越来越多。
常见原因:
- 消费者处理慢
- 消费者数量不够
- 下游数据库 / RPC变慢,导致消费阻塞
- 消费者异常退出
- 某些消息反复失败重试,阻塞正常消费
- 突发流量过大
如何排查:
- 看队列堆积量和增长速度
- 看消费者是否在线、消费速率是否下降
- 看消费者日志,有没有异常或大量重试
- 看下游依赖,例如数据库、Redis、RPC接口是否变慢
- 看单条消息处理耗时是否异常
解决方案:
临时扩容消费者
如果业务可以并行处理,直接增加Consumer数量。
优化消费逻辑
减少慢SQL、减少远程调用、批量写入数据库。
拆分队列
如果单队列吞吐不够,可以按业务维度分成多个Queue并行消费。
处理异常消息
对反复失败的消息,不要一直重试,超过次数进入死信队列。
限流生产者
当队列堆积过大时,上游需要限流或降级。
数据库和MQ一致性
常见场景:
1 | 创建订单 -> 写数据库 -> 发送MQ消息 |
问题在于数据库和MQ不是一个事务:
- 数据库写成功,MQ发送失败:订单创建了,但下游不知道
- MQ发送成功,数据库事务回滚:下游收到了一条不应该存在的消息
最推荐的方案是Outbox模式。
Outbox模式
把业务数据和待发送消息放在同一个数据库事务里提交。
例如创建订单时,同时插入订单表和本地消息表:
1 | begin transaction |
只要事务提交成功,订单和消息记录一定同时存在。
之后由后台任务扫描outbox_message表,把未发送的消息投递到RabbitMQ。发送成功后,把消息状态更新为sent。
完整流程:
1 | 业务接口 |
Outbox解决的是“数据库写成功但MQ没发出去”的问题。即使服务在提交事务后宕机,消息记录仍然在数据库中,后台任务恢复后可以继续发送。
缺点:
- 多一张本地消息表
- 需要后台扫描或CDC同步
- 消息可能重复发送,因此消费者仍然需要幂等
RabbitMQ 高可用
RabbitMQ的普通单节点没有高可用,节点宕机后服务不可用。
RabbitMQ集群可以把多个节点组成一个整体,但需要注意:集群不等于队列高可用。
因为普通Classic Queue默认只存在于声明它的那个节点上。即使集群中有多个节点,如果这个Queue所在节点宕机,该队列也不可用。
Classic Queue
Classic Queue是传统队列,普通情况下队列数据只在一个节点上。
优点是简单、性能较好;缺点是单节点故障会影响队列可用性。
Mirrored Queue
Mirrored Queue是经典镜像队列,会把队列镜像到多个节点,主节点负责处理读写,从节点同步数据。主节点宕机后,从镜像节点中选出新的主节点。
这种方式可以提升可用性,但存在一些问题:
- 同步成本高
- 网络分区时容易复杂
- 主从切换时可能出现数据一致性问题
新版本RabbitMQ更推荐使用Quorum Queue。
Quorum Queue
Quorum Queue(仲裁队列)是基于Raft协议实现的高可用队列。
队列会有多个副本,其中一个Leader负责读写,其他Follower复制日志。消息写入需要多数副本确认后才算成功。
1 | Leader |
如果Leader宕机,剩余副本通过Raft重新选举新的Leader。
Quorum Queue为什么更可靠?
- 基于Raft,多数派写入,数据一致性更强
- Leader故障后可以自动选主
- 比传统镜像队列的行为更可预测
缺点:
- 写入需要复制到多数节点,吞吐会低于单节点Classic Queue
- 更占磁盘和网络
- 不适合特别短暂、吞吐极高、可靠性要求低的临时队列
高可用建议:
- 核心业务队列使用Quorum Queue
- RabbitMQ节点至少3个,保证多数派选举
- 生产者开启Publisher Confirm
- 消费者手动ACK
- 配置死信队列和监控告警
RabbitMQ 集群
RabbitMQ集群主要解决的是Broker节点管理和元数据共享问题。
集群中的节点会共享Exchange、Binding、用户、权限等元数据,但消息和队列数据不一定自动复制到所有节点。
所以面试中要注意:
1 | RabbitMQ集群 ≠ 队列高可用 |
如果只是普通Classic Queue,队列在哪个节点声明,消息主要就在哪个节点。要实现队列高可用,需要使用Quorum Queue或镜像队列。
节点宕机后的情况:
- 如果宕机节点上没有该队列的Leader,影响较小
- 如果宕机节点是Classic Queue所在节点,该队列不可用
- 如果宕机节点是Quorum Queue的Leader,会重新选举Leader,只要多数副本存活,队列可以继续工作
- 如果Quorum Queue失去多数派,队列不可用
RabbitMQ 常见性能瓶颈
消费者处理慢
大多数消息积压问题不是RabbitMQ慢,而是消费者慢。
消息持久化和Confirm开销
强可靠会增加磁盘IO和确认开销,吞吐会下降。
单队列吞吐瓶颈
单个Queue通常只能由一个队列Leader负责,吞吐有限。可以按业务维度拆多个Queue。
消息体过大
MQ不适合传大文件。大对象应该放对象存储或数据库,MQ里只传ID或URL。
频繁创建Connection / Channel
Connection和Channel应该复用,不要每发一条消息就创建一次。
未设置Prefetch
可能导致消息分配不均、消费者本地堆积。
说些什么吧!