《Java 消息中间件·Kafka 内核与客户端》10.2 段滚动、索引重建与恢复
把 segment.bytes 调到 1 MB 后写入 4.5 MB 数据,观察段文件如何按 680 条一批滚出 5 个段;删除一个 .index 再模拟崩溃重启,抓住 Could not find offset index file 与 rebuilding index files 两行日志,验证索引确实是从 .log 重建的。
tag
把 segment.bytes 调到 1 MB 后写入 4.5 MB 数据,观察段文件如何按 680 条一批滚出 5 个段;删除一个 .index 再模拟崩溃重启,抓住 Could not find offset index file 与 rebuilding index files 两行日志,验证索引确实是从 .log 重建的。
顺序写为什么快、零拷贝到底省掉哪两次拷贝、Kafka 为什么把缓存交给操作系统?本节把约 10 MB 数据写进单分区,用 kafka-dump-log.sh 验证 625 个批次的 position 首尾相接,再对照 /proc/meminfo 的 Dirty 值与内核脏页参数,解释刷盘抖动从哪里来。
metrics() 构造后有 77 项,跑起来变成 110 项,同一个名字在不同 group 下含义不同。本节把 producer-metrics 里与背压、批次、延迟相关的指标拆开口径:实测打满 1 MiB 缓冲时 buffer-available-bytes 归零、waiting-threads 变 1,并对比客户端 lag 为 0 而服务端 LAG 为 200 的同一条消费链路。
onSend 在调用线程、序列化之前执行,onAcknowledgement 在 Sender 线程、用户回调之前执行,这个位置差决定了埋点能不能测到真实耗时。本节实测 100 条消息的 send→ack 分布,验证链上拦截器抛异常后仍继续、ack 回调里的 headers 只读,以及 onSend 里 sleep 会等量拖慢 send()。
序列化发生在 send() 的调用线程里,而不是 Sender 线程,这决定了异常在哪抛、可变对象何时被读。本节用手写 TLV 格式在同一 topic 混写 v1/v2:严格老读者在第 4 条抛 RecordDeserializationException,宽容老读者能读完却丢掉新字段,复用字段编号则静默读错值。
自动提交不是后台定时器,它只在 poll() 里触发;提交的值是 position,而 position 在 poll 返回记录时就已推进。本节用源码和实测把这个时间差做成丢消息场景:处理 3 条,位移却已提交到 10,close() 时更进一步到 20。
4.3.0 的 Fetcher 只剩 9,378 字节,因为拉取逻辑被拆进了 AbstractFetch、FetchCollector、CompletedFetch 和 FetchBuffer。本节把 poll 的一次完整路径走一遍,说明请求按 leader 节点合并、分区不按 lag 排序,以及 max.poll.records 与两个字节上限的真实关系。
KafkaConsumer 在 4.3.0 里只是一个门面,真正的实现是 ClassicKafkaConsumer 或 AsyncKafkaConsumer,分支点在 ConsumerDelegateCreator 而不是 KafkaConsumer 本身。本节用源码和三次运行把默认值、判定逻辑、以及为什么 getClass() 永远测不出真实实现讲清楚。
Sender 是一个 while 循环:拉就绪分区、按节点抽批、发请求、poll 响应。本节读 4.3.0 的 runOnce、sendProducerData 与 NetworkClient.poll,并用三组对照实验把 max.in.flight 与重试的乱序关系做实:5 个在途出现 9 处乱序,1 个在途与开启幂等均为 0 处。
每个分区一条双端队列、每个批次占满 16 KB 缓冲、整块内存由 BufferPool 统一分配。本节读 4.3.0 的 append、ready 与 allocate,实测 32 KB 缓冲池被打满后 send() 阻塞 5 秒,并纠正一个常见说法:异常不是从 send() 抛出的,而是从 Future 里拿到的。
KafkaProducer 只有两个线程:调用 send() 的应用线程,和跑 Sender 循环的 kafka-producer-network-thread。本节逐行读 4.3.0 的 send() 与 doSend(),实测回调跑在哪个线程、close() 在回调里为什么被降级成零超时,并划出共享状态与线程安全的边界。
group.protocol 一个取值就换掉整套消费组实现:classic 走客户端分配和 JoinGroup/SyncGroup,consumer 走服务端分配和 ConsumerGroupHeartbeat。本节用反射打印真实实现类,核实 4.3.0 源码里的分支,再从 __consumer_offsets 读出 KIP-848 新增的五种记录,并给出迁移时必须先改的配置。
两个消费者加进同一个组,分区怎么从 3-0 变成 2-1?本节用实测时间线拆开 JoinGroup、SyncGroup、Heartbeat 三个阶段,说明 leader 成员在哪一步算分配、eager 与 cooperative 各自撤销哪些分区,以及 stop-the-world 卡在 poll() 的哪一行;再让一个消费者停 poll 超时,看它如何被踢出。
位移不是消息的一部分,它被写进一个叫 __consumer_offsets 的内部 topic。本节从段文件里解出真实的 key/value,说明一个组的位移为什么全落在同一个分区,用实测输出区分「消费位置」与「已提交位移」,并把 --describe 的六列和 offsets.retention.minutes 的边界讲清楚。
客户端的 Metadata 不是一个定时器,而是由「过期」和「显式请求」两个条件驱动的缓存状态机。本篇按 4.3.0 源码逐段读 timeToNextUpdate、requestUpdate、update、failedUpdate 与 NetworkClient 的 maybeUpdate,并用 DEBUG 日志量出 2 秒和 60 秒两种 max.age 下的真实请求次数。
broker 重启后 brokerEpoch 会变,leader 变更会以一条记录进入 __cluster_metadata,再经 broker 的元数据缓存传到客户端。本篇实测分区扩容在 metadata.max.age.ms=5s 与 600s 下的可见时间差,并说明为什么 broker 掉线时客户端反而恢复得很快。
KRaft 把集群元数据本身写成一条 Raft 日志:topic 创建、broker 注册、leader 变更都是 __cluster_metadata 里的记录。本篇用 kafka-dump-log.sh 解出 4.3.0 单节点的真实记录序列,说明 controller quorum 如何选举、元数据如何提交,以及为什么 4.x 不再需要 ZooKeeper。
停掉 leader 之后,分区多久能用?答案取决于集群能不能「干净」地选出新 leader。本节实测三节点 KRaft:停 leader 后 1.8 秒新 leader 就位、数据零丢失;但停掉两个 broker 会让 controller quorum 失去多数派,整个集群连元数据都查不了——这是 KRaft 和 ZooKeeper 时代最容易被忽略的差别。
acks=all 不是「写完就成功」,它真正的含义是「等 ISR 里的副本都写完」。ISR 会收缩,收缩到低于 min.insync.replicas 时写入被拒。本节用三节点集群实测:杀掉一个 broker 仍能写,再杀一个就报 NOT_ENOUGH_REPLICAS,并解释这两个参数为什么要成对出现。
生产者明明返回了 offset 5 到 9,消费者却一条都读不到——这不是 bug,是 LEO 与高水位分离的正常状态。本节用 docker pause 冻结一个 follower,在同一秒里同时读出「leader 磁盘上有 19 条」和「消费者只能看到 16 条」,并给出 stop、pause、崩溃三种失效下 ISR 收缩的真实时序。