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 |
| ISR | In-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 / 错误重试关键配置:
| 参数 | 作用 | 建议 |
|---|---|---|
acks | 0 / 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),可能丢数据。
必须一起配的两个参数:
acks=all # 生产者等所有 ISR 确认
min.insync.replicas=2 # 至少 2 个副本确认,否则报错而不是静默降级
replication.factor=3 # 副本数 3,容忍挂 1acks=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. 高性能原因(面试高频总结)
- 分区并行:横向扩展,一个 topic 多个分区同时读写。
- 顺序写:只追加,磁盘顺序写接近内存速度。
- 页缓存:读命中 OS 缓存,不自己管内存。
- 零拷贝:
sendfile让数据不进 JVM 堆。 - 批量 + 压缩:
batch.size+linger.ms+ lz4/zstd。 - 稀疏索引:索引小,内存占用低,二分 + 顺序扫很快。
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 对比
| 维度 | Kafka | RocketMQ | RabbitMQ | Pulsar |
|---|---|---|---|---|
| 定位 | 流平台、日志管道 | 业务消息、事务消息 | 灵活路由、低延迟 | 云原生、存算分离 |
| 吞吐 | 极高(百万级) | 高(十万级) | 中(万级) | 高 |
| 延迟 | ms 级 | ms 级 | μs~ms | ms 级 |
| 顺序 | 分区内有序 | 队列内有序 | 队列内有序 | 分区内有序 |
| 事务消息 | 有(较复杂) | 原生支持 | 不支持 | 有 |
| 延迟消息 | 不支持(要靠外部) | 支持(等级延迟) | 插件支持 | 支持 |
| 运维 | 中等 | 中等 | 简单 | 复杂 |
选型口径:日志/埋点/流处理选 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 事务 | 幂等管单会话单分区去重,事务管跨分区原子 |
Cao Note