KafkaProducer 元数据管理:send() 卡住时,它在等什么

0 阅读7分钟

KafkaProducer 元数据管理:send() 卡住时,它在等什么

摘要:上一篇讲了 KafkaProducer 构造器不发任何网络请求。这篇拆 waitOnMetadata 真实源码——第一次 send() 为什么卡 60 秒,ProducerMetadata 怎么从只记 bootstrap 地址到知道每个分区 leader,以及 STABLE / NEED_UPDATE / IN_FLIGHT 三态状态机。基于 kafka-clients 3.9。

关键词:Kafka、KafkaProducer、ProducerMetadata、Metadata、Cluster、waitOnMetadata、awaitUpdate、Sender、max.block.ms

先回答那个 60 秒

上一篇结尾留了个场景:bootstrap.servers 地址写错,new KafkaProducer 不报错,第一条消息 send 进去卡 60 秒,抛 TimeoutException("Topic my-topic not present in metadata after 60000 ms.")。

这 60 秒里业务线程干了什么?答案是它在一个 Condition 上等一个永远不会到来的 version 递增——因为 bootstrap 地址错了,Sender 根本连不上任何节点,MetadataResponse 永远回不来。整个客户端不是"卡死",是设计上就允许你等这么久。

要讲明白这件事,得先看清元数据在客户端里到底是哪几个对象、各自管什么。

01 两个对象:Metadata 管"何时刷",Cluster 管"长什么样"

很多人以为 ProducerMetadata 就是个 Map,其实不是。kafka-clients 里这层拆成了两个类,职责分得很死:

在这里插入图片描述

Metadata 是基类,管"什么时候该刷新"——它持有一个 Cluster cluster 字段、一个单调递增的 int version、一个 boolean needUpdate、一个 boolean requestInProgress、一个订阅 topic 的 Set<String> topics。它本身不存节点和分区信息,只存调度状态。

ProducerMetadata extends Metadata,加了时间戳字段:lastRefreshMs、lastSuccessfulRefreshMs、lastPartialUpdateMs。定期刷新就靠这些时间戳和 metadata.max.age.ms 比大小。

Cluster 才是数据本体——不可变快照。它里面是 Map<Integer, Node> nodesById 和 Map<TopicPartition, PartitionInfo> byTp。PartitionInfo 里有 leader、replicas、inSyncReplicas、offlineReplicas。关键设计:Cluster 不可变。update 时不是改旧的,是 new 一个新 Cluster 整个替换掉 Metadata 里的引用——读线程永远不会看到半更新状态。

读路径和写路径是分开的:业务线程调 metadata.fetch() 直接拿 Cluster 引用,无锁;Sender 线程拿到 MetadataResponse 后调 metadata.update(...),持锁写新 Cluster、version++、listeners.signalAll()。读不抢写,写不阻塞读——这是 send() 高吞吐的关键之一。

构造完的 Cluster 长什么样?只有 bootstrap.servers 解析出来的那几个 InetSocketAddress 转成的 Node,没有任何 topic、没有任何分区、leader 全是 null。这就是为什么第一次 send 必须阻塞。

02 第一次 send():waitOnMetadata 的真实代码

看 KafkaProducer.send() 的链路。表面是一行 producer.send(record),实际走到 doSend(),第一个关键调用就是 waitOnMetadata()。

源码核心逻辑(kafka-clients 3.9,略有简化):

private ClusterAndWaitTime waitOnMetadata(String topic, Integer partition,
        long now, long remainingWaitMs) {
    Cluster cluster = metadata.fetch();
    Integer partitionsCount = cluster.partitionCountForTopic(topic);
    // 已经知道分区数,直接返回
    if (partitionsCount != null &&
        (partition == null || partition < partitionsCount)) {
        return new ClusterAndWaitTime(cluster, 0);
    }

    // 不知道这个 topic,需要刷元数据
    long nowMs = time.milliseconds();
    long remaining = Math.max(0, remainingWaitMs);
    long version = metadata.requestUpdate();  // ① 标记需要更新
    sender.wakeup();                          // ② 叫醒 Sender 线程
    try {
        metadata.awaitUpdate(version, remaining);  // ③ 阻塞等新版本
    } catch (TimeoutException ex) {
        throw new TimeoutException("Topic " + topic +
            " not present in metadata after " + ...);
    }
    return new ClusterAndWaitTime(metadata.fetch(), waitTime);
}

三个动作,逐一看:

① metadata.requestUpdate() 返回当前 version。这个调用不发网络请求,只是把 needUpdate 置 true,返回一个旧版本号 v。业务线程拿着 v 去等——只等"比 v 新的版本"。

② sender.wakeup()。Sender 线程在 NetworkClient.poll() 里阻塞等网络事件,wakeup 让它下一轮醒过来检查 metadata.updateRequested()。这就是为什么业务线程和 Sender 线程之间不需要直接通信——一个置标记,一个轮询。

③ metadata.awaitUpdate(version, remaining)。这是真正阻塞的地方:

public synchronized void awaitUpdate(final long lastVersion, long maxWaitMs) {
    long begin = time.milliseconds();
    long remainingWaitMs = maxWaitMs;
    while (version == lastVersion) {
        if (time.milliseconds() - begin >= maxWaitMs)
            throw new TimeoutException("Failed to update metadata after ...");
        time.wait(remainingWaitMs);   // Condition.await
        remainingWaitMs = maxWaitMs - (time.milliseconds() - begin);
    }
}

它是 synchronized 方法,wait 在 this 的 Condition 上。多个业务线程同时进来,全都阻塞在这把锁的条件队列上——但只触发一次 RPC。因为 requestUpdate() 里如果 requestInProgress=true,就不重复标记,只记一笔。

在这里插入图片描述

Sender 那一侧呢?它的 runOnce() 里有这么一段:

if (metadata.updateRequested() && !hasInFlightFetch()) {
    Node node = client.leastLoadedNode(time.milliseconds());
    if (node != null) {
        List<Node> nodes = fetchTargets ...;
        NetworkClientUtils.maybeUpdateMetadata(now, ...);
    }
}

leastLoadedNode() 选连接数最少的节点——bootstrap 阶段就是连得上的那个,不挑。MetadataRequest 发过去,Broker 返回全集群元数据。回来后 Sender.handleResponse() 调 metadata.update(newCluster):新建 Cluster、version++、listeners.signalAll()——所有阻塞在 awaitUpdate 上的业务线程同时被唤醒,各自重新读 Cluster,这次 leader != null,继续走 RecordAccumulator.append()。

超时分支也在这:awaitUpdate 里 while 循环检查到时间到,抛 TimeoutException。等元数据和等缓冲共享同一个 max.block.ms 预算——不是先等元数据 60 秒再等缓冲 60 秒,是一共就 60 秒。

03 三条路径只是"设标记",决策只有一个

很多教程把元数据刷新讲成"三条独立机制",其实它们最终都汇入同一个状态机:

在这里插入图片描述

三个状态:

  • STABLE:needUpdate=false, requestInProgress=false。Cluster 是新的,业务线程直接读。
  • NEED_UPDATE:needUpdate=true。Sender 下一轮会发 MetadataRequest。
  • IN_FLIGHT:请求已发出,等响应。这期间其他线程调 requestUpdate() 只更新标记,不重复发请求。

三条路径触发的都是"从 STABLE 走到 NEED_UPDATE":

定时刷新。Sender.runOnce() 里每次检查 now - lastSuccessfulRefreshMs >= metadata.max.age.ms,默认 300000(5 分钟)。到点主动发全量——解决 Broker 上下线、leader 迁移、新 topic 创建这些客户端"没遇到"的变化。

被动失效。不等 5 分钟:发送时发现 leader == null、收到 LEADER_NOT_AVAILABLE / NOT_LEADER_OR_FOLLOWER、连接 Broker 失败、TopicAuthorizationException——任何一个都直接 requestUpdate(topic)。连接失败还有指数退避,reconnect.backoff.ms 默认 50ms,最大 reconnect.backoff.max.ms 默认 1s,避免疯狂重试打挂集群。

订阅集合变更。topics.add(topic) 时自动触发一次全量更新。元数据请求只问订阅过的 topic,不是全集群拉取——这是为什么客户端规模大了以后元数据请求量可控。

为什么要这么设计?因为发元数据请求是个重操作(要新建 TCP、序列化全集群拓扑)。如果业务线程一发现 leader 没了就发一个,并发场景下就是请求风暴。现在的模型是:谁都可以设标记,只有 Sender 线程一个决策点发请求,in-flight 时排队。

04 配置和 JMX 怎么看

和元数据相关的配置,按排查优先级排:

  • max.block.ms(60000):send() 在元数据和缓冲上花的总时间预算。这就是上一篇那个 60 秒超时。
  • metadata.max.age.ms(300000):元数据最大存活。拓扑稳定维持默认,扩缩容频繁调到 30~60 秒。
  • request.timeout.ms(30000):单次 RPC 超时。比 max.block.ms 小,所以一次请求超时后客户端还会重试几次,直到总预算用完。
  • retry.backoff.ms(100):刷新失败后的退避。

线上区分元数据问题还是缓冲问题,看 JMX 三个指标:

  • kafka.producer:type=Producer-metrics,client-id=xxx,name=buffer-available-bytes 持续为 0 → 缓冲耗尽,和元数据无关。
  • ...name=metadata-age 持续走高 → 元数据一直刷不新,查连通性或 topic 是否存在。
  • ...name=record-send-rate 为 0 但 ...name=record-network-send-rate 也低 → 消息进缓冲了但 Sender 发不出去,多半是元数据没拿到。

05 排查清单

把前面的内容收成可执行的清单:

  • send() 卡 60 秒 TimeoutException:先 telnet bootstrap-host port 看通不通、topic 在 Broker 上存不存在、metadata-age 在不在涨。别上来就把 max.block.ms 调到 300000——那是让客户端多等 5 分钟,根因没解决。
  • 首次 send 比后续慢几百毫秒到几秒:正常,第一次要走完整时序。如果超过 5 秒,查网络到 bootstrap 的 RTT。
  • 偶发 NOT_LEADER_OR_FOLLOWER:leader 在切换,客户端自动刷新重试。频繁出现就查 Broker 是不是在滚动重启。
  • 元数据请求量异常:查是不是 send 了大量不同 topic——每个新 topic 触发一次全量更新。
  • JMX 指标对不上:确认 client.id 显式配了,否则多个 Producer 的指标混在一起。

写在最后

元数据这层最迷惑人的地方是它"看起来不工作"——new 完不连、send 了卡 60 秒、topic 不存在不报错。这些都不是 bug,是懒加载加单飞请求的设计结果。理解了 requestUpdate / awaitUpdate / version++ 这三个动作,就理解了 KafkaProducer 为什么是这种行为。