kafka请求全流程(一)—— 客户端请求

kafka的源码路上一直都是个小学生,如有发现错误,请多指正,不胜感激。

总结了一张kafka网络通信层架构,如图:

整张图大概划分5部分,分别是:

  1. Clients 或其他 Broker 发送请求给 Acceptor 线程。
  2. Processor 线程处理请求,并放入请求队列。
  3. I/O 线程处理请求。
  4. KafkaRequestHandler 线程将 Response 放入 Processor 线程的 Response 队列。
  5. Processor 线程发送 Response 给 Request 发送方

一. 客户端请求

在 Kafka 中,处理请求是不区分优先级的,Kafka 对待所有请求都一视同仁。这种绝对公平的策略有时候是有问题的。分享一个案例(当然某大佬亲身经历,借用一下):

曾经在生产环境中创建过一个单分区双副本的主题,当时,集群中的 Broker A 机器保存了分区的 Leader 副本,Broker B 保存了 Follower 副本。某天,外部业务量激增,导致 Broker A 瞬间积压了大量的未处理 PRODUCE 请求。更糟的是,运维人员“不凑巧”地执行了一次 Preferred Leader 选举,将 Broker B 显式地调整成了 Leader。这个时候,问题就来了:如果 Producer 程序把 acks 设置为 all,那么,在 LeaderAndIsr 请求(它是负责调整副本角色的,比如 Follower 和 Leader 角色转换等)之前积压的那些 PRODUCE 请求就无法正常完成了,因为这些请求要一直等待 ISR 中所有 Follower 副本同步完成。但是,此时,Broker B 成为了 Leader,它上面的副本停止了拉取消息,这就可能出现一种结果:这些未完成的 PRODUCE 请求会一直保存在 Broker A 上的 Purgatory 缓存中。Leader/Follower 的角色转换,导致无法完成副本间同步,所以这些请求无法被成功处理,最终 Broker A 抛出超时异常,返回给 Producer 程序。

当然我也在公司测试环境操作过,虽然没有等来客户端异常,但确实发现有一批数据比正常数据晚了一个多小时,我猜也许是因为数据量不够(一口气发送了三千万条),让Broker抽空完成了副本间的同步。

当然这个问题就是对请求不区分优先级造成的,接下来回归正题。

1. Data plane 和 Control plane

社区将 Kafka 请求类型划分为两大类:数据类请求控制类请求。Data plane 和 Control plane 的字面意思是数据面和控制面,各自对应数据类请求和控制类请求,也就是说 Data plane 负责处理数据类请求,Control plane 负责处理控制类请求。

目前,Controller 与 Broker 交互的请求类型有 3 种:LeaderAndIsrRequest、StopReplicaRequest 和 UpdateMetadataRequest。这 3 类请求属于控制类请求,通常应该被赋予高优先级。像我们熟知的 PRODUCE 和 FETCH 请求,就是典型的数据类请求。对于Controller模块接下来会总结下。

2. 监听器(Listener)

目前,源码区分数据类请求和控制类请求不同处理方式的主要途径,就是通过监听器。也就是说,创建多组监听器分别来执行数据类和控制类请求的处理代码。

在 Kafka 中,Broker 端参数 listeners 和 advertised.listeners 就是用来配置监听器的。在源码中,监听器使用 EndPoint 类来定义,如下面代码所示:

case class EndPoint(host: String, port: Int, listenerName: ListenerName, securityProtocol: SecurityProtocol) {
  // 构造完整的监听器连接字符串
  // 格式为:监听器名称://主机名:端口
  // 比如:PLAINTEXT://kafka-host:9092
  def connectionString: String = {
    val hostport =
      if (host == null)
        ":"+port
      else
        Utils.formatAddress(host, port)
    listenerName.value + "://" + hostport
  }
  // clients工程下有一个Java版本的Endpoint类供clients端代码使用
  // 此方法是构造Java版本的Endpoint类实例
  def toJava: JEndpoint = {
    new JEndpoint(listenerName.value, securityProtocol, host, port)
  }
}

每个 EndPoint 对象定义了 4 个属性,我们分别来看下。

  • host:Broker 主机名。
  • port:Broker 端口号。
  • listenerName:监听器名字。目前预定义的名称包括 PLAINTEXT、SSL、SASL_PLAINTEXT 和 SASL_SSL。Kafka 允许你自定义其他监听器名称,比如 CONTROLLER、INTERNAL 等。
  • securityProtocol:监听器使用的安全协议。Kafka 支持 4 种安全协议,分别是 PLAINTEXT、SSL、SASL_PLAINTEXT 和 SASL_SSL。

3. SocketServer 定义

对这两大类请求区分处理,是 SocketServer 源码实现的核心逻辑。

class SocketServer(val config: KafkaConfig, 
  val metrics: Metrics,
  val time: Time,  
  val credentialProvider: CredentialProvider) 
  extends Logging with KafkaMetricsGroup with BrokerReconfigurable {
  // SocketServer实现BrokerReconfigurable trait表明SocketServer的一些参数配置是允许动态修改的
  // 即在Broker不停机的情况下修改它们
  // SocketServer的请求队列长度,由Broker端参数queued.max.requests值而定,默认值是500
  private val maxQueuedRequests = config.queuedMaxRequests
  ......
  // data-plane
  private val dataPlaneProcessors = new ConcurrentHashMap[Int, Processor]() // 处理数据类请求的Processor线程池
  // 处理数据类请求的Acceptor线程池,每套监听器对应一个Acceptor线程
  private[network] val dataPlaneAcceptors = new ConcurrentHashMap[EndPoint, Acceptor]()
  // 处理数据类请求专属的RequestChannel对象
  val dataPlaneRequestChannel = new RequestChannel(maxQueuedRequests, DataPlaneMetricPrefix)
  // control-plane
  // 用于处理控制类请求的Processor线程
  // 注意:目前定义了专属的Processor线程而非线程池处理控制类请求
  private var controlPlaneProcessorOpt : Option[Processor] = None
  private[network] var controlPlaneAcceptorOpt : Option[Acceptor] = None
  // 处理数据类请求专属的RequestChannel对象
  val controlPlaneRequestChannelOpt: Option[RequestChannel] = config.controlPlaneListenerName.map(_ => new RequestChannel(20, ControlPlaneMetricPrefix))
}

首先,SocketServer 类定义了一个 maxQueuedRequests 字段,它定义了请求队列的最大长度。默认值是 Broker 端 queued.max.requests 参数值。

其次,在上面的代码中, SocketServer 实现了 BrokerReconfigurable 接口(在 Scala 中是 trait)。这就说明,SocketServer 中的某些配置,是允许动态修改值的。如果查看 SocketServer 伴生对象类的定义的话,你能找到下面这些代码:


object SocketServer {
  val ReconfigurableConfigs = Set(
    KafkaConfig.MaxConnectionsPerIpProp,
    KafkaConfig.MaxConnectionsPerIpOverridesProp,
    KafkaConfig.MaxConnectionsProp)
}

根据这段代码,我们可以知道,Broker 端参数 max.connections.per.ip、max.connections.per.ip.overrides 和 max.connections 是可以动态修改的。

另外,在我们刚刚看的 SocketServer 定义的那段代码中,Data plane 和 Control plane 注释下面分别定义了一组变量,即 Processor 线程池、Acceptor 线程池和 RequestChannel 实例。

  • Processor 线程池:即网络线程池,负责将请求高速地放入到请求队列中。
  • Acceptor 线程池:保存了 SocketServer 为每个监听器定义的 Acceptor 线程,此线程负责分发该监听器上的入站连接建立请求。
  • RequestChannel:承载请求队列的请求处理通道(此处可以看上一篇https://blog.csdn.net/fenglei0415/article/details/105960812)。

严格地说,对于 Data plane 来说,线程池的说法是没有问题的,因为 Processor 线程确实有很多个,而 Acceptor 也可能有多个,因为 SocketServer 会为每个 EndPoint(即每套监听器)创建一个对应的 Acceptor 线程。

Control plane就不一样了, 那组属性变量都是以 Opt 结尾的,即它们都是 Option 类型。这说明了一个重要的事实:你完全可以不理会 Control plane ,即你可以让 Kafka 不区分请求类型。但是,一旦你开启了 Control plane 设置,其 Processor 线程就只有 1 个,Acceptor 线程也是 1 个。另外,它对应的 RequestChannel 里面的请求队列长度被硬编码成了 20,而不是一个可配置的值。这也揭示了社区在这里所做的一个假设:即控制类请求的数量应该远远小于数据类请求,因而不需要为它创建线程池和较深的请求队列。

4. 创建Data plane 过程

学习了 SocketServer 类的定义之后,继续学习 SocketServer 是如何为 Data plane 和 Control plane 创建所需资源的操作。SocketServer 的 createDataPlaneAcceptorsAndProcessors 方法负责为 Data plane 创建所需资源。看下它的实现:

private def createDataPlaneAcceptorsAndProcessors(
  dataProcessorsPerListener: Int, endpoints: Seq[EndPoint]): Unit = {
  // 遍历监听器集合
  endpoints.foreach { endpoint =>
    // 将监听器纳入到连接配额管理之下
    connectionQuotas.addListener(config, endpoint.listenerName)
    // 为监听器创建对应的Acceptor线程
    val dataPlaneAcceptor = createAcceptor(endpoint, DataPlaneMetricPrefix)
    // 为监听器创建多个Processor线程。具体数目由num.network.threads决定
    addDataPlaneProcessors(dataPlaneAcceptor, endpoint, dataProcessorsPerListener)
    // 将<监听器,Acceptor线程>对保存起来统一管理
    dataPlaneAcceptors.put(endpoint, dataPlaneAcceptor)
    info(s"Created data-plane acceptor and processors for endpoint : ${endpoint.listenerName}")
  }
}

createDataPlaneAcceptorsAndProcessors 方法会遍历你配置的所有监听器,然后为每个监听器执行下面的逻辑。

  • 初始化该监听器对应的最大连接数计数器。后续这些计数器将被用来确保没有配额超限的情形发生。
  • 为该监听器创建 Acceptor 线程,也就是调用 Acceptor 类的构造函数,生成对应的 Acceptor 线程实例。
  • 创建 Processor 线程池。对于 Data plane 而言,线程池的数量由 Broker 端参数 num.network.threads 决定。
  • 将 < 监听器,Acceptor 线程 > 对加入到 Acceptor 线程池统一管理。

源码会为每套用于 Data plane 的监听器执行以上这 4 步。举个例子,假设你配置 listeners=PLAINTEXT://localhost:9092, SSL://localhost:9093,那么在默认情况下,源码会为 PLAINTEXT 和 SSL 这两套监听器分别创建一个 Acceptor 线程和一个 Processor 线程池。需要注意的是,具体为哪几套监听器创建是依据配置而定的,最重要的是,Kafka 只会为 Data plane 所使的监听器创建这些资源。至于如何指定监听器到底是为 Data plane 所用,还是归 Control plane,接下来详细说明。

5. 创建 Control plane 过程

前面说过了,基于控制类请求的负载远远小于数据类请求负载的假设,Control plane 的配套资源只有 1 个 Acceptor 线程 + 1 个 Processor 线程 + 1 个深度是 20 的请求队列而已。和 Data plane 相比,这些配置稍显寒酸,不过在大部分情况下,应该是够用了。

SocketServer 提供了 createControlPlaneAcceptorAndProcessor 方法,用于为 Control plane 创建所需资源,源码如下:

private def createControlPlaneAcceptorAndProcessor(
  endpointOpt: Option[EndPoint]): Unit = {
  // 如果为Control plane配置了监听器
  endpointOpt.foreach { endpoint =>
    // 将监听器纳入到连接配额管理之下
    connectionQuotas.addListener(config, endpoint.listenerName)
    // 为监听器创建对应的Acceptor线程
    val controlPlaneAcceptor = createAcceptor(endpoint, ControlPlaneMetricPrefix)
    // 为监听器创建对应的Processor线程
    val controlPlaneProcessor = newProcessor(nextProcessorId, controlPlaneRequestChannelOpt.get, connectionQuotas, endpoint.listenerName, endpoint.securityProtocol, memoryPool)
    controlPlaneAcceptorOpt = Some(controlPlaneAcceptor)
    controlPlaneProcessorOpt = Some(controlPlaneProcessor)
    val listenerProcessors = new ArrayBuffer[Processor]()
    listenerProcessors += controlPlaneProcessor
    // 将Processor线程添加到控制类请求专属RequestChannel中
    // 即添加到RequestChannel实例保存的Processor线程池中
    controlPlaneRequestChannelOpt.foreach(
      _.addProcessor(controlPlaneProcessor))
    nextProcessorId += 1
    // 把Processor对象也添加到Acceptor线程管理的Processor线程池中
    controlPlaneAcceptor.addProcessors(listenerProcessors, ControlPlaneThreadPrefix)
    info(s"Created control-plane acceptor and processor for endpoint : ${endpoint.listenerName}")
  }
}

总体流程和 createDataPlaneAcceptorsAndProcessors 非常类似,只是方法开头需要判断是否配置了用于 Control plane 的监听器。目前,Kafka 规定只能有 1 套监听器用于 Control plane,而不能像 Data plane 那样可以配置多套监听器。

6. 启动Processor 和 Acceptor 线程

Processor 和 Acceptor 线程是在启动 SocketServer 组件之后启动的,具体代码在 KafkaServer.scala 文件的 startup 方法中,如下所示:

// KafkaServer.scala
def startup(): Unit = {
    try {
      info("starting")
      ......
      // 创建SocketServer组件
      socketServer = new SocketServer(config, metrics, time, credentialProvider)
      // 启动SocketServer,但不启动Processor线程
      socketServer.startup(startProcessingRequests = false)
      ......
      // 启动Data plane和Control plane的所有线程
      socketServer.startProcessingRequests(authorizerFutures)
      ......
    } catch {
      ......
    }
}

看一下 SocketServer 的 startProcessingRequests 的逻辑:

def startProcessingRequests(authorizerFutures: Map[Endpoint, CompletableFuture[Void]] = Map.empty): Unit = {
  info("Starting socket server acceptors and processors")
  this.synchronized {
    if (!startedProcessingRequests) {
      // 启动处理控制类请求的Processor和Acceptor线程
      startControlPlaneProcessorAndAcceptor(authorizerFutures)
      // 启动处理数据类请求的Processor和Acceptor线程
      startDataPlaneProcessorsAndAcceptors(authorizerFutures)
      startedProcessingRequests = true
    } else {
      info("Socket server acceptors and processors already started")
    }
  }
  info("Started socket server acceptors and processors")
}

需要注意的是,这是今年4 月 16 日刚刚添加的方法。你需要使用 git 命令去拉取最新的 Trunk 分支代码就能看到这个方法了。

这个方法又进一步调用了 startDataPlaneProcessorsAndAcceptors 和 startControlPlaneProcessorAndAcceptor 方法分别启动 Data plane 的 Control plane 的线程。鉴于这两个方法的逻辑类似,重点学习下 startDataPlaneProcessorsAndAcceptors 方法的实现。

private def startDataPlaneProcessorsAndAcceptors(
  authorizerFutures: Map[Endpoint, CompletableFuture[Void]]): Unit = {
  // 获取Broker间通讯所用的监听器,默认是PLAINTEXT
  val interBrokerListener = dataPlaneAcceptors.asScala.keySet
    .find(_.listenerName == config.interBrokerListenerName)
    .getOrElse(throw new IllegalStateException(s"Inter-broker listener ${config.interBrokerListenerName} not found, endpoints=${dataPlaneAcceptors.keySet}"))
  val orderedAcceptors = List(dataPlaneAcceptors.get(interBrokerListener)) ++
    dataPlaneAcceptors.asScala.filter { case (k, _) => k != interBrokerListener }.values
  orderedAcceptors.foreach { acceptor =>
    val endpoint = acceptor.endPoint
    // 启动Processor和Acceptor线程
    startAcceptorAndProcessors(DataPlaneThreadPrefix, endpoint, acceptor, authorizerFutures)
  }
}

该方法主要的逻辑是调用 startAcceptorAndProcessors 方法启动 Acceptor 和 Processor 线程。

当然在此之前,代码要获取 Broker 间通讯所用的监听器,并找出该监听器对应的 Acceptor 线程以及它维护的 Processor 线程池。

Broker 端参数 control.plane.listener.name,就是用于设置 Control plane 所用的监听器的地方。在默认情况下,这个参数的值是空(Null)。Null 的意思就是告诉 Kafka 不要启用请求优先级区分机制,但如果你设置了这个参数,Kafka 就会利用它去 listeners 中寻找对应的监听器了。

到这里,总结一下

严格来说,Kafka 没有为请求设置数值型的优先级,因此,我们并不能把所有请求按照所谓的优先级进行排序。到目前为止,Kafka 仅仅实现了粗粒度的优先级处理,即整体上把请求分为数据类请求和控制类请求两类,而且没有为这两类定义可相互比较的优先级。那我们应该如何把刚刚说的所有东西和这里的优先级进行关联呢?

通过代码分析,我们知道,社区定义了多套监听器以及底层处理线程的方式来区分这两大类请求。虽然我们很难直接比较这两大类请求的优先级,但在实际应用中,由于数据类请求的数量要远多于控制类请求,因此,为控制类请求单独定义处理资源的做法,实际上就等同于拔高了控制类请求的优先处理权。从这个角度上来说,这套做法间接实现了优先级的区别对待。

那我的问题是:如果在队列中实现优先级的不同,而不是提供不同的资源,可行吗? 后来想想,data plan 和 control plan 请求大小不同,而且处理层使用轮询建立连接,会有偏斜。如果在请求队列限制,API层handle处理都会受影响。不知道准确与否。

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值