面试追问地图

主问题必讲关键点下一层追问
为什么用 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 生产者架构缓冲区、Sender 线程、batch 攒批batch.size、linger.ms、buffer.memory 调优
Kafka 消费者架构拉取模型、位移提交、心跳commitSync vs commitAsync、自动提交风险
Kafka 分区分配Range/Sticky/CooperativeStickyrebalance 影响、增量协同收敛
Kafka acks0/1/all 三级确认min.insync.replicas、unclean leader election
Kafka ControllerController 职责、Leader 选举、epochKRaft vs ZK 模式、unclean 选举代价
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 数」的原因;同一模式见虚拟节点、哈希槽、虚拟内存页。

Kafka 生产者架构:缓冲区与 Sender 线程

频次 ★★★★ · 难度 🟡

是什么:Kafka 生产者不是发一条走一条的,而是攒批发送——消息先写入内存缓冲区,Sender 线程按批次批量发送。

Producer 端架构(简化):
业务线程                     Sender 线程
┌─────────────────┐         ┌──────────────────┐
│  record(key,val)│         │  while (running)  │
│    ↓            │         │    batch 准备好    │
│  累加器(Accumulator)  ←──→│    → 发送到 Broker│
│  每个分区一个队列  │         │    (同步或异步)    │
│  batch 攒满/到时间│         │                  │
└─────────────────┘         └──────────────────┘

核心参数

参数默认值说明
batch.size16KB单个 batch 大小上限,攒满就发(不是等到满才发,而是尽可能填满)
linger.ms0超过此时间 batch 未满也发(设为 5~10ms 可显著提高吞吐,代价是增加延迟)
buffer.memory32MB所有分区的缓冲区总大小,超限则 send() 阻塞(max.block.ms 控制最大阻塞时间)
max.request.size1MB单次请求的最大字节数

为什么攒批发更快:① 减少网络往返次数(N 条消息 1 次请求 vs N 次请求);② 批量压缩率更高(相似数据一起压缩,gzip/snappy 在 batch 上效果好);③ Broker 端批量写入磁盘,顺序写更高效。

常见追问linger.ms 设大了会怎样?→ 生产者延迟增加,但吞吐更高(batch 更大,压缩率更高,网络请求更少)。linger.ms=0 时消息到了就发,延迟最低但吞吐可能受限。

Kafka 消费者架构:拉取与位移提交

频次 ★★★★ · 难度 🟡

是什么:消费者通过拉取模型主动从 Broker 读取消息,核心是协调拉取、位移提交、心跳三者的配合。

Consumer 端架构(简化):
┌─────────────────────────────────────────┐
│  Consumer.poll(timeout)                  │
│    ↓                                     │
│  1. 发送 FetchRequest(拉取消息)          │
│    ↓                                     │
│  2. Broker 返回消息(有数据立即返回,        │
│     没数据等到 timeout 超时)              │
│    ↓                                     │
│  3. 业务处理消息                           │
│    ↓                                     │
│  4. commitSync() / commitAsync() 提交位移  │
│    ↓                                     │
│  5. 心跳线程(heartbeat.interval.ms)      │
│     持续向 GroupCoordinator 发心跳         │
└─────────────────────────────────────────┘

位移提交方式

方式方法优缺点
自动提交(默认)enable.auto.commit=trueauto.commit.interval.ms=5000每 5s 自动提交,消费失败重启会重复消费
手动同步提交commitSync()有重试,提交完才 poll 下一批,影响吞吐
手动异步提交commitAsync()不阻塞,但提交失败不重试(下一次提交会覆盖),适合高吞吐场景

最佳实践:先用 commitAsync() 提交,程序关闭前用 commitSync() 兜底一次。

// 推荐的位移提交模式
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(1000);
    for (ConsumerRecord<String, String> record : records) {
        process(record);  // 业务处理
    }
    consumer.commitAsync();  // 异步提交,不阻塞
}
// 程序关闭前兜底
Runtime.getRuntime().addShutdownHook(() -> {
    consumer.commitSync();  // 同步提交,确保提交完成再退出
    consumer.close();
});

Kafka 分区分配策略

频次 ★★★ · 难度 🟡

是什么:消费者组内,分区分配给消费者的策略。由 partition.assignment.strategy 配置,Kafka 3.x 默认 CooperativeSticky。

策略说明优缺点
Range(默认 2.x)按 Topic 的连续分区范围分配(如 topic1 的 03 给 A,47 给 B)分配不均(N 个分区 ÷ M 个消费者,余数集中在前面几个消费者)
RoundRobin轮询分配,所有 Topic 所有的分区排成列表轮流分配每个消费者分配数相近,但消费元数据开销大
Sticky2.4+ 引入,尽量均匀 + 尽量保持 rebalance 前后分配不变减少 rebalance 后的分区移动,降低冲击
CooperativeSticky(3.x 默认)Sticky 的增量版本,只撤销需要移动的分区,不触发 Stop The World避免”全停再分配”,分阶段收敛

常见追问:CooperativeSticky 和 Sticky 的区别?→ Sticky 在 rebalance 时仍会全量重新分配(只是分配结果尽量保持),CooperativeSticky 只撤销需要移动的分区,rebalance 期间消费者部分分区仍可消费,减少整组停顿。但需要多轮 rebalance 才能收敛(每轮解决一部分分区冲突)。

Kafka 的 acks 参数

频次 ★★★★ · 难度 🟡

是什么:acks 参数控制生产者请求在 Broker 端被确认的持久化级别,直接影响可靠性和吞吐。

行为可靠性吞吐
acks=0生产者发完即视为成功,不等待任何确认丢数据(Leader 写盘失败也不知道)最高
acks=1(默认)Leader 写入本地日志即返回,不等待 FollowerLeader 宕机且未同步到 Follower 时丢数据
acks=allLeader 等待 所有 ISR 副本都写入后才返回最高(配合 min.insync.replicas低(多一次网络往返)

min.insync.replicas 配合acks=all 时,ISR 中至少要有 min.insync.replicas 个副本已确认。如果 ISR 大小低于此值,生产请求被拒绝(NotEnoughReplicasException)。推荐 min.insync.replicas=2,容忍 1 个 Broker 宕机而不丢数据。

常见追问acks=all 就能保证不丢数据吗?→ 不能。acks=all 只保证 ISR 内的副本都写入了,但如果所有 ISR 副本同时宕机(小概率但可能),数据仍然丢失。要更高可靠性要配合 unclean.leader.election.enable=false 防止非 ISR 副本当选。

Kafka 的 Controller 与 Leader 选举

频次 ★★★ · 难度 🔴

是什么:Kafka 集群中有一个 Broker 担当 Controller,负责管理分区状态和 Leader 选举。

Controller 职责

  • 处理分区的新建/删除、Leader 选举
  • 监听 Broker 上下线,触发分区重分配
  • 管理所有分区的 ISR 变更
  • 将元数据变更同步到所有 Broker(通过 ZK Watch 或 KRaft 的 Raft 共识)

Leader 选举过程(Broker 宕机时):

1. Controller 通过 ZK Watch 感知 Broker 离线
2. 遍历该 Broker 上的所有 Leader 分区
3. 对每个分区,从 ISR 中选第一个副本作为新 Leader
4. 如果 ISR 为空(所有副本都宕机):
   - unclean.leader.election.enable=false(默认):分区不可用,等待原 Leader 恢复
   - unclean.leader.election.enable=true:从非 ISR 副本中选一个,代价是丢失数据
5. Controller 将新 Leader 信息写入 ZK 并通知所有 Broker

Leader Epoch(Kafka 2.0+):每个 Leader 任期递增的 epoch 号,Follower 用 epoch 判断日志是否与 Leader 一致。解决 HW 截断导致的数据丢失——unclean 选举后,旧 Leader 恢复时通过 epoch 发现自己的日志比新 Leader 多(未同步到 ISR 的数据),主动截断到与 Leader 一致。

常见追问

  • Controller 挂了怎么办?→ 其他 Broker 通过 ZK 竞选,第一个写入 ZK 的就成为新 Controller,读取全量分区状态重建元数据缓存
  • KRaft(Kafka 3.x+)和 ZK 模式的区别?→ KRaft 用 Raft 共识替代 ZK,Controller 集群(Kraft quorum)自己管理元数据,不再依赖外部 ZK。生产环境建议 3.5+ 开始评估 KRaft 模式

如何设计一个消息队列?

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