【Kafka 源码手记】05 Broker 网络层与请求处理(中):Processor 事件循环与线上的字节

上篇我们详细看了 Acceptor 的工作流程,也对 Processor 有了一些简单了解,最后收在安全握手:listener 的身份在 Processor 构造时变成了 ChannelBuilder,握手在事件循环里逐 poll 推进,ready() 之前业务字节一个不读。这一篇接着往下走------连接就绪之后,Processor 的七步循环怎么流转、一条连接为什么同一时刻只能有一个在途请求、线上的字节怎么被切成一帧一帧的请求、双方又怎么对表版本,以及这整件事为什么没有交给 Netty 去做。

  • 「一条连接同一时刻只有一个在途请求」是怎么用 NIO 实现的?为什么这么设计?
  • apiKey / apiVersion / correlationId 各自干什么?版本协商谈不拢时怎么办?
  • Kafka 为什么放着 Netty 不用,要自己实现 NIO?

Processor:事件循环,与一条连接的「静音」

Processor 是网络层真正的主力:socket 读写、握手推进、成帧、入队、写响应,全在这一个线程的循环里。我们还是先看看 Processor 的全景图,图里标出了它与世界相连的全部通道------四个入口队列/事件源,一个出口。

每个 listener 名下有 num.network.threads(默认 3)个 Processor,线程名 data-plane-kafka-network-thread-{nodeId}-{listener}-{secProto}-{id}(SocketServer.scala:746 )。主循环分七步(:889 的 run()):

scala 复制代码
override def run(): Unit = {
  try {
    while (shouldRun.get()) {
      try {
        // setup any new connections that have been queued up
        configureNewConnections()      // ①
        // register any new responses for writing
        processNewResponses()          // ②
        poll()                         // ③
        processCompletedReceives()     // ④
        processCompletedSends()        // ⑤
        processDisconnected()          // ⑥
        closeExcessConnections()       // ⑦
      } catch {
        case e: Throwable => processException("Processor got uncaught exception.", e)
      }
    }
  } finally {
    // ...
    Utils.swallow(this.logger.underlying, Level.ERROR, () => closeAll())
  }
}
步骤 方法 行号 功能
① configureNewConnections SocketServer.scala:1168 把 Acceptor 塞进 newConnections 的通道注册到 selector
② processNewResponses :933 处理 handler 排进 responseQueue 的五种 Response(本节后半展开)
③ poll :991 selector.select(空闲 300ms,有待办立刻返回,:992-993),读写就绪的通道在此推进(握手也在其中)
④ processCompletedReceives :1002 把完整收到的请求解析、包装、入 RequestChannel,并 mute 连接
⑤ processCompletedSends :1062 响应发完的收尾:记账、回调、unmute
⑥ processDisconnected :1093 清理断连通道
⑦ closeExcessConnections :1111 关掉超过配额的连接

上篇中我们用 Docker 起了一个 Kafka 集群,我们查看 Processor 线程栈,恰好落在 ③ 的 poll 上------Processor.run(SocketServer.scala:897 )调用 poll(:993 ),poll 里再调 clients 模块 Selector.poll(Selector.java:470 ),一路下钻到 epoll 的 select 等事件:

text 复制代码
"data-plane-kafka-network-thread-1-PLAINTEXT-PLAINTEXT-0" #63 [1199] runnable
  at sun.nio.ch.EPoll.wait(...)
  at sun.nio.ch.EPollSelectorImpl.doSelect(...)
  at sun.nio.ch.SelectorImpl.lockAndDoSelect(...)
  at sun.nio.ch.SelectorImpl.select(...)
  at org.apache.kafka.common.network.Selector.select(Selector.java:880)
  at org.apache.kafka.common.network.Selector.poll(Selector.java:470)
  at kafka.network.Processor.poll(SocketServer.scala:993)
  at kafka.network.Processor.run(SocketServer.scala:897)

run 停在 :897 的 poll() 调用那一行,poll 自己停在 :993 的 selector.select 上------栈顶在等事件,这就是一个网络线程最常驻的状态。

对照图看:Acceptor 塞入 newConnections(① 消费),selector 事件驱动 ③,④ 的产物离开网络层去 RequestChannel,handler 侧的响应塞 responseQueue(② 消费),⑤ 完成回环。七步里 ③ 是发动机,其余都是它的前后处理。

先说成帧。「多少字节算一个请求」的知识属于网络层 :Processor 在 ④ 里从字节流解出请求头(SocketServer.scala:775 的 parseRequestHeader,object Processor 下的静态方法),检查 API 是否启用,甚至 KIP-511 之后连 ApiVersionsRequest 都在 Processor 层被截下来记录客户端软件名(:1032 )------这些工作根本轮不到 handler 线程。handler 拿到的已经是解析好的 Request 对象。分层的边界画在这里,而不是画在「读到字节」和「处理字节」之间。成帧的具体规则(4 字节长度前缀、请求头四字段)是下一节的主角。

再说本篇我认为的整个网络层的灵魂:一条连接同一时刻只允许一个在途请求 。看 ④ 的核心段落(SocketServer.scala:1021-1043):

scala 复制代码
val connectionId = receive.source
val context = new RequestContext(header, connectionId, channel.socketAddress, ...)
req = new RequestChannel.Request(processor = id, context = context, ...)
// KIP-511: ApiVersionsRequest is intercepted here to catch the client software name
// and version. ...
if (header.apiKey == ApiKeys.API_VERSIONS) { /* ... */ }
requestChannel.sendRequest(req)
selector.mute(connectionId)

请求从连接上读出来、刚递给 RequestChannel,下一行就是 selector.mute(connectionId)。所谓 mute(静音) ,实现是去掉这条连接在 selector 上的「读兴趣」(clients/src/main/java/org/apache/kafka/common/network/KafkaChannel.java:252):

java 复制代码
void mute() {
    if (muteState == ChannelMuteState.NOT_MUTED) {
        if (!disconnected) transportLayer.removeInterestOps(SelectionKey.OP_READ);
        muteState = ChannelMuteState.MUTED;
    }
}

boolean maybeUnmute() {
    if (muteState == ChannelMuteState.MUTED) {
        if (!disconnected) transportLayer.addInterestOps(SelectionKey.OP_READ);
        muteState = ChannelMuteState.NOT_MUTED;
    }
    return muteState == ChannelMuteState.NOT_MUTED;
}

NIO 里每个通道注册到 selector 时带一个 interest 集合,OP_READ 表示「这条通道可读时请唤醒我」;removeInterestOps(OP_READ) 之后,哪怕内核缓冲区里堆满字节,selector 也不会再为这条通道报告可读事件------注意不是「读到不处理」,是事件循环根本不被告知。mute 就是给连接戴上耳塞;unmute(maybeUnmute,注意没有无条件版本)把 OP_READ 加回来。从时序上看:

读出请求 → mute → 响应写完(⑤ 的 processCompletedSends,:1078 tryUnmuteChannel)→ unmute。请求与响应在一条连接上严格交替,服务端永远不会读到第二条请求,除非第一条的响应已经出门。

为什么要这么设计?看到这段代码时我的第一反应是「太保守了,HTTP 都有 pipelining」,这和我们平时接触的网络实现都不太一样。仔细一想又不无道理:Kafka 协议本来就是一问一答式的 RPC,客户端发出请求后本来就只能干等响应;既然如此,让 broker 在响应前继续从这条连接读请求,就等于允许客户端无节制地推送在途请求,broker 还得为每条连接维护请求表。mute 把这条路直接焊死------单连接的并发就是 1,想并行的客户端自己开多条连接(04 篇说的按 nodeId 建立连接,就是如此)。代价是单连接吞吐被 RTT 加处理时长限制,但 Kafka 的客户端从不指望单连接扛吞吐量,这个取舍就没有问题。

其实,mute 还被复用作了配额节流的执行机制 。handler 线程判定某连接超配额后,不能直接去改 Processor 的 selector------那是别人的线程状态------于是包装成一条 StartThrottlingResponse 排进响应队列,让 Processor 自己把连接静音,到期再排 EndThrottlingResponse 解除。这五种 Response 的分派就在七步循环的 ② processNewResponses(SocketServer.scala:933):

scala 复制代码
private def processNewResponses(): Unit = {
  var currentResponse: RequestChannel.Response = null
  while ({currentResponse = dequeueResponse(); currentResponse != null}) {
    val channelId = currentResponse.request.context.connectionId
    try {
      currentResponse match {
        case response: NoOpResponse =>
          // There is no response to send to the client, we need to read more pipelined requests
          // that are sitting in the server's socket buffer
          updateRequestMetrics(response)
          // Try unmuting the channel. If there was no quota violation and the channel has not been throttled,
          // it will be unmuted immediately. ...
          handleChannelMuteEvent(channelId, ChannelMuteEvent.RESPONSE_SENT)
          tryUnmuteChannel(channelId)

        case response: SendResponse =>
          sendResponse(response, response.responseSend)
        case response: CloseConnectionResponse =>
          updateRequestMetrics(response)
          close(channelId)
        case _: StartThrottlingResponse =>
          handleChannelMuteEvent(channelId, ChannelMuteEvent.THROTTLE_STARTED)
        case _: EndThrottlingResponse =>
          // Try unmuting the channel. The channel will be unmuted only if the response has already been sent out to
          // the client.
          handleChannelMuteEvent(channelId, ChannelMuteEvent.THROTTLE_ENDED)
          tryUnmuteChannel(channelId)
        case _ =>
          throw new IllegalArgumentException(s"Unknown response type: ${currentResponse.getClass}")
      }
    } catch { /* ... */ }
  }
}
Response 类型 谁产生 Processor 动作
SendResponse handler 正常应答 注册写事件,发回客户端
NoOpResponse acks=0 的 PRODUCE 等无需应答 记账 + 尝试 unmute,放行下一条读
CloseConnectionResponse handler 判定该断 记账 + 关连接
StartThrottlingResponse 配额判定超限 mute(静音一段时间)
EndThrottlingResponse 节流到期 尝试 unmute

「谁拥有状态,谁来改」------连节流都要排队回到拥有者线程执行。

这里,我们又可以回到「上篇」中没解释的问题:Processor 创建 Selector 用的不是什么服务端专用类,而是 org.apache.kafka.common.network.Selector------客户端模块的原班代码(SocketServer.scala:864 的 createSelector,上篇安全握手一节看到的闸门就在这个类的 pollSelectionKeys 里)。客户端与服务端共用同一份传输层意味着什么,接着往下看。

线上的字节:请求头、ApiKeys 与版本协商

上一节反复说「成帧发生在网络层」,那成帧的规则是什么?我们慢慢来看:TCP 之上没有任何握手或分隔符,一条连接就是一串「4 字节长度 + 长度所指的请求」的循环。收帧的载体是 NetworkReceive(clients/src/main/java/org/apache/kafka/common/network/NetworkReceive.java ):先攒 4 字节的 size 缓冲区(:62 ,ByteBuffer.allocate(4)),读满后 rewind 取出整数 N,再攒 N 字节的 body:

java 复制代码
public long readFrom(ScatteringByteChannel channel) throws IOException {
    int read = 0;
    if (size.hasRemaining()) {
        int bytesRead = channel.read(size);
        if (bytesRead < 0)
            throw new EOFException();
        read += bytesRead;
        if (!size.hasRemaining()) {
            size.rewind();
            int receiveSize = size.getInt();
            if (receiveSize < 0)
                throw new InvalidReceiveException("Invalid receive (size = " + receiveSize + ")");
            if (maxSize != UNLIMITED && receiveSize > maxSize)
                throw new InvalidReceiveException("Invalid receive (size = " + receiveSize + " larger than " + maxSize + ")");
            // ...

maxSize 的服务端取值是 socket.request.max.bytes(默认 100MB,server/src/main/java/org/apache/kafka/network/SocketServerConfigs.java:95-96)------网络层在这一步就把恶意或误配置的超大帧拦下。请求体内部不再套一层帧长度,字段怎么切由 schema 决定:字符串、数组各带自己的长度与计数前缀。「协议自描述」说的是边界知识长在 schema 里,不长在流上。

请求头:四个字段定身份

body 的开头是请求头。头从 v1 起只有四项,四个字段各有差事:

字段 类型 干什么
apiKey int16 API 类型编号:PRODUCE=0、FETCH=1、ApiVersions=18......
apiVersion int16 这套 schema 的第几版;同一个 FETCH,v4 与 v18 的字段表完全不同
correlationId int32 客户端自增的编号,响应头里原样带回(clients/src/main/java/org/apache/kafka/common/requests/RequestHeader.java:119 的 toResponseHeader);它是防张冠李戴的校验凭证,不参与请求-响应的配对(原因见「版本协商」第 4 点)
clientId nullable string 写给服务端看:日志与指标拿它当键;配额分组 <user, client-id> 的一半

解析代码在 RequestHeader.java:123 的 parse(),顺序需要留意------先「偷」看 apiKey 与 apiVersion 两个 int16,推出这条请求该用哪个版本的头,再把 buffer 倒回去、按那个头版本正式解一遍:

java 复制代码
public static RequestHeader parse(ByteBuffer buffer) {
    short apiKeyId = -1;
    try {
        // We derive the header version from the request api version, so we read that first.
        // The request api version is part of `RequestHeaderData`, so we reset the buffer position after the read.
        int bufferStartPositionForHeader = buffer.position();
        apiKeyId = buffer.getShort();
        short apiVersion = buffer.getShort();
        ApiKeys apiKey = ApiKeys.forId(apiKeyId);
        // ...

        short headerVersion = apiKey.requestHeaderVersion(apiVersion);
        buffer.position(bufferStartPositionForHeader);
        final RequestHeaderData headerData = new RequestHeaderData(new ByteBufferAccessor(buffer), headerVersion);
        // ...

头的版本不由协商决定,由请求自带的 apiVersion 推导(注释原话:"We derive the header version from the request api version ")。为什么能这么做?因为 int16 的 apiKey 和 apiVersion 在所有头版本里都在最前面、定长------先读它们再决定按哪版解,是自描述协议常用的「引导」技巧。

ApiKeys

apiKey 的全集是 clients/src/main/java/org/apache/kafka/common/protocol/ApiKeys.java 的枚举,4.3.1 里包括 93 项,从 PRODUCE 一路排到 share group 与 Raft voter 管理一族。编号一经发布永久不变,所以清单上留着空洞:对客户端稳定开放的列表里,3 之后直接是 8;枚举里那些 LEADER_AND_ISR、VOTE、ENVELOPE 是 broker 间与 Controller 内部的指令,不背对客户端的兼容承诺。全景图里那句「KafkaApis.handle() 按 apiKey 分发」,落到代码上是下篇会展开的巨型 match,分发表的键就是这个枚举。

读这个枚举的源码会遇到一个小小的迷宫:枚举项的构造参数是 ApiMessageType.PRODUCE 这样的引用,id、name、版本区间全部来自它------但如果没有编译一遍 Kafka 代码,ApiMessageType 这个类在源码树里是找不到的。这不是源码缺失,也不是引用了其它仓库中的内容,这些代码是编译期生成的。

生成器在 generator/src/main/java/org/apache/kafka/message/ApiMessageTypeGenerator.java (public enum ApiMessageType 的模板在 :144 ),驱动它的是根 build.gradle 里 project(':clients') 块注册的 processMessages 任务(:1988 起):

groovy 复制代码
task processMessages(type:JavaExec) {
  mainClass = "org.apache.kafka.message.MessageGenerator"
  args = [ "-p", "org.apache.kafka.common.message",
           "-o", "${projectDir}/build/generated/main/java/org/apache/kafka/common/message",
           "-i", "src/main/resources/common/message",
           "-t", "ApiMessageTypeGenerator",
           "-m", "MessageDataGenerator", "JsonConverterGenerator" ]
}

输入是 clients/src/main/resources/common/message/ 下的 198 个消息定义 json(ProduceRequest.json 、FetchRequest.json ......每个 API 一对 request/response),输出写进 build/generated/,再经 sourceSets 并入编译。每个 json 里写着这个 API 的 validVersions、各个版本的字段表、是否 flexible------ApiKeys 里的版本区间、KafkaApis 分发用的 schema,源头都是这批 json。这套「单一事实来源 + 编译期生成」的机制使得改协议只需改 json,枚举与消息类自动再生,不存在两处手工同步的问题。

版本协商:从探测到定版

93 种 API 各自演进,而双向兼容(新客户端连老 broker、老客户端连新 broker)要成立,双方必须先对表。这套协商是连接建立后的第一件事;broker 侧跨着两个线程------网络层的 Processor 与 handler 侧的 KafkaApis,中间经 RequestChannel 交接。整套流程如下:

图中 broker 侧的两条竖线正是上一节的主角:Processor 管收发与 mute/unmute,KafkaApis 管判定与应答,一来一回正好走完七步循环的 ②③④⑤。时序的四个环节,逐一对应代码。

  1. 谁发起 :连接建立后,客户端在 handleConnections(clients/src/main/java/org/apache/kafka/clients/NetworkClient.java:1081 ,登记动作在 :1088 )里把节点放进待协商登记表 nodesNeedingApiVersionsFetch,handleInitiateApiVersionRequests(:1097 )随后发出请求。有个细节需要留意:初始探测发的不是 v0,而是客户端自己支持的最高版本 。代码证据有两处:无参的 new ApiVersionsRequest.Builder() 给出的允许区间是 [oldestVersion, latestVersion] 全区间(clients/src/main/java/org/apache/kafka/common/requests/ApiVersionsRequest.java:40-44 );真正定版发生在 doSend(NetworkClient.java:551 )------此刻还没有对表缓存,versionInfo 为 null,走的是这段:

    java 复制代码
    NodeApiVersions versionInfo = apiVersions.get(nodeId);
    short version;
    // Note: if versionInfo is null, we have no server version information. This would be
    // the case when sending the initial ApiVersionRequest which fetches the version
    // information itself. ...
    if (versionInfo == null) {
        version = builder.latestAllowedVersion();
        ...
    } else {
        version = versionInfo.latestUsableVersion(clientRequest.apiKey(), builder.oldestAllowedVersion(),
            builder.latestAllowedVersion());
    }

    注释原话点破了场景:发初始 ApiVersions 时正是没有服务端版本信息的时刻,于是取 builder 的最高允许版;协商完成后再发请求,同一处代码走 else 分支、按双方交集的最高可用版定版------「先按最高版发,拿不到再降」在 doSend 里一目了然。(这是 NetworkClient 的常规协商路径;上篇 SASL 认证里那一步的 ApiVersions 是另一回事,仍固定 v0,不要混为一谈。)

  2. broker 怎么回答 :core/src/main/scala/kafka/server/KafkaApis.scala:1518 的 handleApiVersionsRequest,三分支:

scala 复制代码
def createResponseCallback(requestThrottleMs: Int): ApiVersionsResponse = {
  val apiVersionRequest = request.body[ApiVersionsRequest]
  if (apiVersionRequest.hasUnsupportedRequestVersion) {
    apiVersionRequest.getErrorResponse(requestThrottleMs, Errors.UNSUPPORTED_VERSION.exception)
  } else if (!apiVersionRequest.isValid) {
    apiVersionRequest.getErrorResponse(requestThrottleMs, Errors.INVALID_REQUEST.exception)
  } else {
    apiVersionManager.apiVersionResponse(requestThrottleMs, request.header.apiVersion() < 4)
  }
}

正常路径下 broker 回一张全量清单,每个 apiKey 配一段支持的 [min, max],客户端取交集的最高值使用;这张表只对当条连接有效,断线重连需要重拿。方法开头有一大段注释:broker 无论认证状态如何都答全表(SASL 握手前客户端就需要它,见上篇安全握手一节),代价是泄露 broker 版本信息,补救靠 SSL 双向认证提前把关。

  1. 谈不拢怎么办------这是协商体系自己的鸡生蛋问题 :ApiVersions 自己也是个 API,版本谈不拢时连「清单都拿不到」。解法分两端。
  • broker 端,ApiVersionsRequest 的构造器注释(clients/src/main/java/org/apache/kafka/common/requests/ApiVersionsRequest.java:84-88 )写明了策略:收到比支持范围更新的版本,当作 v0 处理、按 v0 的 schema 回响应。于是 getErrorResponse(:111 )在 UNSUPPORTED_VERSION 时会塞进一条特殊数据:
java 复制代码
public ApiVersionsResponse getErrorResponse(int throttleTimeMs, Throwable e) {
    ApiVersionsResponseData data = new ApiVersionsResponseData()
        .setErrorCode(Errors.forException(e).code());
    // ...
    // Starting from Apache Kafka 2.4 (KIP-511), ApiKeys field is populated with the supported
    // versions of the ApiVersionsRequest when an UNSUPPORTED_VERSION error is returned.
    if (Errors.forException(e) == Errors.UNSUPPORTED_VERSION) {
        ApiVersionCollection apiKeys = new ApiVersionCollection();
        apiKeys.add(ApiVersionsResponse.toApiVersion(ApiKeys.API_VERSIONS));
        data.setApiKeys(apiKeys);
    }
    return new ApiVersionsResponse(data);
}

错误码是 UNSUPPORTED_VERSION,但 api_keys 列表里填上唯一一条------ApiVersions 自己支持的版本区间。

  • client 端,NetworkClient.java:1023 的 handleApiVersionsResponse 收到这个错误码后,从 v0 响应里取出那条区间,以区间上限为版本降级重发 (:1036-1043 );重发成功,apiVersions.update 缓存对表、connectionStates.ready 置连接就绪(:1052-1053)------协商完成之前,这条连接上别的请求一个也不许发。v0 由此成了这套握手的永久兜底,版本协商体系自己的引导程序。
  1. correlationId 为什么只是校验凭证 :表里把 correlationId 的差事写成「校验」而非「配对」,原因两端各有一半。客户端侧真正的配对靠目的地:clients/src/main/java/org/apache/kafka/clients/InFlightRequests.java:34 用 Map<节点, 在途请求队列> 管理,响应到达时按来源节点从队列头取出(FIFO);correlationId 只做一致性校验,对不上抛 CorrelationIdMismatchException(clients/src/main/java/org/apache/kafka/common/requests/AbstractResponse.java:104-109 )。服务端侧更简单:mute 保证了单连接单在途,顺序本身就是配对。配对在两端都已有更强的机制兜底,correlationId 剩下的差事就是在异步收发里抓住张冠李戴------这也是响应头把它原样带回的全部目的。

tagged fields:加字段而不升版本

版本区间再宽,schema 也不能每加一个字段就升一版。KIP-482(2.4.0 起)给出了另一条路:每个 API 从某个版本起成为「flexible」,请求头升到 v2、字符串与数组改用紧凑编码,消息末尾多出一个标签区------tagged fields。头版本的判定入口是 ApiKeys.requestHeaderVersion(clients/src/main/java/org/apache/kafka/common/protocol/ApiKeys.java:303 ),但方法体只有一行------委托给 messageType.requestHeaderVersion,又是前面 ApiKeys 一节那个编译期生成的 ApiMessageType。

它生成出来到底干什么,我们分两层来看。生成模板是 generator/src/main/java/org/apache/kafka/message/ApiMessageTypeGenerator.java:346 的 generateHeaderVersion(request 在 :178 调用、response 在 :180 ):对每个 API 生成一个 case,按 flexibleVersions 对请求版本做条件判断------落在 flexible 区间内,request 头返回 v2、response 头返回 v1,区间外则是 v1 与 v0;未知 apiKey 走 default 抛 UnsupportedVersionException(:387-391)。

模板里唯一的特例恰好呼应前面版本协商一节:ApiVersionsResponse(apiKey 18)无论版本多高,响应头固定 v0(:362-365 ,注释指名 KIP-511)------v0 兜底能成立,一半靠错误响应里附的那条版本区间,另一半靠这里把响应头也钉在最老版本,任何客户端都解析得了。而编译一遍之后,生成结果就落在 clients/build/generated/main/java/org/apache/kafka/common/message/ApiMessageType.java:755 的 requestHeaderVersion 里------case 18 的条件分支、:1194 处固定返回 0 的特例。

标签区的线上格式很朴素:一个 unsigned varint 写字段个数,随后每个字段三段------tag 编号(varint)、长度(varint)、内容字节。varint 的读写由 ByteUtils 提供(readUnsignedVarint,clients/src/main/java/org/apache/kafka/common/utils/ByteUtils.java:159 ;writeUnsignedVarint,:344)。

解析方拿到标签区怎么办?*Data 类是编译期生成的,但生成模板就在 generator/src/main/java/org/apache/kafka/message/MessageDataGenerator.java 里,读入模板(:476-530)产出的代码骨架是:

java 复制代码
int _numTaggedFields = _readable.readUnsignedVarint();
for (int _i = 0; _i < _numTaggedFields; _i++) {
    int _tag = _readable.readUnsignedVarint();
    int _size = _readable.readUnsignedVarint();
    switch (_tag) {
        case 0: { /* 按 schema 解出对应字段 */ break; }
        // ...
        default:
            this._unknownTaggedFields = _readable.readUnknownTaggedField(this._unknownTaggedFields, _tag, _size);
            break;
    }
}

认识的 tag 按 schema 解;不认识的原样存进 _unknownTaggedFields,写出时由 RawTaggedFieldWriter.forFields(_unknownTaggedFields) 数进总数、逐段写回(写出模板 :707-882 ,未知字段的回收在 :783-784 )。也就是说,未知标签不是丢掉了事,而是原样穿过去------中间节点转发自己不理解的请求时,新加的字段不会在转发途中丢失。写出模板里还有个值得留意的细节:已知 tagged 字段先做非默认值检查(:763-766 ),值等于默认就整个不写------「不出现时一个字节都不花」落到代码上,就是 _numTaggedFields++ 之前的那道条件。

我们就以本节的主角为例。ApiVersionsResponse 的 v3 是第一个 flexible 版本,后来要加的四个字段没进主 schema、全部挂 tag(clients/src/main/resources/common/message/ApiVersionsResponse.json:48-77 ):SupportedFeatures(tag 0)、FinalizedFeaturesEpoch(tag 1)、FinalizedFeatures(tag 2)、ZkMigrationReady(tag 3)。不认识这些 tag 的客户端连上新 broker,跳过即可,响应照常解析------加字段时连版本号都没动。到 v4 才升了一版,修的还只是语义(KAFKA-17011:SupportedFeatures.MinVersion 为 0 时也要下发,ApiVersionsResponse.json:31 ),一个字段没加。v3 的版本注释里还有句要紧的话:tagged fields 只进 body 不进响应头,「头的长度不能变,以保证向后兼容」(:24-26 )------头是双方引导解析的起点,连 flexible 化都动它不得,这与前面 RequestHeader.parse 先「偷」看定长 apiKey/apiVersion 的逻辑是一体的两面。

FetchRequest 的 ClusterId(tag 0,12+,clients/src/main/resources/common/message/FetchRequest.json:64-66)则展示了另一半好处:consumer 通常不知道 clusterId,默认 null 就不带这个字段,需要时才写。

FetchResponse 的分区级还有一组:DivergingEpoch / CurrentLeader / SnapshotId(tag 0/1/2,12+,clients/src/main/resources/common/message/FetchResponse.json:79-101),leader 检测到 Follower 日志分叉、或需要指引快照时才在响应里附上,常态 fetch 一个都不带。

机制上就是把「以后可能要加的可选字段」从主 schema 挪进缓冲区:常用字段照旧定长定序、解析快,罕用字段挂标签、可忽略,加字段不必再动版本号。各 API 的 flexible 起点写在 schema json 的 flexibleVersions 字段里:Produce 9+、Fetch 12+、Metadata 9+、ApiVersions 3+。apiKey 定身份、apiVersion 定 schema、tagged fields 定「不破坏兼容的演进」、correlationId 做校验凭证------十五年协议的机制骨架就这么几句,后面讲 V2 消息格式时,会看到同一套思路在 RecordBatch 上的又一次落地。

为什么不用 Netty

看完了这一整篇文章,我们再回过头来看上篇开头留下的问题:为什么不用 Netty?

从事实上来看:

  1. 整个 4.3.1 的依赖树里没有 Netty------clients 模块的依赖清单(根 build.gradle 的 project(':clients') 块)只有 zstd、lz4、snappy、protobuf、opentelemetry proto、slf4j 这些,grep 全部构建文件无一处 netty。
  2. SocketServer.scala:864 显示 Processor 直接实例化 clients 模块的 org.apache.kafka.common.network.Selector,Broker 网络层不是「另一套 NIO」,而是与 Producer、Consumer 共用同一份传输层------成帧、TLS、SASL 的行为天然两端一致,修改一处两端受益。上篇安全握手一节里客户端与服务端代码同源同路径,就是这个事实的直接体现。

那么为什么呢?代码里没有注释直接回答,我的推断是:首先,clients 的定位是嵌进用户应用的轻量库,零传递依赖近乎硬约束,Netty 的体积和依赖面首先满足不了这一约束,而服务端没有理由为网络层单独维护一套与客户端割裂的实现;其次,历史时点也有影响------Kafka 起步于 2011 年前后,Netty 远未取得今天的事实标准地位;再加上 Kafka 协议自描述、成帧规则简单,不需要 Netty 编解码器生态解决的那类问题。

那么自己实现 NIO 的代价是什么呢?没有 ByteBuf 的池化与引用计数,没有现成的内存泄漏检测,传输层的性能优化全靠自己(Kafka 自己做了 MemoryPool,SocketServer.scala:97)。对一个把「客户端可嵌入」写进基因的系统,这笔交换大概率是划算的。这笔账值得算得更细,系列为此补了一条支线:06 篇讲 Java NIO 的原理,07 篇把 Kafka 的 KSelector 封装摊开,08 篇用 Netty 重写同一种成帧、逐点对照后回收本节的事实与推测。

为什么是划算的?Kafka 的 NIO 实现和 Netty 的实现有什么区别?NIO 的好处是什么?这一篇里我们没办法讲得更细,所以在讨论完 Acceptor、Processor 和 Handler 三者之后,我会再通过几篇文章继续深入 NIO 的内容。

小结

这篇文章的主角是 Processor,其网络层的主力是七步循环:configureNewConnections 收新连接、processNewResponses 处理响应、poll 推进事件,之后处理完成的读写与断连。一条连接同一时刻只有一个在途请求,靠的是 mute/unmute 对 OP_READ 的摘与戴------请求读出来就 mute,响应写出门才 unmute,配额节流复用的正是这套机制。线上的字节按「4 字节长度 + schema 自描述」成帧,请求头四字段定身份:apiKey 定 API、apiVersion 定 schema、correlationId 做校验凭证、clientId 供日志与配额分组;93 项 ApiKeys 背后是一套编译期生成的枚举,ApiVersions 先行对表、谈不拢有 v0 兜底,flexible 版本靠 tagged fields 演进。而这一切跑在与客户端同源的传输层代码上------KSelector 是带 Kafka 业务状态的 Selector,明确非线程安全,这是 mute 与节流必须在 Processor 线程执行的根。

请求进了 RequestChannel 之后呢?那条默认 500 容量的有界队列,满了会发生什么,handler 线程又怎么把每个请求的时间拆成七段可核对的账?下一篇接着看。

下一篇:Broker 网络层与请求处理(下):RequestChannel、handler 线程与七段账本。

相关推荐
坐吃山猪4 小时前
Kafka13-自定义分区器
kafka
starzy19904 小时前
Kafka请求进门口:Processor是怎么把请求读上来的
分布式·kafka
starzy19901 天前
Kafka网络层第一道门:Acceptor线程是怎么启动的
大数据·kafka
huaweichenai1 天前
spring boot对接kafka实现消息队列处理
spring boot·后端·kafka
你看我名字1 天前
Kafka 架构、消息可靠性与生产运维:从分区模型到故障治理
运维·架构·kafka
starzy19902 天前
Kafka源码深度剖析:消息有异常是如何处理的
大数据·kafka
starzy19902 天前
Kafka写数据入口:消息进了Broker之后交给谁
大数据·kafka
螺蛳粉 螺蛳粉2 天前
CDH第七篇:CDH 集成 Kafka 实战:CSD + Parcel 组合拳,先装 ZooKeeper 是铁律
物联网·zookeeper·kafka·消息队列·parcel·cdh集成·csd
Flynn Liang3 天前
物联网时序数据乱序问题全链路治理:从设备到存储的五层实战(附 Flink/Kafka/IoTDB 配置级代码)
flink·kafka·iot·iotdb·emqx·乱序·时序数据