面试追问地图

主问题必讲关键点下一层追问
为什么用 MQ解耦、异步、削峰引入后的复杂度和适用边界
消息不丢失生产确认、Broker 持久化、消费确认各阶段故障后如何恢复
重复消费至少一次语义、重试幂等 Key、唯一约束、结果表
顺序消息同业务键进入同分区/队列扩容、重试和失败消息如何保持顺序
消息积压生产/消费速率、坏消息、慢依赖扩容是否会破坏顺序、下游能否承受
事务消息半消息、回查、最终一致本地事务成功但回查失败
RocketMQ 存储CommitLog、ConsumeQueue、IndexFilemmap、刷盘策略、与 Kafka 分区存储的区别
Kafka 日志存储segment、稀疏索引、二分查找为什么不用 B+树、相对 offset、compact
Kafka 高性能顺序写、页缓存、批量、零拷贝分区数量取舍、为什么不做读写分离
Kafka 副本同步ISR、HW/LEO、acks=allmin.insync.replicas、unclean 选举、Leader Epoch
Kafka rebalance触发条件、整组停顿参数调优、Cooperative Sticky、静态成员
exactly-once幂等生产者、事务幂等为什么不能跨分区、与 RocketMQ 事务的区别
延迟/死信队列TTL、路由、定时投递大量不同延迟级别如何设计
MQ 选型吞吐、延迟、可靠性、生态为什么项目选择当前 MQ
MQ 系统设计通信模型、存储、高可用推拉模式、顺序写、分区与消费关系

MQ 题必须按生产者、Broker、消费者三个阶段分别回答,避免一句“开启持久化”概括全部可靠性。


一、消息队列基础

核心概念

MQ吞吐量可靠性延迟场景
Kafka极高日志、大数据
RabbitMQ中等很高一般业务
RocketMQ很高很高电商、金融

消息队列如何保证消息不丢失?

难度 🔴

生产端 → MQ 服务端 → 消费端

三个阶段都要保证不丢失:
1. 生产端:等待 broker 确认,重试机制
2. MQ 服务端:持久化,主从复制
3. 消费端:消费完后再提交 offset

生产端保证(以 Kafka 为例):

producer.config.set("acks", "all");  // 等待所有副本确认
producer.config.set("retries", 3);

MQ 服务端保证:

Kafka 配置:
- replication.factor = 3  # 副本数
- min.insync.replicas = 2  # 最小同步副本数

RocketMQ 配置:
- 同步写盘模式
- Master-Slave 复制

消费端保证:

// 处理成功后才提交 offset
consumer.commitSync();  // 同步提交

Kafka vs RabbitMQ vs RocketMQ?

难度 🟡

特性KafkaRabbitMQRocketMQ
吞吐量极高(百万级)中等(万级)很高(十万级)
延迟
可靠性很高很高
易用性中等简单中等
应用日志、大数据业务消息电商、金融

选型建议:

  • Kafka:适合日志、埋点、大数据场景,吞吐量最高
  • RabbitMQ:适合一般的业务消息,可靠性最好
  • RocketMQ:适合电商、金融等对可靠性要求高的场景,支持事务消息、顺序消息

二、MQ 高频问题

消息队列为什么会有重复消费?怎么解决?

生产端为了保证消息发送成功,可能会重复推送。消费端更无法从根本上解决这个问题。

解决方案:业务端自己做幂等控制。

1. 唯一标识(幂等键):每条消息带全局唯一ID
2. 数据库唯一约束:利用唯一索引防止重复写入
3. Redis 缓存业务标识:处理前先检查是否已处理过
4. 数据库事务 + 乐观锁:通过版本号控制并发更新

通用概念:所谓 exactly-once 实为「至少一次投递 + 消费侧幂等」的组合效果,端到端的恰好一次不可实现。

消息积压了怎么办?

常规排查: 先排查是不是有 bug。优化消费逻辑(单条改批量),或水平扩容。

紧急扩容(百万级积压):

1. 修复 consumer 的 bug,停掉现有 consumer
2. 新建一个 topic,partition 是原来的 10 倍
3. 写一个临时分发程序,把积压消息均匀写入临时 queue
4. 部署 10 倍数量的 consumer 消费临时 queue
5. 积压清完后恢复原架构

RocketMQ 消息过滤机制

频次 ★★★ · 难度 🟡 · 高频:美团

RocketMQ 支持两级过滤:

Tag 过滤(第一级,Broker 端过滤):

  • 生产者发送时指定消息 Tag:Message msg = new Message("OrderTopic", "payment", body)
  • 消费者订阅时指定 Tag:consumer.subscribe("OrderTopic", "payment || cancel")
  • Broker 在投递前按 Tag hashcode 预过滤,不匹配的消息不进 ConsumeQueue,大幅减少网络传输

SQL 过滤(第二级,消费者端过滤):

  • 需配置 enablePropertyFilter=true
  • 支持 SQL92 表达式:consumer.subscribe("OrderTopic", MessageSelector.bySql("age > 18 AND gender = 'male'"))
  • 过滤属性在消息 properties 中设置(msg.putUserProperty("age", "20")
  • 相比 Tag 只做精确匹配,SQL 过滤支持范围、多条件组合,灵活性更高

面试追问:

  • Tag 和 SQL 过滤选哪个?→ 能用 Tag 用 Tag(Broker 端预过滤性能好);需要复杂条件用 SQL92(但会增加消费者端计算)
  • Kafka 为什么不原生支持消息过滤?→ Kafka 设计哲学是「不做业务逻辑」,过滤由消费者自行处理或使用 Kafka Streams/KSQL;RocketMQ 追求开箱即用的业务功能(包括事务消息、延迟消息、过滤),这是两者定位差异的体现

RocketMQ 事务消息如何实现?

RocketMQ 保证的是 最终一致性

1. Producer 发送 Half Message(半消息)到 Broker
2. Broker 返回 ACK 确认
3. Producer 执行本地事务
4. 根据本地事务结果:
   - 成功 → 发送 Commit,Consumer 消费
   - 失败 → 发送 Rollback,Broker 删除半消息
   - 无响应 → Broker 反查 Producer 本地事务状态

RocketMQ 消息顺序怎么保证?

1. Producer 把需要顺序消费的消息发到同一个 MessageQueue
2. Broker 对 MessageQueue 加锁,保证同一个 Queue 只能被同一个 Consumer 消费
3. Consumer 加锁串行化处理消息

RocketMQ 存储模型:CommitLog 为什么和 Kafka 分区存储不一样?

是什么:RocketMQ 不像 Kafka 那样每个分区一个日志文件,而是一个 Broker 上所有 Topic、所有 Queue 的消息统一顺序写入一份 CommitLog(默认单文件 1GB,写满滚动下一个),靠两个索引结构定位消息:

结构内容作用
CommitLog消息完整内容,严格按写入顺序追加唯一的物理存储,全局顺序写
ConsumeQueue每条记录只有 20 字节:CommitLog offset + size + tag hashcode按 Topic+Queue 维度的逻辑索引,消费者实际读的是它
IndexFileKey → CommitLog offset 的哈希索引支持按消息 Key 精确查询(Kafka 没有对应功能)

为什么这么设计

  • 写入侧:所有消息挤到一份 CommitLog 顺序写,磁盘只有一个写指针在移动,顺序写性能拉满(不受 Topic/Queue 数量影响);如果像 Kafka 一样按分区各开一个文件,Topic/Queue 一多,磁盘头在多个文件间来回跳,顺序写退化——这也是消息队列篇前面提到的”Kafka 分区数不是越多越好”背后同一个磁盘寻道原理
  • 消费侧:消费者要按 Topic 顺序读,但 CommitLog 里同一 Topic 的消息物理上不连续(和别的 Topic 混在一起)——所以异步构建 ConsumeQueue 这层轻量索引,消费者靠它算出要读的 offset,再回 CommitLog 取完整内容,两次 IO(索引在 page cache 里几乎不占时间,代价很小)

读写全流程

写入:Producer → CommitLog(顺序写、加锁保证原子) → 异步转发线程 → 更新 ConsumeQueue / IndexFile
消费:Consumer → 读 ConsumeQueue(拿到 offset+size) → 回查 CommitLog(拿完整消息体) → 处理 → 更新消费位点

mmap 与刷盘策略:CommitLog 通过 MappedByteBuffer(mmap)把文件映射进虚拟内存,写入先落到 page cache(走 mmap 直接写映射内存,避免用户态到内核态的一次数据拷贝),再由刷盘策略决定何时落盘:

策略行为权衡
ASYNC_FLUSH(异步刷盘,默认)写完内存立即返回,后台线程定时/定量刷盘吞吐高,进程崩溃可能丢最后一小段
SYNC_FLUSH(同步刷盘)等真正落盘后才返回 ACK更可靠,吞吐明显下降

常见追问

  • CommitLog 单点写会不会成为瓶颈?→ 单 Broker 内是全局串行写,但这正是它比 Kafka 分区并行写吞吐略低、可靠性和顺序写效率更优的取舍点;横向扩展靠加 Broker 而非加 Topic 内并行度。
  • ConsumeQueue 丢了怎么办?→ 它是可以从 CommitLog 重建的派生索引(类似数据库的二级索引可以从主键数据重建),不是权威数据源,这也是刷盘策略只需保证 CommitLog 可靠、ConsumeQueue 异步构建也没关系的原因。
  • 和 Kafka 存储的本质区别一句话怎么说?→ Kafka 是”分区级顺序写 + 稠密日志”,RocketMQ 是”全局顺序写 + 逻辑索引拆分”,前者靠分区并行扩展吞吐,后者靠索引分离兼顾全局顺序写性能和多 Topic 灵活性。

Kafka 日志存储:segment 和稀疏索引怎么按 offset 找到一条消息?

频次 ★★★ · 难度 🟡

是什么:分区(partition)是逻辑概念,物理上每个分区目录 <topic>-<分区号>/ 下按 segment 分段存储,每段三件套,文件名统一是段内第一条消息的 offset(base offset):

00000000000000368769.log        # 消息本体,顺序追加
00000000000000368769.index      # 稀疏索引:相对 offset(4B) → 物理位置(4B)
00000000000000368769.timeindex  # 时间戳 → 相对 offset

段写满 1GB(log.segment.bytes)或到期(log.roll.hours)就滚动新段;只有最后一段(active segment)可写,之前的段不可变

为什么要分段:单个无限追加的大文件没法过期清理——删中间一段要重写整个文件。分段后 retention 清理 = 整文件删除旧 segment,O(1) 且不碰正在写的数据;不可变的旧段也能放心走零拷贝 sendfile 发给消费者(见上文”Kafka 为什么这么快”)。

稀疏索引与查找流程.index 不是每条消息一项,而是每追加约 4KB 消息(log.index.interval.bytes)才插一条,每项仅 8 字节。按 offset=368776 查找分三步:

1. 找段:所有段按 baseOffset 组织成跳表(ConcurrentSkipListMap),
   floorEntry(368776) → 定位到 368769 这一段
2. 段内找索引:.index 里二分,找 ≤ (368776-368769=7) 的最大索引项,
   得到一个物理 position
3. 顺序扫:从 position 开始顺序扫 .log,最多扫 4KB 就命中目标消息

索引里存相对 offset(当前 offset − baseOffset)是为了把 8 字节压成 4 字节,索引更小、更容易整个驻留 page cache。

对比 MySQL B+树——为什么 MQ 不需要稠密索引

维度Kafka 稀疏索引MySQL B+树(见MySQL
查询模式几乎只有”从 offset 顺序消费”,点查仅用于回溯定位任意 key 点查 + 范围查
写路径只追加不修改,索引也只追加原地更新,页分裂、写放大
索引密度4KB 一项,把你送到”附近”再顺序扫每行都可定位(聚簇叶子即数据)
删除整 segment 删除,索引文件一起删逐行删 + 空洞回收

一句话:索引密度应该匹配访问模式——顺序消费为主时,索引只需粗定位,剩下的顺序扫描本来就是磁盘和 page cache 最擅长的事;为用不上的随机点查维护稠密索引,纯付写放大的成本。

常见追问

  • .timeindex 什么时候用?→ 按时间回溯消费(offsetsForTimes)和按时间过期(retention.ms):先时间戳→offset,再走上面的 offset 查找
  • Kafka 怎么删消息?→ 两种策略:delete(按时间/大小整段删)和 compact(按 key 只留最新值,__consumer_offsets 就用它),不支持删除任意一条
  • 索引项丢了/损坏会怎样?→ 索引是派生数据,broker 启动时可从 .log 重建(与 RocketMQ ConsumeQueue 可从 CommitLog 重建同理,见上文)

通用概念:稀疏索引是”粗定位 + 顺序扫尾”模式——每层只负责把范围缩小到下层能顺序处理的粒度。跳表每一层就是下一层的稀疏索引(见Redis跳表实现原理),ClickHouse 的主键索引同样按 granule(8192 行)稀疏建;对立面是哈希/稠密索引:O(1) 点查但完全放弃顺序局部性。 「粗定位 + 顺序扫尾」之所以划算,是因为顺序扫在有局部性的介质上几乎免费。

Kafka 为什么这么快?

1. 顺序写入:消息顺序追加到磁盘,减少磁盘寻道时间
2. 零拷贝:数据从磁盘直接发送到网络
3. 批量处理:生产者积攒一批消息再发送
4. 分区并行:Topic 分多个 Partition,可并行读写

零拷贝(sendfile)与页缓存的底层原理见操作系统

Kafka 是推模式还是拉模式?为什么?

Kafka 采用 拉取模型(pull)

Pull 模式的优点:
- 消费者按自己的消费能力拉取消息
- 消费者可以任意控制消费进度(重置 offset 重新消费)
- 消费者组机制:一个分区同一时间只能被组内一个消费者消费

RabbitMQ 延迟队列和死信机制

延迟队列: 让消息不是发出去就立即消费,而是等指定时间后才投递。

场景:订单创建后 30 分钟没支付自动取消
实现:消息设置延迟时间 → 延迟交换机等待 → 到期后转发到业务队列

死信队列(DLQ): 处理不了的消息的兜底归宿。

消息变成死信的三种情况:
1. 消费者拒绝消息且不重新入队
2. 消息在队列中过期(TTL 到期)
3. 队列满了,最老的消息被挤出

Kafka 的 ISR、HW、LEO 是什么?副本是怎么同步的?

是什么

概念含义
AR分区的全部副本(Assigned Replicas)
ISR与 Leader 保持同步的副本集合(含 Leader 自己);落后超过 replica.lag.time.max.ms(默认 30s)被踢出
LEO每个副本下一条待写入消息的 offset(Log End Offset)
HW高水位 = ISR 中最小的 LEO;消费者只能读到 HW 之前的消息

同步流程:Producer 写 Leader → Follower 主动拉取(Kafka 副本同步也是 pull)→ ISR 全部同步到某 offset 后 HW 前移 → acks=all 的请求此时才返回成功。

为什么这么设计

  • HW 保证消费者不会读到”可能因 Leader 切换而丢失”的消息
  • ISR 是同步复制和异步复制的折中:不要求全部 AR 确认(太慢),也不是只写 Leader(丢数据),而是”活着且跟得上的副本”确认即可

常见追问

  • acks=all 就一定不丢吗?→ 不一定。如果 ISR 收缩到只剩 Leader,acks=all 退化为 acks=1。必须配合 min.insync.replicas=2:ISR 数量不足时直接拒绝写入(可用性换一致性)。
  • Leader 挂了怎么选举?→ 从 ISR 里选第一个活着的副本。若 ISR 全挂,unclean.leader.election.enable 决定是否允许 OSR(落后副本)上位——true 保可用但丢消息,默认 false。
  • HW 机制有什么缺陷?→ HW 同步有延迟,Leader 频繁切换时可能造成数据丢失/不一致,Kafka 0.11 引入 Leader Epoch(任期号 + 起始 offset)解决。

Kafka rebalance 什么时候发生?有什么危害?怎么减少?

是什么:消费者组内分区所有权的重新分配。由组协调器(Coordinator)主导,全组消费者重新加入并领取分区。

触发条件(三类):

  1. 成员数变化:消费者加入/退出/崩溃(最常见:消费太慢导致 max.poll.interval.ms 超时被踢、心跳超时 session.timeout.ms
  2. 订阅的 Topic 数变化(如按正则订阅时新建了匹配的 Topic)
  3. Topic 分区数变化(扩分区)

危害

  • rebalance 期间整组 STW——所有消费者停止消费,百人规模的组可能停顿分钟级
  • 触发 offset 提交不及时 → 重复消费
  • 频繁 rebalance 会连锁:消费停止 → 积压 → 处理更慢 → 再次超时 → 再次 rebalance

怎么减少

参数建议
max.poll.interval.ms大于单批消息最大处理时长(默认 5min)
max.poll.records调小单批条数,缩短单次 poll 处理时间
session.timeout.ms / heartbeat.interval.ms心跳间隔设为超时的 1/3,避免网络抖动误判
分配策略CooperativeStickyAssignor(增量式,只迁移必要分区,避免全组 STW)或静态成员 group.instance.id(重启不触发 rebalance)

常见追问

  • Range/RoundRobin/Sticky 分配策略区别?→ Range 按 Topic 逐个分段(多 Topic 时分配不均);RoundRobin 全局轮询;Sticky 尽量保留原分配减少迁移;Cooperative-Sticky 进一步做到增量 rebalance(两阶段,不停整组)。
  • 消费者数量超过分区数会怎样?→ 多出来的消费者空闲。分区数是消费并行度上限,这也是”分区数怎么定”的核心依据。

Kafka 如何实现 exactly-once?

三层递进

  1. 幂等生产者enable.idempotence=true,Kafka 3.0+ 默认开启):Broker 给每个 Producer 分配 PID,消息带 <PID, 分区, 序列号>,Broker 去重 → 解决单分区内重试导致的重复写入。跨会话、跨分区无效。
  2. 事务transactional.id + sendOffsetsToTransaction):跨分区原子写 + 把”消费 offset 提交”和”结果写入”绑成一个事务 → 实现消费-转换-生产(consume-transform-produce,如 Flink/Kafka Streams 链路)的 exactly-once。消费端需配 isolation.level=read_committed
  3. 业务幂等兜底:事务只覆盖”Kafka 到 Kafka”。落库、调下游接口等副作用出了 Kafka 体系就管不到,最终还是靠幂等键/唯一约束(见本页问题 1)。

常见追问

  • 事务消息和 RocketMQ 事务消息一样吗?→ 不一样。Kafka 事务解决”多分区写 + offset 提交”的原子性(流处理场景);RocketMQ 半消息解决”本地数据库事务与发消息”的最终一致(业务场景)。两者面向的问题不同。
  • 为什么幂等生产者不能跨分区?→ 序列号按 <PID, 分区> 维度维护,跨分区去重需要全局协调,代价太高,所以跨分区原子性交给事务做。

消息重试机制

频次 ★★★ · 难度 🟡

消息消费失败时,MQ 的重试方式决定了系统稳定性和数据一致性。

RocketMQ 的重试机制:

  • 消费失败后消息进入重试队列%RETRY%ConsumerGroupName),默认重试 16 次,间隔从 10 秒递增到 2 小时(阶梯式延迟)
  • 重试次数耗尽后进入死信队列%DLQ%ConsumerGroupName),需要人工介入或额外程序处理
  • 可通过 setMaxReconsumeTimes 自定义最大重试次数
  • 注意:RocketMQ 的重试是重新投递整批消息——如果批量消费中一条消息失败,整批全部重试(直到所有消息成功或全部进死信)

Kafka 的重试机制:

  • Kafka 没有服务端重试队列——消费失败不提交 offset,下次 poll 会重新获取同一批消息
  • 可在消费者端实现手动重试:先提交 offset,失败消息写入本地重试表或发送到独立的重试 Topic
  • 控制重试频率:合理设置 max.poll.interval.ms 避免重试期间心跳超时被踢出组

RabbitMQ 的重试机制:

  • 结合死信队列 + TTL 实现阶梯重试:消费失败 → 发到延迟队列(TTL 设定重试间隔)→ 到期回到原队列
  • 重试次数耗尽后消息留在死信队列,消费者订阅死信队列人工处理

设计建议:

MQ重试方式如何处理最终失败
RocketMQ服务端重试队列(阶梯延迟)死信队列
Kafka消费者自行实现重试 Topic / 死信 Topic
RabbitMQ死信队列 + TTL 组合死信队列

重试应当搭配业务幂等(见上文「重复消费」),避免重试导致数据重复写入。


Kafka 为什么不支持读写分离?分区数是不是越多越好?

读写分离:Kafka 的读写都走 Leader(2.4+ 才允许 Follower 只读,主要给跨机房省流量用)。

  • 主从读写分离适合读多写少(如 MySQL),MQ 的读写比例接近 1:1,分走读流量收益小
  • Follower 读会遇到 HW 延迟导致的数据滞后和一致性问题,得不偿失
  • Kafka 靠分区做水平扩展:负载分散由多分区多 Leader 承担,不需要从副本分流

分区数取舍

  • 多的好处:消费并行度高、吞吐高
  • 多的代价:文件句柄和内存开销、Leader 选举变慢(故障恢复时间变长)、顺序写退化为随机写(每个分区一个日志文件,分区太多时磁盘在多文件间跳)、端到端延迟上升
  • 经验:按目标吞吐 / 单分区吞吐估算,留扩容余量;单 Broker 分区数控制在 2000~4000 以内

通用概念:分区是 topic 与 broker 之间的间接层。扩容搬的是分区而非 topic,这正是「分区数必须远多于 broker 数」的原因;同一模式见虚拟节点、哈希槽、虚拟内存页。

如何设计一个消息队列?

1. 整体流程:Producer → Broker(存储)→ Consumer → ACK
2. RPC 设计:参考 Dubbo(服务发现、序列化协议)
3. 持久化:文件系统 or 数据库?消息堆积如何处理?
4. 消费关系:点对点 or 广播?
5. 可靠性:消息重复如何幂等处理?
6. 高可用:多副本 → Leader & Follower → 宕机重新选举
7. 消息事务:本地消息落库 → 投递成功后删除 → 定时任务扫描补偿
8. 可扩展:参考 Kafka 的 broker → topic → partition 设计