消息队列 · 可靠投递与选型
削峰/解耦/异步 · 可靠投递(三处不丢)· 重复消费与幂等 · 顺序消费 · 消息积压 · 事务消息 · 死信/延迟队列 · Kafka vs RabbitMQ vs Pulsar 选型
一句话抓手
消息队列的所有面试题都绕着三个主线:"三处不丢"——生产端 ack、Broker 持久化+副本、消费端手动 ack 后再提交位点;"至少一次"是默认交付语义,所以消费端必须幂等;顺序、不重、不丢、高吞吐这四个目标互相矛盾,选型就是选你愿意牺牲哪个。抓住这三条,MQ 的题基本能自圆其说。
场景问题
打个比方:消息队列就像餐厅后厨的点餐传菜口。前厅服务员(生产者)把订单小票往夹子上一挂就转身接待下一桌,根本不用站着等后厨炒完(解耦 + 异步);哪怕高峰期订单雪片般涌来,也只是先在夹子上排队,后厨(消费者)按自己的节奏一张张取着做(削峰)。类比失效边界:这个夹子的规矩是"小票要等后厨确认做好、亲手撤下才算完"(消费端 ack 后才删消息)——于是就有了"至少一次"的坑:后厨把菜做好了、正要撤票时手一抖崩溃了,这张票还挂在夹子上,重启后会被再取一次,同一道菜可能做两遍。所以 MQ 天生不保证"恰好一次",去重只能靠消费端幂等(同一订单号无论被取几次,都只认一次)来兜底。
后台面试与系统设计高频题,本质考对"交付语义 + 可靠性代价"的理解:
| 题目 | 考点 | 直觉答案往往错在 |
|---|---|---|
| 消息会丢吗,哪里丢 | 生产/Broker/消费三段 | 三处都可能丢,各有独立机制,缺一段就丢 |
| 保证不重复消费 | 交付语义 | MQ 只能做到"至少一次",不重靠消费端幂等 |
| 怎么保证顺序 | 分区 + 单线程消费 | 全局有序几乎不可能,只能做分区内有序 |
| 消息积压了几百万怎么办 | 消费能力 | 加消费者受限于分区数;先扩分区或临时转存 |
| 先写库还是先发 MQ | 分布式一致性 | 两个都可能失败,需事务消息/本地消息表 |
| exactly-once 存在吗 | 端到端语义 | 严格 EOS 要 Broker+消费端事务协同,代价高 |
| 延迟消息怎么实现 | 时间轮/分级 | RabbitMQ 靠 TTL+死信,Kafka 需外部调度 |
实现方案
三大用途:削峰、解耦、异步
- 削峰填谷:突发流量写入队列,消费端按自己节奏消费,把"瞬时高并发"摊平成"持续中等负载"(秒杀下单先入队)
- 系统解耦:生产者不需知道谁消费、有几个消费者;下游增减不影响上游
- 异步化:非核心链路(发短信、加积分、写日志)异步化,缩短主链路 RT
代价:引入 MQ = 多一个必须高可用的组件 + 最终一致性(不再是同步强一致)+ 排查链路变长。
可靠投递:三处不丢
三个环节各自独立会丢,缺一段兜底就漏:① 生产端确认、② Broker 持久化+副本、③ 消费端手动 ack。
消息在三个环节都会丢,要分别兜底
1. 生产端 → Broker
- Kafka:
acks=all(等所有 ISR 副本落盘)+retries重试;acks=1只等 Leader,Leader 挂了未同步的丢;acks=0发了不管,最快最不可靠 - RabbitMQ:开启 publisher confirm(Broker 落盘后回 ack),配合
mandatory处理路由不到队列的消息
2. Broker 自身
- 必须持久化(Kafka 顺序写磁盘 + Page Cache;RabbitMQ 队列/消息都要 durable + persistent)
- 必须多副本(Kafka replication-factor ≥ 3、
min.insync.replicas=2;RabbitMQ 镜像队列 / Quorum 队列)。单副本 Broker 宕机磁盘坏就永久丢
3. Broker → 消费端
- 关闭自动 ack,改手动 ack:业务处理成功后再提交(Kafka commit offset / RabbitMQ basic.ack)
- 顺序必须是"先处理,后提交位点"。反过来先提交再处理,处理途中崩溃 → 消息已确认但没处理完 → 丢
重复消费与幂等(至少一次的必然代价)
主流 MQ 默认 at-least-once:消费成功但 ack 丢失/超时 → Broker 重投 → 重复。不重复不能靠 MQ,只能靠消费端幂等:
- 业务唯一键 + 唯一索引(插入重复直接冲突丢弃)
- 前置查询去重表 / Redis
SETNX(msgId)标记已处理 - 状态机:只允许
待支付 → 已支付单向流转,重复的支付回调被状态判断挡掉
详见 业务幂等性设计。
顺序消费
全局有序需要"单分区 + 单消费者",等于放弃并行,吞吐极低。实际只做分区内有序:
- 把需要保序的消息用同一个 key 路由到同一分区(如同一订单号 hash 到同一 partition)
- 该分区单线程消费(消费端不能对同分区消息并发处理)
顺序的隐藏坑
- Kafka 生产端
max.in.flight.requests > 1且开重试时,重试会打乱顺序 → 要保序需max.in.flight=1或开启幂等 producer(enable.idempotence=true,5 以内也能保序) - 消费端拉一批后用线程池并发处理会打乱顺序 → 保序场景只能同分区串行
消息积压
积压 = 生产速率持续 > 消费速率。处理:
- 加消费者——但 Kafka 消费并行度受分区数上限约束(一个分区同组内只能被一个消费者消费)。分区不够先扩分区
- 临时扩容 + 转存:紧急时用一批消费者只做"搬运",把积压消息快速转到扩容后的新 topic(更多分区),再慢慢消费
- 定位根因:下游 DB 慢、消费逻辑重、有毒消息反复重试卡住(→ 死信队列隔离)
事务消息 / 本地消息表(先写库还是先发 MQ)
"更新本地 DB + 发 MQ 通知"要原子成功,否则出现"库改了 MQ 没发"或"MQ 发了库没改"。方案:
- RocketMQ 事务消息:发半消息(对消费者不可见)→ 执行本地事务 → 提交/回滚半消息;Broker 定时回查本地事务状态兜底
- 本地消息表:本地事务里把"待发消息"写进同库的消息表(与业务同一事务原子提交)→ 独立线程轮询消息表投递 MQ → 投递成功删除。用 DB 事务保证"库改了消息一定记下来"
- Kafka 事务:
transactional.id+ 生产端事务,配合消费端read_committed做 Kafka 内部的 EOS,但跨越到外部 DB 仍需上面两种
详见 分布式事务。
死信队列 / 延迟队列
- 死信队列(DLQ):消费失败超过重试上限的消息转入 DLQ,隔离"有毒消息"避免阻塞正常消费,后续人工/补偿处理
- 延迟队列:RabbitMQ 用
TTL + 死信交换机(消息过期变死信路由到目标队列)或rabbitmq-delayed-message插件;Kafka 原生不支持,需分级 topic 或外部调度触发
为什么这么做
- 为什么默认 at-least-once 而非 exactly-once:网络不可靠,ack 可能丢失,Broker 无法区分"消费者没收到"和"收到了但 ack 丢了",只能重投。严格 EOS 需要 Broker 与消费端两阶段事务协调,吞吐和复杂度代价大,多数业务用"至少一次 + 消费幂等"更划算。
- 为什么顺序只做分区内:全局顺序要串行化所有消息,与"高吞吐靠并行"根本冲突。按 key 分区把"需要保序的一组"收敛到一个分区,既保序又保留分区间并行——用局部有序换整体吞吐。
- 为什么要多副本 + ISR:单副本 Broker 是单点,磁盘损坏即永久丢数据。多副本 +
min.insync.replicas保证"至少 N 个副本确认才算写成功",是持久性与可用性的平衡点。
为什么别的选择不行
- 为什么不用 DB 表当队列:DB 轮询有延迟、高频轮询压垮 DB、行锁竞争严重、无原生的消费组/分区/回溯/削峰能力。低频简单场景可以,高吞吐必须专用 MQ。
- 为什么不
acks=0图快:发了不确认,Leader 崩溃或网络抖动直接丢消息且无感知。只有能容忍丢失的场景(如可重算的指标上报)才用。 - 为什么消费端不用自动 ack:自动 ack 在"收到即确认",业务处理崩溃就丢消息。手动 ack「处理成功后确认」才是可靠消费的地基。
沉淀结论
面试速答清单:
- 三处不丢:生产
acks=all+重试、Broker 持久化+多副本(ISR≥2)、消费手动 ack 且"先处理后提交" - 默认至少一次 → 必然重复 → 消费端幂等(唯一索引/去重表/状态机)兜底
- 顺序只做分区内:同 key 同分区 + 单线程消费;注意 producer 重试与消费端线程池打乱顺序
- 积压:扩分区 → 加消费者(受分区数限制)→ 紧急转存到更多分区的新 topic
- 库+MQ 原子:RocketMQ 事务消息 / 本地消息表 / Kafka 事务
- 死信队列隔离有毒消息;延迟消息 RabbitMQ 靠 TTL+死信,Kafka 需外部方案
Kafka vs RabbitMQ vs Pulsar 选型
| 维度 | Kafka | RabbitMQ | Pulsar |
|---|---|---|---|
| 模型 | 分区日志(拉) | 队列 + 交换机(推) | 分层:计算(Broker)+存储(BookKeeper) |
| 吞吐 | 极高(顺序写+零拷贝) | 中 | 高 |
| 延迟 | 毫秒~十毫秒 | 微秒~毫秒(低延迟强) | 毫秒 |
| 顺序 | 分区内有序 | 队列内有序 | 分区内有序 |
| 路由/灵活性 | 弱(topic+分区) | 强(direct/topic/fanout/header) | 中 |
| 回溯消费 | 强(保留期内任意 offset) | 弱(消费即删) | 强 |
| 典型场景 | 日志/流处理/大数据管道/削峰 | 业务解耦/复杂路由/低延迟任务 | 云原生/多租户/存算分离/长期存储 |
一句话选型:要吞吐和流处理 → Kafka;要灵活路由和低延迟业务消息 → RabbitMQ;要云原生存算分离和多租户 → Pulsar。
记忆口诀
- 三处不丢:生产 acks=all+重试 / Broker 持久化+多副本(ISR≥2) / 消费手动 ack 先处理后提交
- 交付语义:默认至少一次 / 必然重复 / 消费端幂等兜底(唯一索引·去重表·状态机)
- 顺序积压:同 key 同分区+单线程保序 / 积压先扩分区再加消费者 / 紧急转存新 topic
- 选型一句:吞吐流处理选 Kafka / 灵活路由低延迟选 RabbitMQ / 存算分离多租户选 Pulsar
内容来源
关键点整理自 Kafka 官方文档、RabbitMQ 文档、Apache Pulsar 文档与《Designing Data-Intensive Applications》(Martin Kleppmann,第 11 章 Stream Processing)重写为五段式。请以各 MQ 官方文档为准。
自测:合上资料能说清楚吗?
- 一条消息从生产到消费,可能在哪几个环节丢失?每个环节各靠什么机制兜底?
参考答案
三处会丢:生产→Broker 靠 acks=all+重试/publisher confirm;Broker 自身 靠持久化+多副本(ISR≥2);Broker→消费 靠手动 ack 且"先处理后提交位点"。缺一段即漏。
- 既然 MQ 保证不了不重复,那"不重复消费"到底靠什么实现?举两种手段。
参考答案
默认 at-least-once,ack 丢失就重投,不重只能靠消费端幂等:业务唯一键+唯一索引冲突丢弃;或 Redis SETNX/去重表标记 msgId;或状态机单向流转挡掉重复回调。
- 为什么顺序消费只能做"分区内有序"而非全局有序?怎么实现分区内有序?
参考答案
全局有序要串行化所有消息,与高吞吐靠并行根本冲突。做法:同 key hash 到同一分区+该分区单线程消费;注意 producer 重试(max.in.flight>1)和消费端线程池会打乱顺序。
- 消息积压几百万,为什么单纯加消费者不一定管用?正确处理顺序是什么?
参考答案
Kafka 同组内一个分区只能被一个消费者消费,并行度受分区数上限约束。顺序:先扩分区→再加消费者→紧急时用一批消费者只做搬运,转存到更多分区的新 topic 再慢慢消费。
- 对比 Kafka 与 RabbitMQ:模型、吞吐、路由灵活性、回溯能力有何差异?各自典型场景?
参考答案
Kafka 分区日志(拉)、吞吐极高、路由弱、回溯强,适合日志/流处理/削峰;RabbitMQ 队列+交换机(推)、吞吐中、路由灵活(direct/topic/fanout)、低延迟但消费即删无回溯,适合业务解耦/复杂路由。