ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

KafkaProducer 源码深度剖析:Sender 线程发送消息流程

KafkaProducer 源码深度剖析:Sender 线程发送消息流程

为什么 Sender 是独立线程

前面讲 RecordAccumulator 时反复提到:业务线程只做 append(μs 级内存操作),网络 IO 全部交给 Sender。这个设计是 Kafka 高吞吐的基石。

如果业务线程自己发消息,一次网络 RTT 几 ms,100 个线程并发写就会有 100 条 Socket 连接,Broker 端连接数爆炸。Sender 单线程统一管理网络,连接数 = Broker 数,所有请求串行入队、按 Broker 分组批量发送——这是典型的"单线程 Reactor"模式,和 Netty、Redis 的思路一致。

01 Sender 线程的运行循环

Sender 是一个KafkaThread,在 KafkaProducer 构造函数末尾启动:

this.sender=newSender(logContext,client,this.metadata,this.accumulator,...);ThreadioThread=newKafkaThread("kafka-producer-network-thread | "+clientId,sender,true);ioThread.start();

启动后进入无限循环while (running) { runOnce() },每轮 runOnce 做四件事:

第一步 drain():从 RecordAccumulator 拉取可发送的批次,按 Broker 分组。这一步是"内存 → 网络"的交接点——drain 出来的批次从 Deque 移除,标记为 in-flight,业务线程不再能修改。

第二步 sendProduceRequests():把每个 Broker 的批次列表构建成一个ProduceRequest,调用NetworkClient.send()封装成ClientRequest入队。注意这一步还没真正写 Socket,只是把请求放进 NetworkClient 的待发送队列。

第三步 client.poll():真正的网络 IO。Selector 用 NIO 非阻塞模式把待发送请求写入 Socket,同时检查是否有响应可读。poll 的超时时间是min(pollTimeout, 最近的退避时间)——有重试请求在退避时,poll 会精确等到退避结束再返回,不空转。

第四步 handleResponse():处理 poll 返回的响应。成功 → 回调业务的 Future + 释放 BufferPool 内存;可重试失败 → 批次重新塞回 Deque 队头等下一轮;不可重试失败 → 回调异常。

02 drain:从"按分区"到"按 Broker"的转换

drain 是 Sender 最核心的逻辑,也是理解 Kafka 高吞吐的关键。业务线程按分区写(每个 TopicPartition 一个 Deque),但网络是按 Broker 发的——drain 做的就是这个维度转换。

drain 的执行步骤:

  1. 遍历readyNodes(有 ready 批次的 Broker 集合),只处理这些节点
  2. 对每个节点,遍历它负责的所有分区,从 Deque 队头取 ready 状态的 batch
  3. 限流检查:该节点的 in-flight 请求数 <max.in.flight.requests.per.connection(默认5)才继续
  4. 大小限制:累计字节数 ≤max.request.size(默认1MB),超过就停
  5. 标记 in-flight:batch 从 Deque 移除,baseOffset占位(等 ACK 后填真实 offset)
  6. 按 nodeId 分组:Map<Integer, List<ProducerBatch>>
  7. 如果遇到未知分区(元数据里没有 Leader),触发metadata.requestUpdate()

输出是按 Broker 分组的批次集合——3 个 Broker 的集群,drain 完可能得到 Node-0 有 2 个批次、Node-1 有 1 个批次、Node-2 没有。然后每个非空节点构建一个 ProduceRequest。

这里有个容易忽略的细节:drain 是按节点遍历的,不是按分区遍历的。先确定哪些 Broker 有 ready 批次,再去拉对应分区的批次。这样做的好处是 in-flight 限流是节点级的,不会因为某个分区流量大就把整个节点的配额占满。

03 NetworkClient:非阻塞发送与 InFlight 跟踪

sendProduceRequests 构建完 ProduceRequest 后,调用NetworkClient.send(clientRequest, now)。这一步不写 Socket,只做两件事:

  1. 把请求放进InFlightRequests(每个节点一个双端队列,计数+1)
  2. 把请求关联到对应的KafkaChannel,等下一次 poll 时写入

Selector 的 poll 做什么:

  • pollSelectionKeys():遍历所有已连接的 KafkaChannel,对写就绪的通道调用channel.write()把请求字节发出去,对读就绪的通道调用channel.read()读响应
  • completedSends:写完的请求记录下来
  • completedReceives:读完的响应记录下来
  • disconnected:断开的连接记录下来

poll 返回后,NetworkClient 遍历completedReceives,把每个响应解析成ClientResponse,从 InFlightRequests 中找到对应的请求(按 correlationId 匹配),然后调用request.callback().onComplete(response)。

InFlightRequests 的作用:

  • 限流:每个节点的队列长度 ≤ max.in.flight,超过就不再发
  • 超时检测:poll 时遍历检查nowMs - createdTimeMs > requestTimeoutMs,超时的请求断开连接(避免收到脏响应),按失败处理
  • 顺序保证:同节点的请求按 correlationId 递增,响应按序匹配

04 响应处理:成功、重试、失败三条路

收到 ProduceResponse 后,Sender 按错误码走三条路:

成功(错误码=0):

batch.done(offset,null);// 完成 Future,业务线程拿到 offsetaccumulator.deallocate(batch);// 归还 BufferPool 内存块

注意 deallocate 是在 Sender 线程做的,不是业务线程。业务线程的 Future 完成后只是被唤醒,内存释放由 Sender 负责——这避免了多线程并发操作 BufferPool 的 free 队列。

可重试失败(NotLeaderForPartition、RequestTimedOut 等):

batch.reenqueue();// 塞回 Deque 队头,等下一轮 drain// 下次发送前等待 retry.backoff.ms(默认100ms)

reenqueue 用的是addFirst()——重传的批次已经 ready,应该优先发,不能排在还在攒的新批次后面。这就是前面讲 Deque 双端设计时提到的"重传插队头"。

重试有总超时限制:delivery.timeout.ms(默认120s),从 append 开始算,包含所有重试+退避时间。超过这个时间还没成功,就走失败分支。

不可重试失败(MessageTooLarge、InvalidTopicException 等):

batch.done(-1,exception);// Future 抛异常accumulator.deallocate(batch);// 内存照样释放,不泄漏

05 超时处理:request.timeout.ms vs delivery.timeout.ms

这两个参数容易混淆,明确区分:

  • request.timeout.ms(默认30s):单次请求的超时。从请求发出到收到响应的时间,超过就断开连接、按失败处理。可重试的会重试。
  • delivery.timeout.ms(默认120s):从 append 到最终成功或失败的总时间。包含所有重试+退避。超过这个时间,即使还能重试也放弃,直接回调失败。

约束关系:delivery.timeout.ms ≥ linger.ms + request.timeout.ms,否则配置校验会报错。

超时检测在 poll 里做:每轮 poll 遍历 InFlightRequests 里的所有请求,检查是否超时。超时的请求会触发连接断开(channel.disconnect()),因为不确定对端是否已经收到请求,保留连接可能收到脏响应。

写在最后

Sender 线程的设计精髓可以浓缩成一句话:业务线程管内存,Sender 线程管网络,两者通过 RecordAccumulator 解耦。

drain 做维度转换(分区→Broker),NetworkClient 做非阻塞 IO,InFlightRequests 做限流和超时,响应处理做回调和内存回收——四个组件各司其职,组合成一个高吞吐、低延迟、可重试的发送流水线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进