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處理都會受影響。不知道準確與否。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章