Skip to content

Kafka 深入 ​

相关笔记:消息队列(RabbitMQ / Kafka 基础与选型)、定时任务调度(延迟消息对比)

消息队列 那篇讲的是「MQ 怎么用、怎么选」;这篇往下挖一层:Kafka 的存储、副本、ISR、再均衡、Exactly-Once。

1. 架构与核心概念 ​

Producer ──► Topic(逻辑概念)
              ├── Partition 0  (Leader: Broker1, Follower: Broker2/3)
              ├── Partition 1  (Leader: Broker2, Follower: Broker1/3)
              └── Partition 2  (Leader: Broker3, Follower: Broker1/2)
Consumer Group ── 每个分片只会被组内一个消费者消费
Producer ──► Topic(逻辑概念)
              ├── Partition 0  (Leader: Broker1, Follower: Broker2/3)
              ├── Partition 1  (Leader: Broker2, Follower: Broker1/3)
              └── Partition 2  (Leader: Broker3, Follower: Broker1/2)
Consumer Group ── 每个分片只会被组内一个消费者消费
概念说明
Broker一个 Kafka 节点
Topic逻辑主题,实际由多个 Partition 组成
Partition并行与顺序的最小单位,一个分区内严格有序
Replica分区的副本,分 Leader / Follower
ISRIn-Sync Replicas,与 Leader 保持同步的副本集合(含 Leader)
Consumer Group组内互斥消费,组间广播
Offset分区内的消息位移,消费者提交的是「我消费到哪了」

为什么吞吐高:分区并行 + 顺序写盘 + 页缓存 + 批量 + 零拷贝 + 压缩。这几个词能串起来讲,就够面试用了。

2. 存储设计 ​

2.1 分段日志 ​

topic-order-partition-0/
├── 00000000000000000000.log      ← 消息本体
├── 00000000000000000000.index    ← 稀疏索引(offset → 物理位置)
├── 00000000000000000000.timeindex
└── 00000000000000032580.log      ← 满了 1GB 就滚动
topic-order-partition-0/
├── 00000000000000000000.log      ← 消息本体
├── 00000000000000000000.index    ← 稀疏索引(offset → 物理位置)
├── 00000000000000000000.timeindex
└── 00000000000000032580.log      ← 满了 1GB 就滚动
  • 每个 segment 默认 1GB(log.segment.bytes);
  • 稀疏索引:不是每条都建索引,而是每 4KB 建一条(log.index.interval.bytes),所以是「先二分定位到接近的位置,再顺序扫」;
  • 查找消息 = 二分查 .index 找 segment 内的近似位置 → 从 .log 顺序扫到目标 offset。

2.2 顺序写 + 页缓存 ​

Kafka 只做追加写,不做随机写 —— 顺序写盘能到几百 MB/s,随机写只有几 MB/s。这是它敢用磁盘扛百万 TPS 的根本原因。

读的时候优先命中 OS 页缓存(Kafka 自己不维护缓存,交给操作系统),所以「消费者追得上生产者」时几乎不读盘。

2.3 零拷贝 ​

消费者拉数据时走 sendfile:数据从页缓存直接进 socket,不经过 JVM 堆,省掉两次拷贝。配合批量拉取,这是高吞吐的另一半原因。

详见 07 Java 网络编程与 NIO 第 6 节。

3. 生产者 ​

3.1 发送流程 ​

send() → 拦截器 → 序列化 → 分区器 → 累加器(RecordAccumulator,按分区攒批)
                                              │
                        Sender 线程按 batch.size / linger.ms 发送
                                              │
                                     Broker 返回 ack / 错误重试
send() → 拦截器 → 序列化 → 分区器 → 累加器(RecordAccumulator,按分区攒批)
                                              │
                        Sender 线程按 batch.size / linger.ms 发送
                                              │
                                     Broker 返回 ack / 错误重试

关键配置:

参数作用建议
acks0 / 1 / all(-1)金融级用 all
retries重试次数配合幂等,设大
enable.idempotence幂等生产者强烈建议 true
batch.size单批字节数默认 16KB,吞吐优先可到 64~128KB
linger.ms攒批等待默认 0,吞吐优先设 5~20ms
compression.type压缩算法lz4(快)或 zstd(省)
max.in.flight.requests.per.connection未确认请求数要顺序 + 重试时设 1,或开幂等

3.2 分区器 ​

  • 指定 key → hash(key) % 分区数,同 key 必进同分区(这是「局部有序」的实现方式);
  • 无 key → 2.4 之前是轮询,之后是粘性分区(Sticky:一批发完再换分区,减少小批次、提升吞吐)。

3.3 幂等与事务 ​

  • 幂等生产者:给每个生产者分配 PID,每条消息带 <PID, 分区, 序列号>,Broker 侧去重。解决「重试导致的重复」。
  • 事务:transactional.id + 两阶段提交,解决「跨分区原子写」(读-处理-写场景,配合 read_committed 消费)。

注意:幂等只保证单个生产者会话内、单分区不重复;跨会话、跨分区要靠事务。

4. 消费者 ​

4.1 消费者组与再均衡 ​

一个分区只能被组内一个消费者消费。消费者增减、分区数变化、订阅关系变化都会触发 Rebalance。

分配策略特点
Range按主题范围分,分区数少时容易不均
RoundRobin跨主题轮询,均匀
Sticky尽量保留上次分配,减少抖动
CooperativeSticky增量再均衡,不用全局停顿(推荐)

再均衡的代价:整个过程 Stop-The-World(Eager 协议),期间不消费。所以:

  • 用 CooperativeSticky 做增量式;
  • 配 group.instance.id(静态成员),重启不触发再均衡;
  • 会话超时、心跳参数要调:session.timeout.ms、heartbeat.interval.ms、max.poll.interval.ms(处理太慢被踢出组,这是最常见的线上事故)。

4.2 位移提交 ​

方式风险
自动提交 enable.auto.commit=true可能在处理前就提交了 → 丢消息
手动同步 commitSync()安全但阻塞、慢
手动异步 commitAsync()快,但失败不回滚,重试可能乱序

标准写法:正常用 commitAsync,finally 或关闭时用 commitSync 兜底。并且先处理再提交。

5. HW / LEO / ISR:一致性核心 ​

概念含义
LEO(Log End Offset)每个副本最后一条消息的下一个位置
HW(High Watermark)ISR 中最小的 LEO,消费者只能看到 HW 之前的消息
ISR与 Leader 保持同步的副本(replica.lag.time.max.ms 内追得上)

为什么要 HW:如果允许消费者读到还没同步到所有 ISR 的消息,一旦 Leader 挂了、由落后的副本接任,这条消息就「消失」了 —— 消费过的东西又不存在,等于数据丢失。所以只暴露 HW 之前的。

Leader 选举:

  • 默认只从 ISR 里选(unclean.leader.election.enable=false);
  • 如果 ISR 全挂,宁可不可用也不选落后副本 —— 保一致性(CP)。
  • 打开 unclean.leader.election 就变成可用性优先(AP),可能丢数据。

必须一起配的两个参数:

properties
acks=all                      # 生产者等所有 ISR 确认
min.insync.replicas=2         # 至少 2 个副本确认,否则报错而不是静默降级
replication.factor=3          # 副本数 3,容忍挂 1
acks=all                      # 生产者等所有 ISR 确认
min.insync.replicas=2         # 至少 2 个副本确认,否则报错而不是静默降级
replication.factor=3          # 副本数 3,容忍挂 1

面试口径:acks=all 不等于「万无一失」,还要 min.insync.replicas >= 2 且副本数 >= 3。三者缺一,都可能丢消息。

6. 三个经典问题:不丢、不重、不乱序 ​

6.1 不丢消息 ​

环节措施
生产端acks=all + retries 大 + 幂等开启
Broker副本数 3 + min.insync.replicas=2 + unclean.leader.election=false
消费端处理成功后再提交位移,别用自动提交
跨系统本地消息表 / 事务消息(RocketMQ)做最终一致

6.2 不重复消费 ​

Kafka 只保证「至少一次」(at-least-once),重复必然发生,所以只能消费端幂等:

  • 唯一键 + 去重表(insert ignore 或先查后插 + 唯一索引);
  • Redis SETNX 标记已处理的消息 id;
  • 业务状态机:只允许「待支付 → 已支付」这种单向流转,重复执行无副作用。

6.3 不乱序 ​

  • 单分区内天然有序,跨分区不保证;
  • 要顺序 → 同 key 打同分区(订单号做 key);
  • 生产端重试会乱序 → 开幂等(max.in.flight <= 5)或设 max.in.flight=1;
  • 消费端多线程处理会乱序 → 按 key 哈希到固定线程,或用「单分区单线程」。

7. 高性能原因(面试高频总结) ​

  1. 分区并行:横向扩展,一个 topic 多个分区同时读写。
  2. 顺序写:只追加,磁盘顺序写接近内存速度。
  3. 页缓存:读命中 OS 缓存,不自己管内存。
  4. 零拷贝:sendfile 让数据不进 JVM 堆。
  5. 批量 + 压缩:batch.size + linger.ms + lz4/zstd。
  6. 稀疏索引:索引小,内存占用低,二分 + 顺序扫很快。

8. 常见运维与调优 ​

场景处理
消息积压加消费者(≤ 分区数)、扩分区、提升单条处理速度、临时降级
分区热点检查 key 分布(某个 key 太热),加随机后缀打散
磁盘满调 log.retention.hours/bytes、开 log.cleanup.policy=delete/compact
重复消费暴增查是否有频繁 Rebalance(max.poll.interval.ms 太小 / 处理太慢)
延迟高查 linger.ms、acks、压缩、网络、ISR 抖动
消费 lag 监控kafka-consumer-groups.sh --describe,或 Burrow / KM(Kafka Exporter)

9. 与其他 MQ 对比 ​

维度KafkaRocketMQRabbitMQPulsar
定位流平台、日志管道业务消息、事务消息灵活路由、低延迟云原生、存算分离
吞吐极高(百万级)高(十万级)中(万级)高
延迟ms 级ms 级μs~msms 级
顺序分区内有序队列内有序队列内有序分区内有序
事务消息有(较复杂)原生支持不支持有
延迟消息不支持(要靠外部)支持(等级延迟)插件支持支持
运维中等中等简单复杂

选型口径:日志/埋点/流处理选 Kafka;业务消息要事务、延迟、重试选 RocketMQ;路由复杂、量不大选 RabbitMQ。

10. 速查表 ​

问题一句话
为什么快分区并行 + 顺序写 + 页缓存 + 零拷贝 + 批量压缩
分区与顺序分区内有序,跨分区无序;同 key 进同分区
HW 是什么ISR 中最小的 LEO,消费者只能读到 HW 之前
ISR 作用只从同步副本里选 Leader,避免丢数据
acks 三种0 不等、1 等 Leader、all 等所有 ISR
怎么不丢acks=all + 副本3 + min.insync=2 + 手动提交
怎么不重消费端幂等(唯一键/去重表/状态机)
怎么不乱序同 key 同分区 + 幂等生产者 + 单线程消费
再均衡代价STW 不消费;用 CooperativeSticky + 静态成员
幂等 vs 事务幂等管单会话单分区去重,事务管跨分区原子