Kafka 面试真正拉开差距的不是「什么是 Topic/Partition」,而是副本同步那一套:ISR 怎么进出、HW 和 LEO 怎么配合、acks 三档各自丢什么。本文把高频题按副本机制、可靠性、顺序性、高性能、消费语义五条线串起来逐题拆解。

考察地图

  • 副本机制:多副本怎么工作、ISR 进出规则、HW 与 LEO 的关系。可靠性的地基,最常被追问。
  • 可靠性:acks 三档语义、不丢失的完整配置组合。考察能否把生产端、Broker、消费端串成一条链。
  • 顺序性:分区有序边界与 key 路由。考察对「有序是有代价的」的理解。
  • 高性能:顺序写、页缓存、批量、零拷贝。考察能否讲清「快」的每个来源,而非背名词。
  • 消费语义:重复消费、offset 提交时机、Rebalance。考察消费端的工程取舍。
  • 建议自行查阅:Kafka 与 RocketMQ 的术语对照、KRaft 迁移流程、Spring Kafka 重试注解——偏记忆和版本细节,面试多为铺垫。

副本机制

Kafka 的多副本机制是怎么工作的?

结论:每个 Partition 有多个副本,一个 leader 对外读写,其余 follower 只从 leader 拉取同步;leader 故障时从同步进度达标的 follower 中选新 leader。副本只为容灾,不分担读写流量。

原理:生产者只把消息发给 leader,follower 通过拉取请求持续追赶 leader 的日志。「读写都走 leader」让一致性问题简化为「follower 追得够不够快」,代价是副本不做读写分离——三个副本的吞吐仍由 leader 那一台承担。replication.factor 决定副本数,生产常配 3,用两份额外存储换容忍一台故障。

延伸:副本分配到不同 Broker 上是容错的前提(同一台机器上的三个副本毫无意义);Kafka 还会尽量把同一 Partition 的副本打散到不同机架。ZooKeeper 时代由 Controller 负责 leader 选举,KRaft 模式(Kafka 3.3+ 生产可用,4.0 起唯一模式)改用内置 Raft 仲裁管理元数据,摆脱了对 ZK 的依赖。

追问:为什么 follower 不直接分担读请求?提示:考虑「读到尚未同步的数据」会引入什么语义问题,以及 Kafka 后来引入的 follower fetching 是为了什么(跨机房读本地化,不是分担负载)。

什么是 ISR?副本什么情况下会被踢出 ISR?

结论:ISR(In-Sync Replicas)是 leader 维护的「跟得上」的副本集合,包含 leader 自己;follower 落后超过阈值就被移出,追回来再加回。只有 ISR 成员才有资格参与 leader 选举(默认配置下)。

原理:判断「跟得上」的标准不是落后多少条消息,而是时间:follower 超过 replica.lag.time.max.ms(默认 30 秒)没有发起拉取或没有追上,就被移出 ISR。用时间而非条数的原因很实际:突发大流量下按条数衡量会瞬时「掉队」被误踢,而它只是慢了几秒,拉取速率其实正常。

延伸:ISR 是动态伸缩的,这是它和固定多数派(如 Raft 系方案)的本质区别——Kafka 允许 ISR 收缩到只剩 leader 也继续写(取决于 min.insync.replicas)。ISR 伸缩要同步到元数据层,频繁抖动会产生大量元数据变更,这也是大集群要控制分区总量的原因之一。

追问:unclean.leader.election.enable=false 与 ISR 是什么关系?提示:false 表示只在 ISR 里选 leader,宁可分区不可用也不让落后副本上位丢数据。

HW 和 LEO 是什么?它们如何配合防止消费到未同步的数据?

结论:LEO(Log End Offset)是副本日志中下一条待写入的位点;HW(High Watermark)是 ISR 所有副本都已复制的最高位点。消费者只能读到 HW 之前的消息,HW 与 LEO 之间的数据对消费端不可见。

原理:leader 从 follower 的拉取请求中得知各副本 LEO,取 ISR 中最小的作为 HW 并回传。举例:三个副本 LEO 为 100、98、95,则 HW 是 95——HW 之前的消息在 ISR 全员上各存一份,leader 宕机无论谁接任都不丢。commit 语义就锚定在 HW 上:acks=all 的生产者要等 HW 越过自己写入的位点才收到成功响应。

延伸:HW 机制有个经典缺陷——follower 的 HW 要靠下一次拉取响应才能更新,leader 切换瞬间可能出现日志截断(follower 把 HW 之后的数据切掉向新 leader 对齐),极端时已「成功」的消息也会丢。Kafka 0.11 引入 leader epoch 解决:每个副本记录(epoch, offset)历史,切换后按 epoch 找对齐点而非盲目按 HW 截断。

追问:为什么说 HW 更新存在「延迟一拍」的问题?提示:follower 的 HW 来自上一轮 fetch 响应,leader 宕机时各副本的 HW 可能不一致,这正是 leader epoch 要修的场景。

可靠性

acks=0/1/all 三档分别是什么语义?

结论:acks=0 发完不管,最快但可能丢;acks=1 等 leader 落盘即返回,是默认值;acks=all(等价 -1)等 ISR 所有副本确认才返回,最强但延迟最高。

原理:三档的本质是「成功」的定义权在哪。acks=1 下 leader 写完就回,若 leader 随后宕机而 follower 尚未同步,这条消息就丢了;acks=all 把成功条件提升为「ISR 全员落盘」,配合 min.insync.replicas=2 意味着至少两台 Broker 持有数据才算成功,能扛住单机故障。代价很直接:等待链路变长,吞吐下降、延迟上升,尤其跨机架部署时更明显。

延伸:acks=all 不等于绝对安全——ISR 收缩到只剩 leader 时,all 实际只等 leader 自己。所以必须配 min.insync.replicas:ISR 成员数低于该值时 Broker 直接拒绝写入,用可用性换「承诺不缩水」。经典组合 replication.factor=3 + min.insync.replicas=2 + acks=all:容忍一台故障,坏两台宁可停写也不假成功。

追问:既然 acks=all 已经等 ISR 全员,为什么还需要 min.insync.replicas?提示:ISR 可以收缩到 1,此时 all 退化成了 1。

如何配置才能保证 Kafka 消息不丢失?

结论:没有单个开关,丢失防护是生产端、Broker、消费端三段的组合:生产端 acks=all + retries 加幂等;Broker 端 replication.factor≥3 + min.insync.replicas≥2 + unclean.leader.election.enable=false;消费端关闭自动提交、处理成功后再提交 offset。

原理:逐段看丢失点。生产端:send() 是异步的,网络抖动时消息可能没到 Broker,靠回调感知失败并配合 retries 重试;开启幂等生产者(新版本默认开)让重试不写重。Broker 端:上一题的四件套防 leader 切换丢数据。消费端:自动提交 offset 时,拉到消息还没处理就宕机,offset 已前进,消息等于被「跳过」;改手动提交后风险变成重复消费——但至少不丢。

延伸:注意这个组合的净效果是 at-least-once:不丢的代价是可能重复,最终语义要靠消费端幂等收口。Kafka 的事务(幂等生产者 + 事务 API)可以在「消费-处理-生产」链路内做到 exactly-once,但只覆盖 Kafka 内部流转,消息出了 Kafka 进数据库的那一刻,仍然要靠业务幂等。

追问:手动提交 offset 应该处理前提交还是处理后提交?提示:处理后提交防丢但可能重复,处理前提交防重复但可能丢——二选一,取决于业务更不能容忍哪个。

顺序性

Kafka 如何保证消息顺序性?

结论:Kafka 只保证单个 Partition 内有序,不保证 Topic 级全局有序。工程做法是「同一业务 key 路由进同一分区 + 分区内单线程顺序消费」。

原理:消息追加到 Partition 时按到达顺序分配递增 offset,物理上就是一条有序日志,分区内有序天然成立。保住业务顺序的关键是发送端把相关消息钉在同一分区:指定 key(如订单 ID),默认分区器对 key 取模,同 key 永远落同分区。反例是一个 Topic 只建一个分区——顺序完美但并行度归零。消费侧一个分区同一时间只分给组内一个消费者,天然串行;若消费端内部再开线程池乱序处理,等于自己把顺序打碎。

延伸:发送侧还有一个隐蔽的乱序源:重试。生产者允许同时存在多个未确认的批次(max.in.flight.requests.per.connection 默认 5),若第一批失败后重试,后发的批次可能先到达,分区内顺序就被打乱。开启幂等生产者后该参数被自动限制到不超过 5 且服务端按序号去重重排,可以保证单分区内有序;不开幂等又想严格有序,就得把它降到 1,代价是吞吐明显下降。

追问:消费失败后重试会不会破坏顺序性?提示:会——失败消息跳过或重入都会让后续消息先被处理,强顺序业务需要状态机或版本号在业务层兜底,不能只靠 MQ。

高性能

Kafka 为什么这么快(高吞吐低延迟)?

结论:四个来源叠加:分区并行、顺序写加页缓存、批量压缩传输、零拷贝。核心思想是把随机 I/O 变顺序 I/O、把小请求变大请求、把数据拷贝减到最少。

原理:其一,Topic 拆成多个 Partition 分散在不同 Broker,读写天然并行,吞吐随分区数近似线性扩展。其二,消息只追加到分区日志末尾,磁盘顺序写吞吐媲美内存随机写;读写优先命中 Page Cache,不经应用层缓存,避免 JVM 堆与 OS 各存一份的双倍浪费。其三,生产者按分区攒批(linger.ms、batch.size 控制),一次请求携带成百上千条消息并整体压缩,摊薄 RPC 与系统调用的固定成本。其四,消费路径走 sendfile 零拷贝(详见下一题)。

延伸:快是有边界的。分区不是越多越好——每个分区对应 Broker 上的文件句柄和内存缓冲,分区总量过大会拖慢 leader 选举和 Controller 故障恢复;批量攒得越大,单条消息的端到端延迟越高。Kafka 的调优本质就是在吞吐与延迟之间选点。

追问:为什么 Kafka 不用内存缓冲再异步落盘的思路?提示:它用了——Page Cache 就是,只是缓冲区归 OS 管而非 JVM,既免 GC 压力又能在进程重启后保住缓存。

零拷贝(sendfile)具体省掉了什么?

结论:传统 read+write 发送文件要 4 次上下文切换和 4 次数据拷贝(其中 2 次是 CPU 搬运);sendfile 让数据在内核空间直达 Socket 缓冲区,省去用户态的往返,只剩 2 次切换和最少 2 次拷贝。

原理:消费者拉取消息时,数据路径是「磁盘 → 内核读缓冲 → 用户空间 → Socket 缓冲 → 网卡」。进入用户空间那段对 Kafka 毫无价值——它不看消息内容,只是搬运。FileChannel.transferTo() 底层调用 sendfile,数据从 Page Cache 直接拷到 Socket 缓冲区,应用进程全程不碰数据;网卡支持 SG-DMA 时连 Socket 缓冲那次拷贝也能省,Page Cache 数据直接进网卡。消息越大、消费流量越高,省下的 CPU 和内存带宽越可观。

延伸:零拷贝 + 页缓存 + 顺序读三者是配套的:顺序读保证 Page Cache 命中率高(读的都是刚写的尾部),零拷贝负责把命中后的数据以最低成本送出去。生产端写路径则靠 mmap 思路类似地减少拷贝,RocketMQ 的 CommitLog 写路径就明确用了 mmap。

追问:零拷贝是不是「零次拷贝」?提示:不是,DMA 拷贝仍在,省的是 CPU 参与的用户态/内核态之间的拷贝与上下文切换。

消费语义

为什么会出现重复消费?怎么解决?

结论:重复的根源是「业务处理完成」与「offset 提交成功」之间没有原子性——消费完还没提交就宕机、Rebalance 打断提交、生产者重试写入重复,都会让同一条消息被再次投递。解决靠消费端幂等,而非指望 MQ 不重复。

原理:Kafka 默认语义是 at-least-once,宁可重发也不丢,这是可靠性的另一面。典型场景:消费者处理完消息、还没提交 offset 就崩溃,恢复后从旧位点重新拉取,消息再消费一遍。应对靠幂等,按业务选强度:弱幂等用 Redis SETNX 按消息 key 做短窗口去重;强幂等用数据库唯一键、幂等表或业务状态机(如「订单已是支付状态则跳过」),适合资金、库存类不能错的场景。

延伸:上游也能减少重复源:开启幂等生产者后,生产重试不会在分区产生重复记录;enable.auto.commit=false 配合「处理成功后同步提交」能把重复窗口压到最小。但要接受一个事实:分布式环境下窗口无法归零,幂等是必选项而不是优化项。

追问:Kafka 的 exactly-once 语义为什么不能替代业务幂等?提示:事务只覆盖「从 Kafka 读、处理、写回 Kafka」的闭环,写入外部数据库的那一刻不在事务保护内。

Rebalance 什么时候触发?有什么危害?如何缓解?

结论:消费组成员变化(上下线、心跳超时)、订阅 Topic 变化、分区数变化都会触发 Rebalance;期间全组停止消费重新分配分区,带来消费停顿、重复消费和延迟抖动。

原理:Rebalance 由 Broker 侧的 Coordinator 协调:检测到成员变化后,让所有成员放弃现有分区,再按分配策略(Range、RoundRobin、Sticky、CooperativeSticky)重新发牌。最容易误触发的场景是消费太慢:单次 poll() 处理超过 max.poll.interval.ms(默认 5 分钟),协调者认为该成员已死,踢出并触发重平衡;处理慢又导致再次超时,形成「Rebalance 风暴」。

延伸:缓解围绕「少触发、触发后少动」:控制 max.poll.records 让处理时间远小于超时阈值;发布滚动重启避免整组同时下线;用静态成员(group.instance.id)让重落实例保住原分区;分配策略选 CooperativeSticky,从全量回收改为增量迁移,只有需挪动的分区短暂停。最后,Rebalance 前后仍可能重复,幂等是底线。

追问:Eager(全停)与 Cooperative(增量)两种再平衡协议差别在哪?提示:前者所有成员先 revoke 再 assign,全程 Stop-The-World;后者只迁移需要换主的分区,其余分区照常消费,但需要两轮 Rebalance 收敛。

小结

  • 副本只为容灾不分担读写;ISR 按时间阈值动态伸缩;HW 之前的数据才对消费者可见,leader epoch 修复了 HW 机制的截断缺陷。
  • 可靠性的经典配方是 acks=all + replication.factor=3 + min.insync.replicas=2 + 消费端手动提交,净语义是 at-least-once,幂等收口。
  • 顺序性只在分区内成立,靠 key 路由 + 单分区串行消费 + 幂等生产者防重试乱序。
  • 高性能来自分区并行、顺序写与页缓存、批量压缩、sendfile 零拷贝四件套。
  • Rebalance 的止损点是处理时长控制、静态成员与 Cooperative 策略,而不是回避扩缩容。