KafkaProducer 加载元数据:一条 MetadataRequest 的完整旅程
摘要:第二篇讲了 waitOnMetadata 怎么阻塞、为什么等 60 秒。这篇补上另一面——Sender 线程到底发了什么请求、Broker 返回了什么、客户端怎么把响应变成 Cluster 对象。bootstrap.servers 只是敲门地址,全集群拓扑是一条 MetadataRequest 拉回来的。
关键词:KafkaProducer、MetadataRequest、MetadataResponse、leastLoadedNode、Cluster、Kafka 源码
上一篇没讲完的事
回答一个更基础的问题:KafkaProducer 怎么知道集群里有哪些 Broker、每个分区的 leader 是谁?
bootstrap.servers 只是你配的几个"敲门地址"——Producer 刚构造完时,Cluster 对象里只有这几个节点,没有任何 topic、没有任何分区。它是怎么变成"知道全集群"的?
01 四个阶段:从 bootstrap 到知道 leader
整条链路四步:
- bootstrap 阶段:配置里的地址填进初始 Cluster,不发请求
- Sender 选节点:从已连接节点里挑最闲的那个,发 MetadataRequest
- Broker 返回拓扑:全集群 brokers + 每个 topic 的分区详情
- 客户端解析:新建不可变 Cluster,version++,唤醒等待线程
关键认知:MetadataRequest 不是发给"集群"的,是发给某一个具体 Broker 的。任何一个活着的 Broker 都能返回全集群拓扑——它不需要是 controller,因为元数据在每个 Broker 本地都有缓存。
02 MetadataRequest:请求里到底装了什么
很反直觉——请求体轻得可怜:
{"api_key":3,"topics":[{"name":"my-topic"},{"name":"order-topic"}],"allow_auto_topic_creation":true}就这些。不传分区号、不传节点 ID、不传客户端状态。客户端只说"我想知道这两个 topic 的情况",剩下的 Broker 全知道。
三个要点:
- 只问订阅过的 topic:topics.add() 才会加进列表。不是每次拉全集群——虽然返回的 brokers 列表是全的,但 topics 数组只含你关心的
- 可以自动建 topic:allow.auto.topic.creation 默认 true(老版本),Broker 收到请求如果 topic 不存在会自动建——生产环境建议关掉,用 --create 显式建
- 同一时刻只发一个:in-flight 标记为 true 时,其他线程再 requestUpdate() 也只是排队,不会再发第二个请求——这是防请求风暴的关键设计
03 MetadataResponse:Broker 返回了什么
响应比请求重得多,两大块:
brokers 数组——集群所有节点:
"brokers":[{"node_id":1,"host":"kafka1","port":9092,"rack":"rk1"},{"node_id":2,"host":"kafka2","port":9092,"rack":"rk1"},{"node_id":3,"host":"kafka3","port":9092,"rack":"rk2"}]这就是为什么 bootstrap 只配两个地址也能发现全集群——第一个 Broker 返回的 brokers 列表里就有另外两个。
topic_metadata 数组——每个 topic 的分区详情:
"topic_metadata":[{"name":"my-topic","partitions":[{"partition_index":0,"leader_id":1,"replicas":[1,2,3],"isr":[1,2,3]},{"partition_index":1,"leader_id":2,"replicas":[1,2,3],"isr":[1,2,3]},{"partition_index":2,"leader_id":3,"replicas":[1,2,3],"isr":[1,2]}]}]leader_id就是关键——Producer 知道把消息发给哪个 Broker。isr字段决定 acks=all 时要等几个副本确认。
错误码不致命:LEADER_NOT_AVAILABLE 或 UNKNOWN_TOPIC 时,客户端不会崩,只是这次刷新没拿到新拓扑,等下一轮再试。
04 leastLoadedNode:请求发给哪个 Broker
不是随便挑一个。Sender 用 leastLoadedNode() 选节点:
逻辑很朴素:
- 遍历所有已连接的节点
- 数每个节点的 in-flight 请求数
- 选 in-flight 最少的那个
为什么?MetadataRequest 虽然小,但它走的是同一条 TCP 连接。如果一个节点已经排了 3 个 ProduceRequest 在路上,再塞一个 MetadataRequest 会让元数据响应变慢——业务线程还在等呢。选最闲的节点,元数据刷新最快。
如果没有已连接节点呢?第一次加载就是这种情况——从 bootstrap 列表里挑一个,新建 TCP 连接(可能还要走 SSL 握手、SASL 认证),连上了再发请求。这就是第一次 send() 比后续慢几百毫秒到几秒的原因之一。
05 谁触发了刷新:三条路汇入一个动作
MetadataRequest 不是定时发一次就完了,它会反复触发。三条路:
- 定时刷新:metadata.max.age.ms 默认 300000(5 分钟)。解决 Broker 扩缩容、leader 迁移——你客户端不知道,Broker 自己换了 leader
- 业务线程设标记:send() 时发现 topic 不存在或 leader 是 null,requestUpdate() 置标记
- 被动失效:ProduceRequest 返回 NOT_LEADER_OR_FOLLOWER、连接断开、LEADER_NOT_AVAILABLE——任何一个都触发刷新
三条路最终都到同一个决策点:Sender 线程。它每轮 runOnce 检查:needUpdate 是不是 true?in-flight 元数据请求是不是完成了?两个都满足才发新请求。
这个"谁都能设标记、只有一个线程发请求"的设计是 KafkaProducer 元数据层的核心——如果 100 个业务线程各自发一个 MetadataRequest,Broker 直接被打挂。现在的模型下,不管多少线程设标记,最多同时只有一个 in-flight 元数据请求。
06 配置与排查
| 配置 | 默认值 | 作用 |
|---|---|---|
| bootstrap.servers | 无(必填) | 敲门地址,2~3 个即可 |
| metadata.max.age.ms | 300000 | 元数据最大存活,5 分钟 |
| max.block.ms | 60000 | 等元数据+等缓冲总预算 |
| reconnect.backoff.ms | 50 | 连接失败重试间隔 |
| reconnect.backoff.max.ms | 1000 | 指数退避上限 |
排查时:
- 一直卡在等元数据:先 ping/telnet bootstrap 地址。bootstrap 配错了就是等 60 秒 TimeoutException
- 元数据频繁刷新:JMX 看 metadata-update-rate。如果刷得太频繁,查是不是大量新 topic 在 send
- 某个分区一直 leader=null:Broker 端问题,查该分区是不是在重新选举,或 topic 没建
- 第一次 send 特别慢:正常,新建 TCP + SSL/SASL 握手 + 第一次拉元数据,几百毫秒到几秒
写在最后
元数据加载最容易被误解的地方是"bootstrap.servers 配的就是集群全部地址"。其实不是——它只是你知道的入口。Producer 连上第一个 Broker,用一条 MetadataRequest 把全集群拓扑拉回来,之后就再也不需要 bootstrap 了。
理解了 MetadataRequest 的请求/响应结构、leastLoadedNode 的节点选择、单飞刷新的设计,就理解了 Producer 为什么能做到"随便配两个节点就能发现全集群"——这不是魔法,是一条轻量请求换来的完整拓扑。