消息队列八股文
消息队列基础
为什么要使用消息队列?
消息队列主要解决三个问题:异步、解耦、削峰。
异步
如果没有消息队列,一个请求中可能需要同步执行很多操作:
1 | 创建订单 -> 扣库存 -> 发短信 -> 发邮件 -> 写日志 -> 返回响应 |
如果每一步都同步执行,接口响应时间会很长。
使用消息队列后,可以把非核心逻辑异步化:
1 | 创建订单 -> 扣库存 -> 投递消息 -> 返回响应 |
这样主链路只保留核心操作,接口响应速度会更快。
解耦
如果订单系统直接调用短信系统、邮件系统、积分系统,那么订单系统需要知道这些下游系统的接口地址、调用方式、失败处理逻辑。
一旦新增一个下游业务,订单系统就要改代码。
使用消息队列后,订单系统只负责发布“订单创建成功”这个事件。至于谁关心这个事件,由消费者自己订阅。
1 | 订单系统 -> MQ -> 短信系统 |
这样生产者和消费者之间只依赖消息格式,不直接依赖彼此的接口。
削峰
如果某个时间点请求量突然上涨,数据库或下游服务可能承受不了瞬时压力。
消息队列可以把瞬时流量先缓存起来,消费者按照自己的处理能力慢慢消费。
1 | 突发流量 -> MQ 暂存 -> 消费者按固定速度处理 |
例如秒杀系统中,请求量可能瞬间冲到几十万 QPS,但真正能创建成功的订单数量有限,可以先把请求写入队列,再异步消费。
消息队列的缺点
消息队列不是只有优点,引入 MQ 后系统复杂度会明显上升。
主要问题:
- 系统可用性降低:原来只依赖数据库,现在还依赖 MQ
- 一致性变复杂:主业务成功了,但消息可能发送失败
- 可能出现消息丢失:生产、存储、消费三个阶段都可能丢消息
- 可能出现重复消费:MQ 一般只保证至少一次投递,不保证只投递一次
- 可能出现消息积压:消费者处理速度跟不上生产者
- 顺序性更难保证:多个消费者并发消费时,消息顺序可能被打乱
所以面试中只说“我用了 MQ”是不够的,必须能说清楚:
- 为什么这里需要 MQ
- 消息丢了怎么办
- 消费失败怎么办
- 重复消费怎么办
- 消息积压怎么办
- 如何监控和补偿
常见消息队列对比
| 中间件 | 特点 | 常见场景 |
|---|---|---|
| RabbitMQ | 基于 AMQP,路由灵活,支持 ACK、死信队列、延迟队列 | 业务异步、任务队列、通知、订单事件 |
| Kafka | 高吞吐、分区日志、可持久化、适合流式数据 | 日志采集、埋点、实时计算、数据管道 |
| RocketMQ | 阿里开源,支持事务消息、延迟消息、顺序消息 | 电商、金融、订单、事务一致性 |
| Redis Stream | Redis 内置消息流,轻量,支持消费者组 | 轻量级异步任务、简单消息流 |
如果是普通业务异步、需要灵活路由和失败重试,RabbitMQ 很常用。
如果是海量日志、行为事件、实时数据流,Kafka 更合适。
RabbitMQ 基础
RabbitMQ 是什么
RabbitMQ 是一个基于 AMQP 协议的消息队列中间件。
AMQP(Advanced Message Queuing Protocol)是一种消息队列协议,定义了生产者、消费者、交换机、队列、路由等概念。
RabbitMQ 的特点:
- 支持多种 Exchange 类型,路由能力强
- 支持消息持久化
- 支持手动 ACK
- 支持死信队列
- 支持延迟消息方案
- 支持消费端限流 QoS
- 管理后台完善,便于查看队列状态
RabbitMQ 核心概念
Producer
生产者,负责发送消息。
生产者不直接把消息发给 Queue,而是把消息发给 Exchange。
Consumer
消费者,负责从 Queue 中消费消息。
消费者处理成功后一般需要手动 ACK,告诉 RabbitMQ 这条消息可以删除。
Broker
RabbitMQ 服务端实例,也就是消息中间件本身。
一个 RabbitMQ Broker 内部可以有多个 virtual host,每个 virtual host 之间的 exchange、queue、binding 相互隔离。
Connection
客户端和 RabbitMQ Broker 之间的 TCP 连接。
Connection 是重量级资源,一般一个进程不会为每个业务都创建一个新的 TCP 连接。
Channel
Channel 是建立在 Connection 之上的轻量级逻辑通道。
RabbitMQ 大部分操作都通过 Channel 完成,例如:
- 声明 Exchange
- 声明 Queue
- 绑定 Queue
- 发布消息
- 消费消息
- ACK / NACK 消息
Connection 和 Channel 的关系可以理解为:
1 | 一个 TCP Connection |
为什么需要 Channel?
因为频繁创建 TCP 连接开销比较大,所以 RabbitMQ 在一个 TCP 连接上复用多个 Channel。
实际开发中常见做法:
- 一个应用进程可以复用一个 Connection
- 不同 worker / 不同并发消费者使用独立 Channel
- 不要在多个 goroutine / 线程里无保护地并发操作同一个 Channel
Exchange
Exchange 是交换机,负责接收生产者发送的消息,然后根据 routing key 和 binding key 把消息路由到一个或多个 Queue。
生产者发送消息时指定:
- exchange
- routing key
- message body
Exchange 根据规则决定消息进入哪些队列。
Queue
Queue 是消息队列,真正保存消息的地方。
消费者是从 Queue 中消费消息,而不是从 Exchange 中消费消息。
Binding
Binding 是 Exchange 和 Queue 之间的绑定关系。
绑定时通常会指定 binding key。
Routing Key
Routing Key 是生产者发送消息时携带的路由键。
Exchange 会根据 routing key 和 binding key 的匹配关系决定消息路由到哪些 Queue。
RabbitMQ 消息流转过程
RabbitMQ 的基本流程如下:
1 | Producer |
注意:生产者通常不直接发送消息到 Queue,而是发送到 Exchange。
Exchange 类型
RabbitMQ 常见 Exchange 类型有四种:
- direct
- fanout
- topic
- headers
面试中重点掌握前三种。
Direct Exchange
Direct Exchange 按照 routing key 精确匹配。
只有消息的 routing key 和队列绑定的 binding key 完全相等,消息才会进入该队列。
1 | Exchange: order.direct |
生产者发送:
1 | routing key = order.created |
那么消息只会进入 Queue A。
适合场景:明确的一对一路由,例如订单创建、订单支付、订单取消。
Fanout Exchange
Fanout Exchange 会把消息广播给所有绑定到该 Exchange 的队列,忽略 routing key。
1 | Exchange: log.fanout |
生产者发送一条消息,A、B、C 三个队列都会收到一份。
适合场景:广播通知,例如系统公告、日志广播、缓存刷新通知。
Topic Exchange
Topic Exchange 支持通配符匹配,是业务系统中很常用的一种 Exchange。
通配符规则:
*:匹配一个单词#:匹配零个或多个单词
单词之间用 . 分隔。
例如:
1 | Queue A binding key: order.* |
消息:
1 | routing key = order.created |
匹配结果:
- Queue A 可以收到,因为
order.*匹配两个单词 - Queue B 可以收到,因为
order.#匹配 order 后面的任意内容 - Queue C 可以收到,因为
*.created匹配任意前缀 + created
如果消息是:
1 | routing key = order.user.created |
则:
order.*不能匹配,因为*只能匹配一个单词order.#可以匹配*.created不能匹配,因为中间多了一个 user
Topic Exchange 适合场景:事件驱动系统,例如:
1 | user.register |
Headers Exchange
Headers Exchange 根据消息 header 匹配,而不是根据 routing key 匹配。
实际业务中用得比较少,面试中知道即可。
RabbitMQ 常见工作模式
简单队列模式
一个生产者,一个队列,一个消费者。
1 | Producer -> Queue -> Consumer |
适合最简单的异步任务。
Work Queue 工作队列模式
一个队列,多个消费者竞争消费。
1 | Producer -> Queue -> Consumer 1 |
同一条消息只会被其中一个消费者消费。
适合任务分发,例如图片处理、视频转码、异步写库。
注意:多个消费者并发消费可以提升吞吐,但会影响严格顺序性。
Publish / Subscribe 发布订阅模式
通过 Fanout Exchange 广播消息。
1 | Producer -> Fanout Exchange -> Queue A -> Consumer A |
每个队列都会收到一份消息。
适合广播场景。
Routing 路由模式
通过 Direct Exchange 按 routing key 精确路由。
1 | Producer -> Direct Exchange -> error.queue |
适合根据消息类型进行精确分发。
Topic 主题模式
通过 Topic Exchange 按通配符路由。
1 | Producer -> Topic Exchange -> order.queue |
适合复杂业务事件路由,是后端业务系统中很常用的模式。
ACK 机制
什么是 ACK
ACK 是消费者对 RabbitMQ 的确认机制。
消费者处理完消息后,需要告诉 RabbitMQ:这条消息已经处理成功,可以从队列中删除。
如果消费者没有 ACK,RabbitMQ 会认为消息还没有处理完成。
自动 ACK 和手动 ACK
RabbitMQ 消费消息时可以设置 autoAck。
自动 ACK
自动 ACK 表示 RabbitMQ 只要把消息投递给消费者,就立刻认为消息消费成功。
1 | autoAck = true |
问题是:如果消费者刚收到消息就宕机了,消息还没处理,但 RabbitMQ 已经删除消息了,这条消息就丢了。
所以生产环境中,核心业务消息一般不使用自动 ACK。
手动 ACK
手动 ACK 表示消费者处理完业务逻辑后,再主动确认消息。
1 | autoAck = false |
这样可以避免消费者处理过程中宕机导致消息丢失。
Ack、Nack、Reject 区别
Ack
Ack 表示消息处理成功。
1 | Ack(multiple=false) |
RabbitMQ 收到 ACK 后,会从队列中删除该消息。
Nack
Nack 表示消息处理失败。
Nack 可以指定是否重新入队:
1 | Nack(multiple=false, requeue=true) // 重新放回队列 |
Reject
Reject 也表示拒绝消息,但一次只能拒绝一条消息。
1 | Reject(requeue=true) |
Nack 比 Reject 更灵活,因为 Nack 支持 multiple 批量拒绝。
multiple 参数是什么意思
Ack / Nack 中的 multiple 参数表示是否批量确认。
multiple=false:只确认当前这条消息multiple=true:确认当前 delivery tag 及之前所有未确认消息
一般业务代码中,如果没有明确的批量确认设计,使用 multiple=false 更安全。
requeue 参数是什么意思
requeue 表示失败消息是否重新回到原队列。
requeue=true:消息重新入队,之后可能再次被消费requeue=false:消息不回原队列,如果配置了死信队列,会进入死信队列
注意:不能无脑 requeue=true。
如果业务一直失败,消息会被反复投递,形成无限重试,导致队列被毒消息拖死。
消息可靠性
面试中经常会问:如何保证消息不丢?
要分三个阶段回答:
1 | 生产者 -> RabbitMQ -> 消费者 |
消息可能在三个阶段丢失:
- 生产者发送到 RabbitMQ 的过程中丢失
- RabbitMQ 存储消息时丢失
- 消费者消费消息时丢失
生产者阶段如何防止消息丢失
生产者发送消息后,如果网络抖动、Exchange 不存在、RabbitMQ 宕机,都可能导致消息没有真正到达 Broker。
常见解决方案:
- Publisher Confirm
- mandatory + return callback
- 本地消息表 / Outbox
- 失败重试和告警
Publisher Confirm
Publisher Confirm 是 RabbitMQ 提供的生产者确认机制。
开启 Confirm 后,生产者发送消息,RabbitMQ 收到并处理后,会回调确认结果。
1 | Producer publish message |
- ack:RabbitMQ 已经收到消息
- nack:RabbitMQ 没有成功处理消息,需要生产者重试或记录失败
Confirm 解决的是:生产者不知道消息有没有到达 Broker 的问题。
mandatory 和 return callback
Confirm 只能说明消息到达了 Exchange,但不一定说明消息被路由到了 Queue。
如果 Exchange 存在,但 routing key 没有匹配任何 Queue,消息可能被丢弃。
可以设置 mandatory:
1 | mandatory = true |
当消息无法路由到任何队列时,RabbitMQ 会把消息退回给生产者,生产者可以记录日志、重试或告警。
本地消息表 / Outbox
如果业务操作和发送 MQ 消息需要保持一致,单纯重试不一定够。
例如:
1 | 1. 插入订单成功 |
这时数据库中已经有订单,但下游系统收不到订单创建事件。
可以使用本地消息表 / Outbox:
1 | 同一个数据库事务中: |
这样即使 MQ 临时不可用,待投递消息也已经落库,后续可以继续重试。
Broker 阶段如何防止消息丢失
RabbitMQ 收到消息后,如果没有持久化,Broker 宕机后消息可能丢失。
需要做三件事:
- Exchange 持久化
- Queue 持久化
- Message 持久化
Exchange 持久化
声明 Exchange 时设置 durable:
1 | durable = true |
这样 RabbitMQ 重启后 Exchange 还存在。
Queue 持久化
声明 Queue 时设置 durable:
1 | durable = true |
这样 RabbitMQ 重启后 Queue 还存在。
Message 持久化
发送消息时设置 delivery mode:
1 | DeliveryMode = Persistent |
这样消息会被持久化到磁盘。
注意:Exchange 和 Queue 持久化并不代表消息持久化。三者都要配置。
持久化是否能保证 100% 不丢
不能。
消息持久化只是降低 Broker 宕机导致消息丢失的概率,但不是绝对保证。
原因是 RabbitMQ 收到消息后,写入磁盘也可能有一个很短的时间窗口。如果 Broker 在消息还没真正刷盘时宕机,仍然可能丢失。
因此更严格的可靠性通常需要:
- 生产者 Confirm
- Queue / Message 持久化
- RabbitMQ 高可用队列
- 消费端手动 ACK
- 业务侧补偿机制
消费者阶段如何防止消息丢失
消费者阶段最重要的是:业务处理成功后再 ACK。
错误做法:
1 | 收到消息 -> 立刻 ACK -> 执行业务逻辑 |
如果 ACK 后业务还没处理完,消费者宕机,消息已经被 RabbitMQ 删除,业务却没有执行成功。
正确做法:
1 | 收到消息 -> 执行业务逻辑 -> 业务成功 -> ACK |
这样消费者宕机时,未 ACK 的消息会重新回到队列,之后可以再次投递给其他消费者。
重复消费与幂等性
为什么会重复消费
大多数 MQ 默认提供的是 至少一次投递(at least once),而不是严格的 exactly once。
至少一次投递的含义是:消息尽量不丢,但可能重复。
常见重复消费场景:
生产者重试导致重复
生产者发送消息后,RabbitMQ 实际已经收到消息,但生产者没有收到确认,于是重新发送了一次。
1 | Producer -> RabbitMQ 成功 |
结果 RabbitMQ 中可能有两条相同业务含义的消息。
消费者处理成功但 ACK 失败
消费者已经把业务逻辑执行成功了,但 ACK 时网络断开。
1 | Consumer 执行业务成功 |
这时消费者会再次处理同一条消息。
消费者超时或宕机
消费者拿到消息后处理时间太久,或者处理过程中宕机,RabbitMQ 会把未 ACK 的消息重新投递。
手动重试导致重复
业务代码中为了保证成功,可能会把失败消息重新发布回队列。如果原消息 ACK 失败,也可能导致新旧两条消息同时存在。
如何避免重复消费
核心思想:不要依赖 MQ 保证不重复,而是业务自己保证幂等。
幂等指的是:同一个操作执行一次和执行多次,最终结果一致。
常见方案:
- 数据库唯一索引
- event_id 去重表
- Redis SETNX
- 状态机流转
- 乐观锁版本号
- 业务天然幂等
数据库唯一索引
如果一条消息对应一条唯一业务记录,可以在数据库中建立唯一索引。
例如点赞关系:
1 | UNIQUE KEY uk_user_video (user_id, video_id) |
重复消费时再次插入会触发 duplicate key,业务代码捕获后直接认为成功。
1 | 第一次消费:insert 成功 |
这种方式简单可靠,非常常见。
event_id 去重
如果消息本身可以携带全局唯一的 event_id,可以建立事件处理表或在业务表上加唯一索引。
1 | CREATE TABLE processed_event ( |
消费流程:
1 | 1. 开启事务 |
这样可以保证同一个事件只被处理一次。
Redis SETNX 去重
对于不需要强事务一致性的场景,可以使用 Redis:
1 | SET mq:event:123 processed NX EX 86400 |
- 设置成功:第一次处理,可以执行业务
- 设置失败:说明已经处理过,直接丢弃
优点:性能高。
缺点:Redis 过期后可能无法继续去重;如果 Redis 故障,需要考虑降级。
状态机流转
对于订单这类有明确状态的业务,可以通过状态机保证幂等。
例如订单状态:
1 | 待支付 -> 已支付 -> 已发货 -> 已完成 |
如果重复收到“支付成功”消息:
- 当前状态是待支付:更新为已支付
- 当前状态已经是已支付:直接忽略
- 当前状态已经是已发货:说明消息过期,也直接忽略
状态机天然适合处理重复消息和乱序消息。
乐观锁版本号
如果业务需要防止并发重复更新,可以使用版本号。
1 | UPDATE product |
如果重复消费时版本号已经变化,更新行数为 0,说明已经被处理过。
哪些业务天然幂等
有些操作本身就是幂等的。
例如:
1 | DELETE FROM likes WHERE user_id = 1 AND video_id = 2; |
删除一次和删除多次,最终结果都是没有这条点赞关系。
再比如 Redis ZSet:
1 | ZADD timeline 100 video_1 |
如果 member 相同,多次 ZADD 不会产生多条相同记录,只会更新 score。
但要注意:如果 score 每次不同,多次执行虽然不会重复插入 member,但 score 可能被更新,是否可接受要看业务。
消费失败怎么办
失败类型
消费失败大致可以分为两类:
临时性失败
例如:
- 数据库短暂不可用
- Redis 网络抖动
- 下游接口超时
- MQ 连接短暂异常
这种失败可以重试。
永久性失败
例如:
- 消息格式错误
- 参数非法
- 业务数据不存在
- 反序列化失败
这种失败重试也没用,应该直接丢弃或进入死信队列,等待人工排查。
直接 requeue 的问题
最简单的失败处理是:
1 | Nack(requeue=true) |
这样消息会重新回到队列。
但这有两个问题:
- 无法控制重试次数
- 失败消息可能立刻再次被消费,形成死循环
例如数据库一直不可用,消息会不断被取出、失败、重新入队、再次取出,导致 CPU 空转和日志爆炸。
推荐的重试方式
更合理的方式是:控制重试次数,超过次数后进入死信队列。
1 | 消费失败 |
常见实现方式:
- 消息 header 中记录 retry_count
- 单独创建 retry queue
- TTL + DLX 实现延迟重试
- RabbitMQ delayed message exchange 插件实现延迟重试
- 超过最大次数后进入 DLQ
为什么需要延迟重试
如果失败是因为下游服务短暂不可用,立即重试往往还是失败。
更好的方式是延迟重试:
1 | 第一次失败:10 秒后重试 |
这样可以给下游服务恢复的时间,也能避免无意义的高频重试。
什么是毒消息
毒消息指的是永远无法被正常消费的消息。
例如消息体格式错误:
1 | {"user_id":"abc"} |
但消费者期望 user_id 是数字。
如果毒消息不断 requeue,会一直阻塞队列消费。
处理方式:
- 参数校验失败直接 ACK 丢弃,或进入死信队列
- 不要无限 requeue
- 对死信队列做监控和告警
死信队列
什么是死信队列
死信队列(Dead Letter Queue,DLQ)用于保存无法被正常消费的消息。
RabbitMQ 中消息变成死信后,会被投递到死信交换机(Dead Letter Exchange,DLX),再由 DLX 路由到死信队列。
流程:
1 | 普通队列 -> 消费失败 / 过期 / 队列满 -> DLX -> 死信队列 |
什么情况下消息会变成死信
常见情况:
- 消费者 Nack / Reject,并且
requeue=false - 消息 TTL 过期
- 队列长度超过限制,旧消息被挤出
- quorum queue 达到 delivery-limit
如何配置死信队列
声明普通队列时指定死信交换机:
1 | x-dead-letter-exchange = dlx.exchange |
然后声明 DLX 和死信队列:
1 | 普通 Queue |
死信队列有什么用
死信队列不是用来“自动修复问题”的,而是用来保存失败消息,避免消息直接丢失。
进入死信队列后,一般需要:
- 记录日志
- 监控告警
- 人工排查
- 修复数据或代码
- 提供后台补偿工具重新投递
死信队列和重试队列的区别
| 类型 | 作用 |
|---|---|
| 重试队列 | 临时保存待重试的失败消息 |
| 死信队列 | 保存超过重试次数或无法处理的消息 |
重试队列是“还有机会自动恢复”,死信队列是“自动处理失败,需要人工或补偿系统介入”。
TTL 和延迟消息
TTL 是什么
TTL(Time To Live)表示消息或队列中消息的存活时间。
RabbitMQ 支持两种 TTL:
- 队列级 TTL:队列中所有消息统一过期时间
- 消息级 TTL:每条消息单独设置过期时间
TTL + DLX 实现延迟消息
RabbitMQ 本身普通队列不直接支持延迟消息,但可以用 TTL + DLX 实现。
例如要实现 10 秒后消费:
1 | Producer -> 延迟队列(delay.queue, TTL=10s) |
消费者不监听延迟队列,而是监听真实消费队列。
TTL 延迟队列的问题
如果使用消息级 TTL,可能出现队头阻塞问题。
例如同一个队列中:
1 | 消息 A TTL = 60s |
如果 A 在队头,B 即使已经过期,也可能要等 A 先过期后才能被转发。
所以生产中如果延迟场景复杂,可以考虑:
- 使用多个固定延迟级别的队列
- 使用 RabbitMQ delayed message exchange 插件
- 使用 RocketMQ 这类原生支持延迟消息的 MQ
QoS 和 prefetch
什么是 prefetch
RabbitMQ 默认可能会一次性给消费者推送很多消息。
如果某个消费者处理很慢,但 RabbitMQ 已经把大量消息投递给它,这些消息会处于 unacked 状态,其他消费者拿不到,导致负载不均衡。
可以通过 QoS 设置 prefetch:
1 | basic.qos(prefetchCount = 10) |
含义是:一个消费者最多同时持有 10 条未 ACK 的消息。
1 | Consumer 未 ACK 消息数 < 10 -> RabbitMQ 继续投递 |
prefetch 的作用
- 防止某个消费者一次性拿太多消息
- 实现更公平的任务分发
- 控制消费者内存压力
- 避免下游数据库被单个消费者打爆
prefetch 越大越好吗
不是。
prefetch 太大:
- 单个消费者积压太多 unacked 消息
- 宕机后需要重新投递大量消息
- 负载不均衡
prefetch 太小:
- 网络往返变多
- 吞吐下降
一般根据消息处理耗时和消费者能力调优。
如果单条消息处理很快,可以适当调大;如果消息处理很慢或占用内存大,应该调小。
消息积压
什么是消息积压
消息积压是指生产者发送消息的速度大于消费者处理消息的速度,导致队列中的消息越来越多。
1 | 生产速度:10000 条/s |
消息积压的原因
常见原因:
- 消费者数量太少
- 消费逻辑太慢
- 下游数据库或接口变慢
- 出现毒消息反复重试
- 突发流量过大
- 消费者进程异常退出
- prefetch 配置不合理
消息积压怎么排查
排查顺序:
- 看队列长度是否持续增长
- 看消费者数量是否正常
- 看 unacked 消息数量是否异常
- 看消费者日志是否大量报错
- 看数据库、Redis、下游接口是否变慢
- 看死信队列是否有大量消息
- 看是否存在单条毒消息导致反复失败
消息积压怎么处理
短期应急:
- 临时增加消费者实例
- 提高消费者并发度
- 扩容下游服务
- 暂停或限流生产者
- 将失败消息转移到死信队列
长期优化:
- 优化消费逻辑,减少慢 SQL 和远程调用
- 批量消费、批量写库
- 按业务 key 拆分多个队列
- 增加监控和告警
- 对非核心消息允许丢弃或降级
- 根据队列积压动态扩容 worker
如何判断是不是 MQ 瓶颈
需要看指标:
- publish rate:生产速率
- deliver rate:投递速率
- ack rate:确认速率
- ready messages:待消费消息数
- unacked messages:已投递未确认消息数
- consumer count:消费者数量
- dead letter count:死信数量
如果 ready 一直涨,说明消费能力不足。
如果 unacked 很高,说明消息已经投递给消费者,但消费者处理慢或没有 ACK。
消息顺序性
RabbitMQ 能保证消息顺序吗
RabbitMQ 在单队列、单消费者、没有重试和重新入队的情况下,可以基本保证队列内消息顺序。
1 | Producer -> Queue -> Consumer |
但是以下情况会破坏顺序:
- 一个队列有多个消费者并发消费
- 消费失败后 requeue
- 消费者处理速度不同
- 生产者多线程并发发送
- 消息进入不同队列
为什么多个消费者会破坏顺序
例如队列中有消息 A、B:
1 | A -> Consumer 1,处理很慢 |
虽然 A 先被投递,但 B 可能先处理完成。
最终业务效果上就是 B 先于 A 生效。
如何保证顺序消费
常见方案:
单队列单消费者
最简单,但吞吐最低。
1 | Queue -> Consumer |
适合顺序性要求高、吞吐要求不高的业务。
按业务 key 分队列
把同一个业务 key 的消息路由到同一个队列。
例如同一个订单 ID 的消息必须顺序处理:
1 | order_id % N -> queue_index |
这样同一个订单的消息进入同一个队列,不同订单可以并发处理。
消费端按 key 串行化
消费者收到消息后,根据业务 key 放入本地不同的串行队列。
1 | order_1 -> worker A 串行处理 |
状态机兜底
即使消息乱序,也通过状态机保证不会错误更新。
例如订单已经发货后,又收到“支付成功”消息,应该判断当前状态,不应该回退。
面试回答
可以这样回答:
RabbitMQ 只能在单队列单消费者等简单条件下保证队列内顺序。实际业务中如果有多个消费者、重试或 requeue,顺序就可能被破坏。对于强顺序业务,我会按业务 key 路由到固定队列,或者在消费端按 key 串行处理,同时用状态机防止乱序消息造成错误状态变更。
消息丢失问题
面试题:如何保证消息不丢失
可以从三段回答:
1 | 生产者 -> Broker -> 消费者 |
生产者不丢
- 开启 Publisher Confirm,确认消息到达 Broker
- 开启 mandatory,处理无法路由的消息
- 发送失败要重试、记录日志或落库
- 对强一致业务使用 Outbox / 本地消息表
Broker 不丢
- Exchange durable
- Queue durable
- Message persistent
- 使用高可用队列,例如 quorum queue
- 对 RabbitMQ 做监控、备份和集群部署
消费者不丢
- 使用手动 ACK
- 业务处理成功后再 ACK
- 业务处理失败时重试或进入死信队列
- 消费逻辑需要幂等,防止重复投递造成数据错误
消息丢失能完全避免吗
很难做到绝对不丢。
工程上更常见的是:
- 尽量降低丢失概率
- 能发现丢失
- 能补偿丢失
所以可靠消息系统通常还需要:
- 日志追踪
- 消息状态表
- 失败重试
- 死信队列
- 定时补偿任务
- 监控报警
事务消息和 Outbox
为什么本地事务和 MQ 投递会有一致性问题
数据库事务只能保证数据库操作的原子性,不能直接保证 MQ 投递也原子。
例如:
1 | 1. 插入订单成功 |
结果是订单已经存在,但下游没有收到订单创建事件。
反过来也可能:
1 | 1. 发送 MQ 成功 |
结果是下游收到订单创建事件,但数据库里没有订单。
这就是本地事务和外部消息系统之间的一致性问题。
方案一:先写数据库再发 MQ
1 | begin transaction |
问题:commit 成功后,如果 publish MQ 失败,消息会丢。
方案二:先发 MQ 再写数据库
1 | publish MQ |
问题:MQ 发送成功后,如果数据库事务失败,下游会收到不存在的数据事件。
方案三:本地消息表 / Outbox
Outbox 的核心是把业务数据和待发送消息写入同一个数据库事务。
1 | begin transaction |
这样可以保证:只要业务数据提交成功,待投递消息也一定落库。
即使 MQ 当时不可用,后续也可以通过扫描 outbox 表继续投递。
Outbox 的优点
- 解决本地事务和 MQ 投递之间的不一致问题
- MQ 短暂故障时消息不会直接丢失
- 支持失败重试和人工补偿
- 可以记录消息状态,方便排查问题
Outbox 的缺点
- 需要额外的 outbox 表
- 需要后台扫描任务
- 消息可能重复投递,消费者仍然要幂等
- 有一定延迟,不是完全实时
Outbox 表常见字段
1 | CREATE TABLE outbox_messages ( |
常见状态:
- pending:待投递
- processing:正在投递
- published:投递成功
- failed:超过重试次数,投递失败
多节点扫描 Outbox 如何避免重复抢占
如果多个 worker 同时扫描 outbox 表,可能同时拿到同一条 pending 消息。
可以使用条件更新抢占:
1 | UPDATE outbox_messages |
只有更新行数为 1 的 worker 才算抢占成功。
如果更新行数为 0,说明已经被其他节点抢占。
processing 状态卡住怎么办
如果 worker 抢占成功后宕机,消息可能一直停留在 processing。
可以设置超时回收机制:
1 | UPDATE outbox_messages |
这样其他节点后续可以重新抢占投递。
RabbitMQ 高可用
单机 RabbitMQ 有什么问题
单机 RabbitMQ 一旦宕机,生产者无法发送消息,消费者无法消费消息。
即使消息持久化,服务不可用期间也会影响业务。
生产环境通常需要高可用部署。
RabbitMQ 集群
RabbitMQ 集群可以有多个节点。
集群中的元数据会在节点间同步,例如:
- exchange
- queue 元信息
- binding
- user
- vhost
但普通队列的消息默认只存储在队列所在节点上,不是自动复制到所有节点。
镜像队列和 Quorum Queue
早期 RabbitMQ 常用 mirrored queue(镜像队列)实现队列复制。
新版本更推荐 quorum queue。
Quorum Queue 基于 Raft 协议复制消息,多个副本之间选出 leader,写入通过多数派确认,提高可用性和数据安全性。
简单理解:
1 | Quorum Queue |
生产者和消费者通过 leader 读写,消息复制到多数副本后认为成功。
高可用是否等于不用做幂等
不是。
高可用解决的是 Broker 宕机和数据可靠性问题,不解决重复投递问题。
即使使用 RabbitMQ 集群,仍然可能因为生产者重试、消费者 ACK 丢失、网络抖动导致重复消费。
所以消费者幂等仍然必须做。
RabbitMQ 和 Redis Pub/Sub 的区别
Redis Pub/Sub 也是发布订阅,但它和 RabbitMQ 有明显区别。
| 对比项 | RabbitMQ | Redis Pub/Sub |
|---|---|---|
| 消息保存 | Queue 可以保存消息 | 不保存消息 |
| 消费者离线 | 消息仍可保留在队列 | 离线期间消息直接丢失 |
| ACK | 支持手动 ACK | 不支持 ACK |
| 重试 | 支持重试和死信 | 不支持内置重试 |
| 适合场景 | 可靠异步任务 | 实时广播通知 |
Redis Pub/Sub 适合实时在线通知,例如在线用户推送、配置广播。
RabbitMQ 更适合可靠任务处理,例如订单事件、异步写库、消息通知入库。
Kafka 基础
Kafka 是什么
Kafka 是一个分布式事件流平台,也可以看作高吞吐的分布式消息系统。
Kafka 更像一个“可持久化的分布式日志”。
消息写入 Kafka 后,会追加到日志文件中,消费者根据 offset 顺序读取。
1 | Producer -> Kafka Topic Partition -> Consumer |
Kafka 常用于:
- 日志采集
- 用户行为埋点
- 实时计算
- 数据同步
- 大数据管道
- 流式处理
Kafka 核心概念
Broker
Kafka 集群中的一个服务节点。
多个 Broker 组成 Kafka 集群。
Topic
Topic 是消息主题,用于区分不同业务类型的消息。
例如:
1 | user_behavior |
Partition
一个 Topic 可以分成多个 Partition。
Partition 是 Kafka 并行和高吞吐的核心。
1 | Topic: order_event |
每个 partition 内部是有序追加日志。
Kafka 只能保证同一个 partition 内消息有序,不能保证整个 topic 全局有序。
Offset
Offset 是消息在 partition 中的位置。
消费者消费消息后,会提交 offset,表示自己消费到哪里了。
1 | partition 0: msg0 msg1 msg2 msg3 msg4 |
Consumer Group
Consumer Group 是消费者组。
同一个消费者组内,多个消费者共同消费一个 Topic。
一个 partition 在同一时刻只能被同一个消费者组内的一个消费者消费。
1 | Topic 有 3 个 partition |
如果消费者数量大于 partition 数,多出来的消费者会空闲。
Kafka 为什么吞吐高
Kafka 吞吐高的原因:
- 顺序写磁盘
- 利用操作系统 page cache
- 批量发送和批量消费
- 零拷贝
- 分区并行
- 消息压缩
顺序写磁盘
Kafka 写消息是追加写日志文件,主要是顺序 IO。
顺序写磁盘性能远高于随机写。
Page Cache
Kafka 大量依赖操作系统 page cache。
消息写入时先进入 page cache,之后由操作系统刷盘。
消费者读取最近消息时,也可能直接从 page cache 读,不一定真正访问磁盘。
零拷贝
Kafka 使用零拷贝技术减少数据从内核态到用户态的拷贝次数。
传统读取文件并发送网络:
1 | 磁盘 -> 内核缓冲区 -> 用户缓冲区 -> socket 缓冲区 -> 网卡 |
零拷贝可以减少中间拷贝:
1 | 磁盘 -> 内核缓冲区 -> socket 缓冲区 -> 网卡 |
Kafka 的 ACK 机制
生产者发送消息时可以设置 acks。
acks = 0
生产者发送后不等待 Broker 确认。
性能最高,但最容易丢消息。
acks = 1
只要 leader partition 写入成功,就返回成功。
如果 leader 写入后还没同步给 follower 就宕机,消息可能丢失。
acks = all / -1
leader 等待 ISR 中的副本都确认后才返回成功。
可靠性最高,但延迟更高。
ISR 是什么
ISR(In-Sync Replicas)表示和 leader 保持同步的副本集合。
Kafka 一个 partition 有多个副本:
1 | partition 0 |
如果 follower 落后太多,会被移出 ISR。
生产者设置 acks=all 时,通常要求 ISR 中的副本都写入成功后才返回。
Kafka 如何保证顺序
Kafka 只保证单个 partition 内有序。
如果要保证同一个订单的消息有序,需要让同一个订单 ID 的消息进入同一个 partition。
常见做法是指定 message key:
1 | key = order_id |
这样同一个 order_id 会进入同一个 partition。
Kafka 如何处理重复消费
Kafka 消费者通过提交 offset 记录消费进度。
重复消费常见原因:
1 | 消费者处理业务成功 |
解决方式仍然是业务幂等:
- 唯一索引
- event_id 去重
- 状态机
- Redis 去重
Kafka 有事务和 Exactly Once Semantics,但它主要解决 Kafka 内部“消费 - 处理 - 生产到 Kafka”的一致性问题。
如果消费者还要写 MySQL、调用外部接口,仍然需要业务幂等。
RabbitMQ 和 Kafka 对比
| 对比项 | RabbitMQ | Kafka |
|---|---|---|
| 模型 | 传统消息队列 | 分布式日志 / 事件流 |
| 路由能力 | Exchange 路由能力强 | 主要依赖 topic / partition |
| 消费模式 | 队列消息被消费后删除 | 消息按保留策略保存,消费者记录 offset |
| 吞吐量 | 中等,适合业务消息 | 很高,适合海量事件流 |
| 延迟 | 较低 | 通常也低,但更偏吞吐 |
| 顺序性 | 单队列单消费者有序 | 单 partition 有序 |
| 重试 / 死信 | 原生支持较方便 | 需要业务设计 retry topic / DLQ topic |
| 消息回溯 | 不方便,消费后删除 | 天然支持按 offset 回放 |
| 常见场景 | 订单、通知、任务队列、业务异步 | 日志、埋点、流计算、数据管道 |
面试回答:为什么选择 RabbitMQ 而不是 Kafka
可以这样回答:
如果业务主要是订单、通知、点赞、评论这类业务事件,更关注灵活路由、消费确认、失败重试、死信队列和较低复杂度,RabbitMQ 更合适。RabbitMQ 的 Exchange、Routing Key、ACK、DLX 对业务异步任务很友好。
Kafka 更适合海量日志、用户行为流、实时计算和数据管道,它的优势是高吞吐、分区并行、消息可回放。但如果只是普通业务消息,Kafka 的重试和死信往往需要自己设计 retry topic 和 DLQ topic,业务复杂度更高。
常见面试题
为什么要用消息队列
主要是异步、解耦、削峰。
异步可以把非核心链路放到后台执行,降低接口响应时间。
解耦可以让生产者只发布事件,不直接依赖下游系统。
削峰可以把瞬时流量缓存在队列中,消费者按照自身能力慢慢处理,保护数据库和下游服务。
但引入 MQ 后也会带来消息丢失、重复消费、积压、一致性和运维复杂度等问题。
如何保证消息不丢失
分三段回答:
生产者阶段:使用 Publisher Confirm 确认消息到达 Broker,使用 mandatory 处理无法路由的消息,发送失败要重试或落库。强一致场景可以使用 Outbox。
Broker 阶段:Exchange、Queue 设置 durable,消息设置 persistent,并使用高可用队列。
消费者阶段:关闭自动 ACK,业务处理成功后再手动 ACK,处理失败则重试或进入死信队列。
同时要有监控、告警、补偿机制,因为工程上很难只靠 MQ 配置做到绝对不丢。
如何避免消息重复消费
不要依赖 MQ 保证 exactly once,而是在业务层做幂等。
常见方案:
- 数据库唯一索引
- event_id 去重表
- Redis SETNX
- 状态机判断
- 乐观锁版本号
- 利用业务天然幂等性
例如一条消息携带 event_id,消费时先插入去重表。如果插入成功,继续执行业务;如果 duplicate,说明已经处理过,直接 ACK。
消费失败怎么办
先区分失败类型。
如果是临时失败,例如数据库超时、Redis 抖动,可以延迟重试。
如果是永久失败,例如消息格式错误、参数非法,重试没有意义,可以直接丢弃或进入死信队列。
不能无限 Nack(requeue=true),否则毒消息会反复消费,拖垮队列。
更合理的做法是:记录 retry_count,失败后延迟重试,超过最大次数进入死信队列,并配合告警和人工补偿。
什么是死信队列
死信队列用于保存无法正常消费的消息。
消息在以下情况下会变成死信:
- Nack / Reject 且 requeue=false
- 消息 TTL 过期
- 队列超过最大长度
- quorum queue 超过 delivery-limit
死信队列的作用不是自动解决问题,而是保留失败现场,方便后续排查和补偿。
为什么不能直接 requeue=true
因为直接 requeue 会让消息立刻回到原队列,可能马上再次被消费。
如果失败原因一直存在,这条消息会无限重试,导致 CPU 空转、日志暴涨、队列阻塞。
生产中一般要限制重试次数,超过次数后进入死信队列。
消息积压怎么办
先定位原因:
- 生产速度是否突然变高
- 消费者是否正常在线
- 消费逻辑是否变慢
- 数据库或下游接口是否变慢
- 是否有毒消息反复重试
- ready 和 unacked 数量是否异常
处理方式:
- 增加消费者实例
- 提高消费并发
- 优化消费逻辑和慢 SQL
- 批量处理
- 对生产者限流
- 拆分队列
- 失败消息进入死信队列
如何保证消息顺序
RabbitMQ 只有在单队列、单消费者、无重试乱序的情况下才能比较好地保证顺序。
如果多个消费者并发消费,就无法保证处理完成顺序。
强顺序业务可以按业务 key 路由到同一个队列,或者消费端按 key 串行处理,同时用状态机兜底,避免乱序消息导致错误状态变更。
RabbitMQ 的 Exchange 和 Queue 有什么区别
Exchange 负责路由消息,Queue 负责保存消息。
生产者通常把消息发送给 Exchange,Exchange 根据 routing key 和 binding key 把消息路由到一个或多个 Queue。
消费者从 Queue 中消费消息。
RabbitMQ 的 Channel 是什么
Channel 是建立在 TCP Connection 上的轻量级逻辑通道。
创建 TCP 连接成本比较高,所以 RabbitMQ 通过 Channel 在一个 Connection 上复用多个逻辑通道。
实际开发中一般一个进程复用少量 Connection,不同 worker 或并发消费者使用独立 Channel。
RabbitMQ 中一条消息会被多个消费者消费吗
要分情况。
如果是同一个队列上的多个消费者,它们是竞争消费关系,一条消息正常只会被其中一个消费者消费。
如果一个 Exchange 绑定了多个 Queue,那么消息可能被路由到多个 Queue,每个 Queue 都会保存一份消息,不同 Queue 的消费者都可以消费到。
所以:
1 | 同一个 Queue 多消费者:竞争消费 |
RabbitMQ 如何实现延迟消息
常见方式有两种:
- TTL + DLX
- delayed message exchange 插件
TTL + DLX 的思路是:消息先进入延迟队列,过期后变成死信,再通过 DLX 路由到真实消费队列。
如果延迟时间种类很多,TTL 方案可能有队头阻塞问题,可以考虑 delayed message exchange 插件。
Kafka 为什么吞吐高
Kafka 吞吐高主要因为:
- 顺序写磁盘
- 利用 page cache
- 批量发送和批量消费
- 零拷贝
- 分区并行
- 消息压缩
Kafka 不是因为“不写磁盘”才快,而是因为它把随机写变成了顺序追加写,并充分利用了操作系统缓存。
Kafka 如何保证消息顺序
Kafka 只保证同一个 partition 内有序。
如果要保证同一个业务实体的消息有序,需要让相同业务 key 的消息进入同一个 partition。
例如订单消息使用 order_id 作为 key:
1 | partition = hash(order_id) % partition_count |
这样同一个订单的消息会进入同一个 partition,从而保证该订单维度的顺序。
Kafka 和 RabbitMQ 怎么选
如果是业务异步任务,例如订单通知、用户消息、后台任务、消费失败重试,更适合 RabbitMQ。
如果是海量日志、埋点数据、实时计算、数据同步,更适合 Kafka。
简单说:
- RabbitMQ 更像任务分发系统
- Kafka 更像可回放的分布式事件日志
MQ 能保证 exactly once 吗
一般不要这么认为。
RabbitMQ 常见语义是 at least once,也就是至少投递一次,可能重复。
Kafka 有事务和 exactly once semantics,但主要用于 Kafka 内部的消费、处理、再生产链路。如果处理过程中还要写 MySQL、调用外部接口,仍然需要业务幂等。
所以工程实践中一般回答:
MQ 层尽量保证消息不丢,业务层负责幂等,最终通过重试、死信、补偿和监控保证最终一致性。
总结
消息队列的核心价值是异步、解耦、削峰。
但只会使用 MQ 不够,面试中更关注 MQ 带来的复杂问题:
- 消息如何不丢
- 消息如何不重复造成错误
- 消费失败如何重试
- 死信队列如何处理
- 消息积压如何排查
- 顺序性如何保证
- 本地事务和 MQ 投递如何一致
- RabbitMQ 和 Kafka 如何选型
RabbitMQ 更适合业务事件和任务队列,重点掌握 Exchange、Queue、Binding、Routing Key、Channel、ACK、QoS、DLX、TTL、Confirm 和幂等消费。
Kafka 更适合大吞吐事件流,重点掌握 Topic、Partition、Offset、Consumer Group、Replica、ISR、高吞吐原理和分区顺序性。
说些什么吧!