几乎每个后端都会在简历里写一句「使用 MQ 实现异步解耦、削峰填谷」,写的时候轻描淡写,面试的时候就会被“三连”问懵:消息会不会丢?会不会重复?能不能保证顺序?这三个问题是消息队列的“铁三角”,再往下追就是事务消息、积压、延迟消息、选型,最后落到一句:“你们线上出过 MQ 事故吗?”
我自己的经验是:MQ 这一章别按产品背,按“一条消息的一生”来背——它从生产者出发,经过 Broker 存储与复制,到消费者处理并确认,每一段都可能丢、可能重、可能乱。把这三段各自的故障点和对策讲清楚,选型与产品原理不过是在给这条主线加注脚。
本文根据公开面经与官方文档整理改编,不是我本人某场面试的实录。按难度分三层:🌱 初级(0–3 年)是什么、怎么用;🌿 中级(3–5 年)为什么、怎么选、怎么排查;🌳 高级(5 年+)架构取舍、线上治理、量化复盘。每层给「标准回答要点 / 追问链 / 加分项」。关于版本,我在文中标注了 Kafka 与 RocketMQ 4.x/5.x 的差异,凡是我没有在官方文档里核对过的默认值,都写明“以你使用版本的文档为准”。
一、知识地图:一条消息的一生
下图把可靠性三段、以及每一段常见的“丢/重/乱”风险画在一起,后面所有专题都能挂在这张图上。
二、选型:Kafka、RocketMQ、RabbitMQ 到底怎么选
🌱 初级:先说清 MQ 解决什么问题
标准回答要点:消息队列的三大作用——异步(下单后发短信、加积分不必同步等)、解耦(订单服务不用直接依赖库存、营销、通知服务,只发事件)、削峰(秒杀时把瞬时流量缓冲到队列,下游按自己的能力消费)。代价也要会说:系统复杂度上升(要处理丢、重、乱、积压)、一致性变弱(从强一致变成最终一致)、多了一个需要高可用的组件。
基本概念:Producer / Consumer / Broker / Topic;Kafka 的 Partition 与 Consumer Group;RocketMQ 的 MessageQueue(官方概念文档中称为 Topic 下最小的存储单元)与 Consumer Group;RabbitMQ 的 Exchange / Binding / Queue。
🌿 中级:三款产品的定位差异
| 维度 | Kafka | RocketMQ | RabbitMQ |
|---|---|---|---|
| 定位 | 分布式日志 / 事件流平台,大数据、日志、埋点管道 | 业务消息中间件,电商、金融类事务型场景 | 企业消息总线,复杂路由、低延迟小规模 |
| 吞吐 | 极高(单机可到百万级消息/秒量级,取决于消息大小与配置) | 高(十万级/秒常见) | 中(万级到十万级,取决于队列类型与持久化) |
| 顺序 | 分区内有序 | 队列内有序,官方提供 FIFO 消息类型 | 单队列单消费者有序,多消费者会乱 |
| 事务消息 | 有事务(偏 Kafka 内部读-处理-写的原子性) | 有事务消息(半消息 + 回查),面向“本地事务 + 发消息” | 无原生分布式事务消息,用 confirm + 本地消息表 |
| 延迟消息 | 无原生,需要自己实现(多级 topic / 时间轮) | 原生支持(4.x 固定级别,5.x 可指定时间,上限以版本文档为准) | TTL + 死信交换机,或延迟消息插件 |
| 消息回溯 | 按 offset 或时间重新消费,天然支持 | 按时间回溯消费位点 | 消费即删除,回溯要借助 Streams |
| 路由/过滤 | 按 topic/分区,过滤能力弱 | Tag、SQL92 属性过滤 | direct / fanout / topic / headers 交换机,路由最灵活 |
| Topic 数量 | 分区多了会影响性能与恢复时间 | 大量 Topic 表现更好(所有 Topic 共用一个 CommitLog) | 队列数量大也要注意资源 |
| 运维 | 4.0 起去掉 ZooKeeper,使用 KRaft | NameServer + Broker,5.x 引入 Controller 模式 | Erlang 环境,集群与镜像/仲裁队列 |
我引用 JavaGuide 那篇 2026 年更新的面试总结里的几个观点,与我的经验吻合:Kafka 强调顺序写、页缓存、零拷贝带来的吞吐,天然适合事件流和日志回放;RocketMQ 面向业务消息,事务消息、延迟消息、精细顺序保障是它的卖点,存储采用 CommitLog + ConsumeQueue;RabbitMQ 靠 Exchange 做灵活路由,并且现代版本推荐用 Quorum Queue 与 Streams 取代旧的经典队列。
怎么答选型(模板):先问场景,再谈产品。“我们做的是订单支付这类业务消息,要事务消息与延迟消息,量级中等,所以选 RocketMQ;行为日志、埋点、CDC 这类大吞吐流式数据用 Kafka,并且下游有 Flink/Spark 消费;后台任务、邮件通知这类低吞吐、复杂路由可以用 RabbitMQ。”并补一句:选型还要看团队运维能力和云厂商托管服务,不要为了性能数字选一个没人会运维的组件。
🌳 高级:选型背后的取舍与迁移
- 吞吐 vs 延迟 vs 一致性:Kafka 靠批量与顺序 IO 换吞吐,单条消息端到端延迟因批量(
linger.ms)略高;RabbitMQ 单条延迟低但吞吐上限低;RocketMQ 居中且业务特性丰富。没有最优,只有匹配。 - 多租户与治理:业务线多时,要有统一的 Topic 申请、命名、配额(生产/消费限流)、ACL、消息大小上限(比如 1MB)、保留时间标准;否则一个业务的大消息或死循环重试就能拖垮共享集群。
- 迁移与双写:从 A 迁到 B,通常做双写 + 双消费校验 + 灰度切消费者 + 保留回退窗口;消息 ID、顺序性、事务语义在两个产品之间不等价,要逐项映射。
- “为什么不直接用数据库表当队列”:量小时可以(本地消息表就是这个思路),但吞吐、消费者扩展、堆积能力与运维成熟度不如专用 MQ;这个问题的成熟回答是“规模小、强一致要求高时,我确实会用表 + 轮询,规模上来再演进”。
三、消息可靠性:三段保证
🌱 初级:消息会不会丢?在哪丢?
标准回答要点:会,丢的位置有三个:生产者 → Broker、Broker 自身存储、Broker → 消费者。所以可靠性要三段一起做,缺一段都是白做。
- 生产者:不要“发了就不管”。用同步发送或带回调的异步发送,拿到 Broker 的确认再认为成功,失败要重试;仍失败要落库(本地消息表)后台补偿。
- Broker:消息要落盘并复制到多个副本才算安全。
- 消费者:先处理业务,成功后再确认(ack / 提交位点);不要自动确认后异步处理,也不要在 catch 里吞掉异常。
三种投递语义:至多一次(可能丢,不重复)、至少一次(不丢,可能重复)、恰好一次(不丢不重,代价最大,通常是“至少一次 + 幂等”的等效实现)。实际业务普遍选至少一次 + 消费端幂等。
🌿 中级:每个产品的关键参数
Kafka:
- 生产端:
acks=all(等待 ISR 中所有副本确认;注意是 ISR 而不是所有副本);retries足够大;开启幂等生产者enable.idempotence=true(Kafka 3.0 起客户端默认开启,为每个生产者分配 PID、每个分区维护序列号,Broker 据此去重);发送用带回调的send(record, callback),不要只 fire-and-forget。 - Broker 端:
replication.factor=3;min.insync.replicas=2(与 acks=all 配合,ISR 少于 2 时写入直接失败而不是降级丢数据);unclean.leader.election.enable=false(禁止让不在 ISR 里的副本当 leader,否则可能丢已确认的数据);刷盘靠副本与页缓存,一般不强制 fsync(flush.messages等保持默认),可靠性由多副本承担。 - 消费端:
enable.auto.commit=false,业务处理成功后手动提交(commitSync或批量提交);提交位点的时机决定“丢”还是“重”:先提交后处理可能丢,先处理后提交可能重。
JavaGuide 的总结里有一个很值得记的点:“acks=all 等的是 ISR 确认,不是所有副本”,所以必须和 min.insync.replicas 配套使用,否则 ISR 只剩一个 leader 时,acks=all 也退化成 acks=1。
RocketMQ:
- 生产端:使用同步发送(
send)并检查SendStatus;异步发送必须在回调里处理失败;重试次数、超时按业务配置。 - Broker 端:
flushDiskType=SYNC_FLUSH(同步刷盘,收到消息刷盘后才返回,最可靠也更慢)与默认的异步刷盘的取舍;主从之间SYNC_MASTER(同步复制)与ASYNC_MASTER;5.x 与 DLedger / Controller 模式提供基于 Raft 的自动选主。 - 消费端:并发消费返回
CONSUME_SUCCESS才算成功,返回RECONSUME_LATER或抛异常会进入重试队列;重试到上限后进入死信队列,需要人工或补偿程序处理。
RabbitMQ:
- 生产端:开启 publisher confirm,Broker 落地(持久化队列的话是落盘后)才确认;无法路由到队列的消息用
mandatory+ return 回调发现。 - Broker 端:交换机、队列、消息都持久化;关键业务用仲裁队列(Quorum Queue),基于 Raft 复制,官方文档明确它适合“不能丢消息”的长生命周期队列(如订单);它还带有投递次数限制处理毒药消息(
x-delivery-count,默认上限 20 次,超过后丢弃或死信),并可配置 at-least-once 死信策略。 - 消费端:手动 ack,业务成功再 ack;处理失败 nack 时谨慎设置 requeue,无限重投会形成“毒药消息死循环”;合理设置 prefetch(QoS)避免单个消费者囤积大量未确认消息。
追问链:
- “如果 Broker 已经存了,但确认包丢了,生产者以为失败重发了怎么办?”——这就是重复的来源之一,所以要靠幂等生产者(Kafka)或消费端幂等兜底。
- “同步刷盘一定更好吗?”——不一定。同步刷盘每条消息(或每批)都等 fsync,吞吐显著下降;多副本同步复制往往能提供不弱于单机刷盘的可靠性,所以很多场景选“异步刷盘 + 同步复制”,具体看丢消息容忍度与吞吐要求。
- “消费者宕机,消息会丢吗?”——不会,因为没 ack(没提交位点),重启或重平衡后会由其它消费者从上次提交位点继续,代价是可能重复。
🌳 高级:端到端“不丢”的工程闭环
- 发送侧的终极兜底:本地消息表(事务外发,Outbox)。业务数据与“待发送消息记录”在同一个本地事务里写入;后台任务(或 CDC 订阅表的 binlog)扫描待发送记录并投递,收到 Broker 确认后标记已发送,失败自动重试。这样即使 MQ 整体不可用,消息也不会丢,只是延迟。这比“先提交事务再发消息”(提交后宕机会丢)和“先发消息再提交事务”(事务回滚会产生幽灵消息)都可靠。
- 对账兜底:再可靠的链路也应该有离线对账——每天按业务单据核对“订单已支付”与“下游已收到”,发现缺失补发;把它当作“最后一道保险”,并把对账差异率作为监控指标。
- 消息可追踪:每条消息带业务 key 与 traceId,Broker 查询(RocketMQ 支持按 Key 查询,依赖 IndexFile)、日志平台可联查,出问题时能回答“这条消息在哪一段消失了”。
- SLA 量化:给出“99.99% 消息 5 秒内到达、对账差异率低于十万分之一”这类目标,并说明用哪些指标(生产成功率、端到端延迟分位、消费 lag、死信数量)来衡量。
加分项:能说清 Kafka 的“恰好一次”(幂等生产者 + 事务 + isolation.level=read_committed)只保证 Kafka 内部“读-处理-写”闭环,一旦消费后写外部数据库或调外部接口,仍要靠业务幂等;这是官方语义边界,不要在面试里说成“Kafka 能做到端到端恰好一次”。
四、重复消费与幂等
🌱 初级:为什么会重复,怎么处理
标准回答要点:重复的来源有:①生产者重试(确认丢失);②消费者处理成功但确认失败或宕机,重新投递;③重平衡(Rebalance)导致位点回退;④人工回溯位点或重放。所以“至少一次”天然会重复,处理重复的责任在消费端——让同一条消息处理一次和处理多次结果一致,这就是幂等。
常见幂等手段:
- 唯一索引 / 去重表:以业务唯一键(订单号、支付流水号)建唯一索引,重复插入报主键冲突就忽略。最稳,依赖数据库。
- 状态机:订单状态只允许
待支付 → 已支付,用update ... set status=2 where id=? and status=1,影响行数为 0 就说明已处理。 - Redis 去重:
SET msgKey 1 NX EX 86400,成功才处理。注意它是“先占位再处理”,占位后处理失败要清除占位,否则消息重试会被误判为已处理;要处理“处理中”状态。 - 天然幂等操作:
set x = 5是幂等的,x = x + 5不是,尽量把累加改成基于版本的覆盖。
🌿 中级:幂等 key 怎么选,坑在哪
- 不要只依赖 MQ 的 messageId:生产者重发同一业务事件时 messageId 会变化(重试可能生成新的消息),JavaGuide 的总结也强调 messageId 只在传输层有用,真正的幂等要用业务唯一键(订单号 + 事件类型)。
- 幂等要和业务写入在同一个本地事务里:“先查去重表没有,再处理,再写去重表”是三步,并发下有竞态;正确做法是去重表插入 + 业务更新放在同一事务,靠唯一索引挡住并发。
- 去重记录的保留时间:重复窗口有多长,去重记录就要保留多久(比如覆盖消息最大保留期与重试窗口),过短会放过重复,过长占存储;可以分层:Redis 存近期、DB 唯一键做最终裁判。
- 幂等不等于过滤旧消息:如果消息乱序,旧状态的消息晚到,幂等 key 没冲突却会把新状态覆盖回旧状态,需要版本号 / 时间戳比较(只接受版本更大的更新)。
- 幂等的边界:涉及外部系统(发短信、调支付渠道)时,要把幂等键透传给下游(如渠道的商户订单号),依赖下游的幂等接口;否则再怎么去重也拦不住调用两次。
// 消费端幂等示例:去重表 + 业务更新同一事务(示意)
@Transactional(rollbackFor = Exception.class)
public void onPaid(PayEvent e) {
try {
// uk_biz_key(biz_key) 唯一索引:订单号 + 事件类型
consumeLogMapper.insert(new ConsumeLog(e.getOrderNo() + ":PAID"));
} catch (DuplicateKeyException dup) {
return; // 已处理过,直接确认消息
}
// 状态机保护:只允许待支付 → 已支付
int rows = orderMapper.markPaid(e.getOrderNo()); // update ... where status = 1
if (rows == 0) {
log.warn("订单状态不符,忽略 {}", e.getOrderNo());
}
}🌳 高级:幂等的体系化
- 在框架层统一做:把幂等抽成消费模板或注解(业务 key 表达式、去重存储、过期时间、失败清理),避免每个团队自己写、写出各种竞态。
- “处理中”的第三态:长耗时处理的消息,Redis 占位过期早于处理完成会导致另一个消费者重复进入;用“处理中(带心跳续期)/ 已完成”两态,并对“处理中”的消息延迟重试而不是直接丢弃。
- 幂等与重试配合:重试策略(指数退避、最大次数、死信)必须假设“每次重试都可能是重复”;死信里的消息人工重放同样要走幂等。
- 用数据说话:统计重复率(去重命中数 / 消费数)。重复率突然升高往往是重平衡频繁、消费超时被重投或 Broker 抖动的信号,是很好的告警指标。
五、顺序消息
🌱 初级:什么是顺序消息,怎么保证
标准回答要点:业务上“同一个订单的创建、支付、发货必须按顺序处理”,并不要求全局有序,只要求同一业务 key 有序。做法:发送时按业务 key 选择固定的分区(队列),Kafka 用 key 的哈希决定分区,同一分区内消息有序;RocketMQ 用 MessageQueueSelector 按订单号取模选择队列。消费时,一个分区/队列同一时刻只能由一个消费者线程串行处理。
全局有序只能用单分区 + 单消费者,吞吐降到单点上限,所以能不做就不做。
🌿 中级:顺序被破坏的五个位置
- 生产者重试导致乱序:Kafka 里消息 1 发送失败、消息 2 成功、消息 1 重试成功,顺序变成 2、1。解法:开启幂等生产者(序列号保证),此时
max.in.flight.requests.per.connection只要不超过 5 仍能保证分区内有序;不开幂等的话要把它设为 1(牺牲吞吐)。 - 分区数变化:扩分区后
hash(key) % 分区数变了,同一个 key 会漂移到新分区,新老分区的消息消费进度不同,顺序会被打破。要么一开始预留足够分区,要么扩容时有迁移与停写窗口。 - 消费者多线程并发处理:拿到一个分区的消息后丢进线程池并行处理,顺序就乱了。解法:按业务 key 哈希到固定的内部单线程队列(“分区内再分片串行”),保持同 key 串行、不同 key 并行。
- 消费失败重试:RocketMQ 顺序消费(
MessageListenerOrderly)失败时会阻塞该队列并重试(默认无限次,需要设置上限或告警),否则一条毒药消息会堵死整个队列,这是顺序性的代价;并发消费失败则进入重试队列,天然打乱顺序。 - Rebalance:重平衡时分区归属变化,处理中的消息可能被新消费者重复消费,要配合幂等。RocketMQ 的顺序消费者会在 Broker 端对队列加锁,保证同一时间只有一个消费者处理某个队列(但锁在重平衡瞬间仍存在短暂窗口,要有幂等兜底)。
RabbitMQ:单队列 + 单消费者可保证有序;多个消费者并发处理会乱;消息重新入队(requeue)会打乱顺序;如需分区顺序,需要自己用多个队列 + 一致性哈希交换机(Consistent Hash Exchange 插件)把同 key 路由到同一队列。
追问链:“顺序消息 + 高并发怎么兼顾?”——同 key 串行、不同 key 并行;分区数取得足够大(如 32、64);每个分区消费者处理要快,避免热点 key 把某个分区堵死。“热点 key(一个超级大商家订单)导致某分区积压怎么办?”——这就是顺序性的代价,可以对热点 key 做二级拆分(例如“商家 id + 订单号哈希后缀”),只保证更细粒度的顺序,前提是业务允许。
🌳 高级:顺序性与可用性的取舍
- 顺序消息本质是用可用性和吞吐换局部顺序:一个分区不可用,它上面所有 key 的消息都暂停;一条毒药消息会阻塞后面所有消息。设计上要有毒药消息隔离:重试 N 次后转入死信并放行后续消息,同时通过版本号让业务能容忍缺一条(例如状态机下一条消息带全量状态)。
- 能不用顺序就不用:更好的设计是让消息携带全量状态与版本号(“订单当前状态 = 已发货,版本 7”),消费者用版本比较丢弃旧消息,容忍乱序,吞吐和可用性都更好。面试里说出“我会先想办法让消费端容忍乱序,实在不行才上顺序消息”,是高级岗位很加分的表述。
六、事务消息
🌿 中级:RocketMQ 事务消息怎么工作
要解决的问题:本地事务(写订单)与发消息(通知库存)如何做到“要么都成功要么都失败”。先提交事务再发消息,提交后宕机消息丢;先发消息再提交事务,事务回滚会有幽灵消息。
RocketMQ 事务消息流程(对照官方“事务消息”文档):
- 生产者发送半消息(Half Message)到 Broker,Broker 存下来,但对消费者不可见;
- Broker 返回发送成功,生产者执行本地事务;
- 本地事务结束后,生产者向 Broker 发送二次确认:Commit(消息变为可消费)或 Rollback(Broker 丢弃半消息);
- 如果 Broker 一直没收到二次确认(网络问题、生产者宕机重启),Broker 会主动发起回查(check-back),询问生产者集群中的任一实例这条半消息对应本地事务的状态,生产者根据本地数据(如订单是否存在)返回 Commit / Rollback / Unknown;
- 超过最大回查次数仍无法确定,Broker 会按 Rollback 处理(官方文档:无法确认时超过一定时间后自动回滚;具体回查间隔与最大次数请以所用版本文档和配置为准)。
// RocketMQ 事务消息:本地事务与回查逻辑(示意,API 以所用版本为准)
public class OrderTxListener implements TransactionListener {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
orderService.createOrder((OrderDTO) arg); // 本地事务:写订单
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 回查:根据业务 key 查订单是否已落库,必须是幂等只读查询
boolean exists = orderService.existsByOrderNo(msg.getKeys());
return exists ? LocalTransactionState.COMMIT_MESSAGE
: LocalTransactionState.ROLLBACK_MESSAGE;
}
}要牢记的边界(官方文档与面经共识):
- 事务消息保证的是最终一致,不是强一致;它只保证“本地事务与消息发出”的原子性,不保证消费者一定处理成功,消费端仍要重试与幂等;
- 5.x 中事务消息要求 Topic 的消息类型为 Transaction(官方概念文档:一个 Topic 只允许一种消息类型);
- 回查逻辑必须可重入、只读、快速,并且本地库里要有能判断事务结果的依据(订单表本身或事务日志表)。
Kafka 的事务与之不同:它解决的是“消费 topic A → 处理 → 写 topic B + 提交位点”在 Kafka 内的原子性(transactional.id、initTransactions / beginTransaction / commitTransaction,消费者用 isolation.level=read_committed 只读已提交消息)。它不是“数据库本地事务 + 发消息”的分布式事务。面试时要主动区分这两种“事务”,很多人栽在这里。
RabbitMQ:AMQP 有 tx 事务模式(性能很差,很少用);实践里用发布确认 + 本地消息表。
🌳 高级:事务消息 vs 本地消息表 vs Seata
| 方案 | 优点 | 缺点 / 适用 |
|---|---|---|
| RocketMQ 事务消息 | 业务侵入小,回查由 Broker 驱动,无需自建扫表 | 绑定 RocketMQ;要写回查接口;仅最终一致 |
| 本地消息表(Outbox) | 与 MQ 无关,通用;可用 CDC 订阅表变更 | 要自己扫表 / 订阅,多一张表与运维;有轻微延迟 |
| Seata AT / TCC | 接近强一致体验、事务语义完整 | 性能损耗、全局锁与运维复杂度,TCC 要写三个接口 |
成熟的回答是:大多数场景选“本地事务 + 可靠消息 + 幂等 + 补偿”,用事务消息或 Outbox 二选一;只有资金类强一致场景才考虑 Seata,并评估性能。复盘时要能给量化:“接入事务消息后,下单接口不再依赖库存服务的同步调用,P99 从 380ms 降到 90ms,消息丢失率通过对账为 0;代价是新增回查接口并把库存扣减改为异步,库存展示有秒级延迟。”
七、消息积压、延迟消息与死信
🌱 初级 + 🌿 中级:积压怎么处理
积压(lag)指消费速度低于生产速度,未消费消息越堆越多。排查思路要先定位原因再动手(JavaGuide 的总结也强调先区分原因):
- 是不是生产突增:秒杀、批量导入、上游重放。是流量问题,需要扩容或限流;
- 是不是消费变慢:单条处理耗时变长——慢 SQL、下游接口超时、锁竞争、Full GC、日志同步刷盘;看消费者线程栈与处理耗时监控;
- 是不是消费者数量/分区不够:Kafka 一个分区同一消费组内只能被一个消费者消费,消费者数量超过分区数是浪费的,加机器没用;
- 是不是被毒药消息卡住 / 频繁重平衡:顺序消费被阻塞;消费超时(Kafka 的
max.poll.interval.ms)导致被踢出消费组、反复重平衡,看似在消费,实则空转。
处理手段(从快到慢):
- 先止血:限流或暂停低优先级生产者;如果积压消息价值低(日志、埋点),可以直接跳过(重置位点到最新);
- 提升单消费者速度:批量消费、批量入库(把逐条 insert 改为批量)、线程池并行(无顺序要求时)、异步化下游、缓存热点查询;
- 横向扩容:加消费者实例;Kafka 需要先增加分区(且只对新数据生效,且会影响 key 分布);RocketMQ 增加队列数;
- 紧急搬运:对巨量积压,可以临时建一个分区数更多的新 Topic,写一个“搬运程序”把积压消息不经处理快速转发到新 Topic,再启动更多消费者并行消费,处理完后恢复原架构;
- 写事后:积压期间消息可能超过保留时间被删除(Kafka 的 retention、RabbitMQ 的 TTL),要提前评估,必要时延长保留或补数据。
🌳 高级:积压的预防与量化
- 容量模型:峰值生产 TPS × 单消息处理耗时 ÷ 单消费者并发度 = 需要的消费者并发数,再乘以 1.5–2 的冗余。例如峰值 2 万条/秒、单条处理 20ms、每消费者 8 线程并发,则理论需要 2 万 × 0.02 ÷ 8 = 50 个消费者,并保证分区数不少于消费者数。
- 监控与告警:告警指标用“lag 的增长趋势与预计清空时间”,而不是绝对值;同时看消费 TPS、单条耗时分位数、重试与死信数量、重平衡次数。lag 绝对值大但在下降是没事的,lag 小但持续上升要提前处理。
- 隔离与优先级:核心与非核心业务用不同 Topic / 消费组隔离,避免非核心积压拖垮核心;延时不敏感的批量任务走独立集群或低峰消费。
- 压测与演练:定期做“消费者全停 10 分钟后恢复”的演练,验证积压后能否在承诺时间内追平,以及追平期间下游(数据库)能否扛住突然放开的消费速度——积压恢复时的“洪峰”常常把数据库打挂。
🌿 中级:延迟消息怎么做
| 产品 | 做法 | 注意 |
|---|---|---|
| RocketMQ | 4.x 支持固定的延迟级别(1s、5s、10s、30s、1m…最长 2h);5.x 支持指定投递时间的定时/延迟消息(有最大延迟上限,以版本文档为准);官方消息类型里有独立的 DELAY 类型 | Topic 类型要匹配;不要指望精确到毫秒 |
| RabbitMQ | ①消息或队列设置 TTL,过期后转入死信交换机,再由消费者监听死信队列;②延迟消息插件 rabbitmq-delayed-message-exchange | TTL 方案有队头阻塞:队列只检查队头消息是否过期,前面 TTL 长的消息会挡住后面 TTL 短的;JavaGuide 的总结也提到这个坑 |
| Kafka | 无原生支持。常见做法:多个不同延迟时长的 Topic + 转发服务按时间转发;或消息里带执行时间,消费者暂停分区(pause)等待 | 复杂度高,业务重延迟消息优先选 RocketMQ |
| 通用方案 | Redis ZSet(分数为执行时间戳)+ 轮询;时间轮;数据库定时任务扫描;XXL-Job / 延迟任务框架 | 关键场景(如“30 分钟未支付关单”)不要只依赖延迟消息,加一个兜底的定时扫描 |
追问:“订单 30 分钟未支付自动关闭,怎么设计?”——下单时发一条延迟 30 分钟的消息,消费者收到后先查订单当前状态,仍是待支付才关闭(消息只是触发器,状态以数据库为准,天然幂等;即使消息早到、晚到、重复到都不出错),同时加一个每分钟的兜底扫描任务处理漏网之鱼。这正是 JavaGuide 那篇总结里说的“无论消息时机如何,消费者都应核对业务状态”。
🌿 中级:重试与死信队列
- RocketMQ:并发消费失败进入
%RETRY%消费组名重试队列,按递增间隔重试,默认最多 16 次(4.x 常见配置,以所用版本为准),超过后进入%DLQ%消费组名死信队列,需要人工处理或写程序重放。顺序消费失败则原地重试阻塞。 - Kafka:没有内置的重试与死信队列,需要业务用“重试 Topic + 死信 Topic”实现(Spring Kafka 提供
DefaultErrorHandler与DeadLetterPublishingRecoverer等封装)。 - RabbitMQ:通过死信交换机(DLX)实现;仲裁队列自带投递次数上限。注意 nack + requeue 会立刻重投,容易形成“热循环”。
- 死信治理:死信要有告警、查看、重放工具;重放前先修复根因;重放走幂等;设置死信保留期限并定期清理。
八、Kafka 为什么吞吐这么高
🌿 中级:六个关键设计
- 顺序写磁盘 + 日志结构:每个分区就是一个只追加的日志,分成多个 segment 文件(默认单个 1GB,可配置),按 offset 命名;顺序写的速度可以接近内存随机写,远好于随机写磁盘。
- 页缓存(Page Cache):Kafka 不自己维护复杂的内存缓存,而是依赖操作系统页缓存:写入先写到页缓存,由 OS 异步刷盘;读取热数据直接命中页缓存,还避免了 JVM 堆内缓存带来的 GC 压力。这也是为什么 Kafka 机器要留足空闲内存给页缓存,堆不宜过大。
- 零拷贝(sendfile):消费者拉取数据时,Broker 用
sendfile把页缓存里的数据直接送到网卡,省掉“内核态 → 用户态 → 内核态”的两次拷贝与上下文切换。注意 TLS 加密开启后无法使用零拷贝,吞吐会下降。 - 批量与压缩:生产者把多条消息攒成 batch(
batch.size、linger.ms),整批压缩(compression.type:lz4 / zstd / snappy / gzip),整批写入、整批传输,网络与磁盘利用率大增;Broker 通常不解压,原样存储,消费者解压。 - 分区并行:Topic 分成多个分区,分布到不同 Broker,生产与消费都可以并行,吞吐随分区数与机器数线性扩展(在合理范围内)。
- 稀疏索引:每个 segment 有
.index(offset 索引)与.timeindex(时间戳索引),并不是每条消息都建索引,而是隔一定字节建一条(稀疏),查找时用二分找到最近的索引项再顺序扫描,索引小、可整体放进内存。
副本机制:每个分区有一个 leader 和若干 follower,follower 从 leader 拉取数据;ISR(In-Sync Replicas)是与 leader 保持同步的副本集合,落后太多(replica.lag.time.max.ms)会被踢出;leader 挂了从 ISR 中选新 leader。高水位(HW)是 ISR 中所有副本都已复制到的位置,消费者只能读到 HW 之前的消息,保证 leader 切换时已读消息不会“消失”。
消费组与重平衡:同一消费组内,一个分区只被一个消费者消费;成员变化或订阅变化触发重平衡。重平衡期间(传统协议)整个组会暂停消费(“stop the world”)。缓解:使用 Cooperative Sticky 分配策略(增量重平衡)、使用静态成员(group.instance.id)避免重启触发重平衡、合理设置 session.timeout.ms 与 max.poll.interval.ms,并减小单次 max.poll.records 保证处理不超时。Kafka 4.0 起还引入了新一代消费组协议(KIP-848),把协调逻辑移到 Broker 端以减少全组暂停,具体是否启用请看你使用的版本文档。
版本背景:Kafka 4.0 去掉了对 ZooKeeper 的依赖,元数据完全由 KRaft 管理;旧版本仍需 ZooKeeper 或处于迁移期,选型与升级时要确认。
追问链:“Kafka 为什么不适合大量 Topic?”——每个分区是独立的文件目录与索引,大量分区带来大量文件句柄、随机 IO、控制器与 leader 选举压力;而 RocketMQ 所有 Topic 混写同一个 CommitLog,Topic 多也不会退化成随机写。“Kafka 会丢消息吗?”——参数配错就会(acks=1、脏选举、自动提交位点),配对了并且用多副本,在“不同时坏掉多数副本”的前提下不丢。
🌳 高级:Kafka 生产参数与容量
- 分区数怎么定:按目标吞吐 ÷ 单分区吞吐估算,同时不小于期望的最大消费者并发数,并留 1.5–2 倍余量;分区不是越多越好(文件句柄、选举时间、内存、端到端延迟都会变差),单 Broker 分区数要有上限治理。
- 磁盘与保留:按“日均写入量 × 保留天数 × 副本数 × 1.2”估算磁盘;用
retention.ms/retention.bytes控制;关键 Topic 保留时间要覆盖“最长故障恢复 + 消费追平”的时间。 - 大消息:默认
message.max.bytes约 1MB,随意调大会拖垮吞吐;大文件应放对象存储,消息里只传引用。 - Exactly Once 的现实:流处理 Kafka → Kafka 用事务;Kafka → 数据库用“幂等写 / 事务内同时保存位点”实现;不要承诺端到端恰好一次,而是承诺“至少一次 + 效果上的恰好一次(幂等)”。
九、RocketMQ 存储模型
🌿 中级:CommitLog + ConsumeQueue + IndexFile
标准回答要点:
- 写路径:所有 Topic 的消息都顺序追加到同一个 CommitLog(只有一个写入流,写入永远是顺序的,Topic 再多也不会退化);随后后台线程异步把消息的位置信息分发到对应 Topic 与 QueueId 的 ConsumeQueue,以及 IndexFile。
- 读路径:消费者按队列位点读 ConsumeQueue 里的定长条目,拿到 CommitLog 偏移量后去读消息体。读消息体是随机读,但热点数据通常在页缓存里。
- 与 Kafka 对比:Kafka 每个分区一个独立日志,写入是“多路顺序写”,分区多了趋近随机写;RocketMQ 是“单路顺序写 + 索引分离”,Topic 数量伸缩性更好,代价是读取多一次间接寻址,且 CommitLog 混合存储让按 Topic 清理与隔离更复杂。
- “推”还是“拉”:RocketMQ 的 Push 消费本质是长轮询的 Pull:消费者向 Broker 发拉取请求,没有消息则 Broker 挂起请求一段时间,有新消息立刻返回,兼顾实时性与消费者的流控。
- 消息类型:官方概念文档列出四类——普通(NORMAL)、顺序(FIFO)、延迟(DELAY)、事务(TRANSACTION),5.0 起每个 Topic 只允许一种消息类型。
- 高可用:主从架构中 Slave 复制 Master 的 CommitLog;传统主从不能自动切换,5.x 提供基于 Raft 的 DLedger / Controller 方案自动选主。
追问链:“RocketMQ 怎么做到消息过滤?”——Tag 过滤:Broker 用 ConsumeQueue 里的 Tag 哈希粗筛,消费者端再比较 Tag 字符串精筛;SQL92 属性过滤在 Broker 端按消息属性计算。“消费位点存在哪?”——集群模式下存在 Broker 端(consumerOffset.json),广播模式存在消费者本地。“为什么会有‘消息重复消费’在 RocketMQ 里更常见?”——消费者扩缩容、重平衡、位点提交是定时异步的(默认约每 5 秒),宕机时最后一段位点没持久化就会重复。
🌳 高级:RocketMQ 落地的几个要点
- 刷盘与复制的组合:核心交易集群常用“异步刷盘 + 同步双写”或直接用 DLedger 多数派复制,既有较好吞吐,又不因单机掉电丢已确认消息;金融级再叠加同步刷盘。要用压测量化:同一硬件上对比三种组合的 TPS 与 P99,把结果写进选型文档。
- 消费者线程模型:并发消费的线程池大小(
consumeThreadMin/Max)、单次批量(consumeMessageBatchMaxSize)、拉取批量(pullBatchSize)是积压治理的第一批旋钮;调之前先看瓶颈在消费逻辑还是拉取。 - 消息治理:Topic 与消费组的命名与申请规范;消息大小限制(避免大消息);Key 必填(便于查询与排障);灰度发布时用 Tag 或独立消费组区分。
- 版本升级:4.x → 5.x 的客户端与 Topic 消息类型校验会变,升级前要评估兼容性并做灰度。
十、RabbitMQ 补充:路由与队列类型
🌱 初级 + 🌿 中级
- 路由模型:生产者把消息发给 Exchange,Exchange 按类型与 Binding 规则路由到 Queue。
direct(routing key 精确匹配)、fanout(广播)、topic(通配符*一个词、#零或多个词)、headers(按头属性)。 - 队列类型:经典队列(Classic)、仲裁队列(Quorum)、Streams。官方文档建议:需要高可用和不丢消息的长期队列用 Quorum;它不适合临时队列、极低延迟要求、非关键数据、超长积压(官方提到数百万级以上应考虑 Streams)以及大规模扇出。旧的“镜像队列”在新版本中已被仲裁队列取代。
- Prefetch:消费者未确认消息的上限,太大导致一个慢消费者囤积消息、其他消费者空闲;太小则吞吐受网络往返限制。
- 流控与内存水位:Broker 内存或磁盘达到阈值会阻塞生产者连接(
connection.blocked),生产者要处理这种通知。 - 与 Kafka 的核心区别:RabbitMQ 是“智能 Broker、简单消费者”(Broker 负责路由与投递状态),消息被确认后删除;Kafka 是“简单 Broker、智能消费者”(消费者自己管位点),消息按保留策略保存,可重复消费。
十一、高频追问速查表
| 问题 | 一句话答案 | 出处 / 延伸 |
|---|---|---|
| MQ 有什么用,有什么代价 | 异步、解耦、削峰;代价是复杂度、最终一致、多一个高可用组件 | 先谈场景再谈产品 |
| 三款 MQ 怎么选 | Kafka 大吞吐事件流;RocketMQ 业务消息与事务/延迟;RabbitMQ 灵活路由小规模 | 还要看团队运维能力 |
| 消息会丢在哪 | 生产者发送、Broker 存储、消费者确认三段,缺一不可 | acks=all + min.insync.replicas、手动 ack |
| acks=all 就一定不丢吗 | 不一定,它等 ISR 而非全部副本,要配 min.insync.replicas 并禁用脏选举 | JavaGuide 总结的关键点 |
| 为什么会重复消费 | 生产者重试、确认丢失、重平衡、位点回退;至少一次必然重复 | 消费端必须幂等 |
| 幂等怎么做 | 业务唯一键 + 唯一索引/去重表与业务同事务,或状态机条件更新 | 不要只靠 messageId |
| 怎么保证顺序 | 同业务 key 到同一分区/队列,消费端同分区串行 | 重试、扩分区、多线程会破坏 |
| Kafka 消息乱序原因 | 重试导致乱序,开幂等生产者可在 in-flight ≤ 5 时保持分区有序 | 否则 in-flight 设 1 |
| RocketMQ 事务消息流程 | 半消息 → 本地事务 → Commit/Rollback → 缺失二次确认则回查 | 只保证最终一致,消费端仍需幂等 |
| Kafka 事务与 RocketMQ 事务区别 | Kafka 保 Kafka 内读-处理-写原子;RocketMQ 保本地事务与发消息原子 | Kafka EOS 不等于端到端 |
| 消息积压怎么办 | 先定位原因;提速(批量/并行)、扩容(消费者≤分区)、搬运到多分区新 Topic、必要时丢弃低价值消息 | 监控 lag 趋势而非绝对值 |
| 延迟消息怎么做 | RocketMQ 原生;RabbitMQ 用 TTL+死信或插件;Kafka 需自建 | TTL 方案有队头阻塞;消费时核对业务状态 |
| Kafka 为什么快 | 顺序写、页缓存、零拷贝、批量压缩、分区并行、稀疏索引 | 开 TLS 无法零拷贝 |
| RocketMQ 存储模型 | 所有 Topic 混写 CommitLog,ConsumeQueue 做定长位置索引,IndexFile 按 key 查询 | Topic 多不退化 |
| Rebalance 有什么问题 | 重平衡期间暂停消费并可能重复,用协作式分配与静态成员缓解 | 控制 max.poll.interval.ms |
| 死信队列干什么 | 承接反复失败的消息,避免阻塞;配告警与重放工具 | Kafka 需自建,RocketMQ 自带 %DLQ% |
一句话备忘:不丢靠“发送确认 + 多副本 + 处理后再 ack”,不重靠“消费端幂等(业务唯一键 + 同事务)”,有序靠“同 key 同分区 + 分区内串行”,事务靠“半消息回查或本地消息表”,积压靠“先定位再提速扩容”;没有银弹,只有“至少一次 + 幂等 + 对账”的组合。
十二、收尾
整理完这一章,我觉得 MQ 面试有个隐藏评分标准:你是不是“悲观主义者”。好的回答者默认网络会断、机器会挂、消息会重、消费者会慢,然后一步步说“所以我会怎么设计”。而普通的回答者,默认一切顺利,然后在追问中被一个个“万一”击穿。所以建议你每答一个机制,都补一句“如果它失败了,兜底是什么”——发送失败有本地消息表,消息重复有幂等,事务不确定有回查,一切都有问题有对账。
最后再啰嗦一句版本的事:Kafka 的 ZooKeeper → KRaft、RocketMQ 4.x → 5.x、RabbitMQ 的镜像队列 → 仲裁队列,都是近几年发生的大变化。面试里提到版本差异,是“真的用过、真的关心”的最好证据,但请务必以你实际使用版本的官方文档为准,本文标注“以版本文档为准”的地方尤其如此。
参考资料
- JavaGuide:2026 最新消息队列面试题总结(可靠性、幂等、顺序、积压与选型):本文可靠性三段、幂等 key、积压排查、事务消息与延迟消息边界的主要公开面经来源。
- Apache RocketMQ 官方文档:核心概念:Topic、MessageQueue、消费组、Tag,以及普通/顺序/延迟/事务四种消息类型与“一个 Topic 一种类型”的说明。
- Apache RocketMQ 官方文档:事务消息:半消息、Commit/Rollback、回查机制与“仅保证最终一致”的官方说明。
- RabbitMQ 官方文档:Quorum Queues:基于 Raft 的仲裁队列适用场景、投递次数上限(默认 20)与 at-least-once 死信策略。
- JavaGuide:MySQL 专题:与本专栏 MySQL 篇配套的公开知识体系,事务消息与本地消息表场景常与其中的事务、日志知识联动。
一起聊聊