Kafka原理剖析之「Topic建立」

昔久發表於2024-09-07

一、前言

Kafka提供了高效能的讀寫,而這些讀寫操作均是操作在Topic上的,Topic的建立就尤為關鍵,其中涉及分割槽分配策略、狀態流轉等,而Topic的新建語句非常簡單

bash kafka-topics.sh \
--bootstrap-server localhost:9092 \ // 需要寫入endpoints
--create --topic topicA				// 要建立的topic名稱
--partitions 10 					// 當前要建立的topic分割槽數
--replication-factor 2				// 副本因子,即每個TP建立多少個副本

因此Topic的建立可能並不像表明上操作的那麼簡單,這節我們就闡述一下Topic新建的細節

以下論述基於Kafka 2.8.2版本

二、整體流程

Topic新建分2部分,分別是

  1. 使用者呼叫對應的API,然後由Controller指定分割槽分配策略,並將其持久化至Zookeeper中
  2. Controller負責監聽Zookeeper的回撥函式拿到後設資料變更後,觸發狀態機並真正執行副本分配

Kafka原理剖析之「Topic建立」

使用者發起一個Topic新建的請求,Controller收到請求後,開始制定分割槽分配方案,繼而將分配方案持久化到Zookeeper中,然後就向使用者返回結果

而在Controller中專門監聽Zookeeper節點變化的執行緒(當然這個執行緒與建立Topic的執行緒是非同步的),當發現有變更後,將會非同步觸發狀態機進行狀態流轉,後續會將對應的Broker設定為Leader或Follower

三、Topic分割槽分配方案

在模組一中,主要的流程是3部分:

  1. 使用者向Controller發起新增Topic請求
  2. Controller收到請求後,開始制定該Topic的分割槽分配策略
  3. Controller將制定好的策略持久化至Zookeeper中

而上述描述中,流程1、3都是相對好理解的,我們著重要說的是流程2,即分割槽分配策略。Kafka分割槽制定方案核心邏輯放在 scala/kafka/admin/AdminUtils.scala 中,分為無機架、有機架兩種,我們核心看一下無機架的策略

無機架策略中,又分為Leader Replica及Follow Replica兩種

3.1、Leader Partition

而關於Leader及Follower的分配策略統一在方法kafka.admin.AdminUtils#assignReplicasToBrokersRackUnaware中,此方法只有20多行,我們簡單來看一下

private def assignReplicasToBrokersRackUnaware(nPartitions: Int,  // 目標topic的分割槽總數
                                               replicationFactor: Int,	// topic副本因子
                                               brokerList: Seq[Int],	// broker列表
                                               fixedStartIndex: Int,	// 預設情況傳-1
                                               startPartitionId: Int  /* 預設情況傳-1 */): Map[Int, Seq[Int]] = {
  val ret = mutable.Map[Int, Seq[Int]]()
  val brokerArray = brokerList.toArray
  // leader針對broker列表的開始index,預設會隨機選取
  val startIndex = if (fixedStartIndex >= 0) fixedStartIndex else rand.nextInt(brokerArray.length)
  // 預設為0,從0開始
  var currentPartitionId = math.max(0, startPartitionId)
  // 這個值主要是為分配Follower Partition而用
  var nextReplicaShift = if (fixedStartIndex >= 0) fixedStartIndex else rand.nextInt(brokerArray.length)
  // 這裡開始對partition進行迴圈遍歷
  for (_ <- 0 until nPartitions) {
    // 這個判斷邏輯,影響follower partition
    if (currentPartitionId > 0 && (currentPartitionId % brokerArray.length == 0))
      nextReplicaShift += 1
    // 當前partition的第一個replica,也就是leader
    // 由於startIndex是隨機生成的,因此firstReplicaIndex也是從broker list中隨機取一個
    val firstReplicaIndex = (currentPartitionId + startIndex) % brokerArray.length
    // 儲存了當前partition的所有replica的陣列
    val replicaBuffer = mutable.ArrayBuffer(brokerArray(firstReplicaIndex))
    for (j <- 0 until replicationFactor - 1)
      replicaBuffer += brokerArray(replicaIndex(firstReplicaIndex, nextReplicaShift, j, brokerArray.length))
    ret.put(currentPartitionId, replicaBuffer)
    currentPartitionId += 1
  }
  ret
}

由此可見,Topic Leader Replica的分配策略是相對簡單的,我們再簡單概括一下它的流程

  1. 從Broker List中隨機選取一個Broker,作為 Partition 0 的 Leader
  2. 之後開始遍歷Broker List,依次建立Partition 1、Partition 2、Partition 3....
  3. 如果遍歷到了Broker List末尾,那麼重定向到0,繼續向後遍歷

假定我們有5個Broker,編號從1000開始,分別是1000、1001、1002、1003、1004,假定partition 0隨機選舉的broker是1000,那麼最終的分配策略將會是如下:

Broker

1000

1001

1002

1003

1004

Leader Partition

0

1

2

3

4

5

6

7

8

9

而假定partition 0隨機選舉的broker是1002,那麼最終的分配策略將會是如下:

Broker

1000

1001

1002

1003

1004

Leader Partition

3

4

0

1

2

8

9

5

6

7

這樣做的目的是將Partition儘可能地打亂,將Partition Leader分配到不同的Broker上,避免資料熱點

然而這個方案也並不是完美的,它只是會將當前建立的Topic Partition Leader打散,並沒有考慮其他Topic Partition的分配情況,假定我們現在建立了5個Topic,均是單分割槽的,而正好它們都落在Broker 1000上,下一次我們建立新Topic的時,它的Partition 0依舊可能落在Broker 1000上,造成資料熱點。不過因為是隨機建立,因此當Topic足夠多的情況時,還是能保證相對離散

3.2、Follower Partition

Leader Replica已經確定下來,接下來就是要制定Follower的分配方案,Follower的分配方案至少要滿足以下2點要求

  • Follower要隨機打散在不同的Broker上,主要是做高可用保證,當Leader Broker不可用時,Follower要能頂上
  • Follower的分配還不能太隨機,因為如果真的全部隨機分配的話,可能出現某個Broker比其他Broker的replica要多,而這個是可以避免的

Follower Replica的分配邏輯除了上述說的kafka.admin.AdminUtils#assignReplicasToBrokersRackUnaware方法外,很重要的一個方法是kafka.admin.AdminUtils#replicaIndex

private def replicaIndex(
  firstReplicaIndex: Int, 	// 第一個replica的index,也就是leader index
  secondReplicaShift: Int, 	// 隨機shift,範圍是[0, brokerList.length),每隔brokerList.length,將+1
  replicaIndex: Int, 	// 當前follower副本編號,從0開始
  nBrokers: Int): Int = {  // broker數量
  val shift = 1 + (secondReplicaShift + replicaIndex) % (nBrokers - 1)
  (firstReplicaIndex + shift) % nBrokers
}

其實這個方法只有2行,不過這2行程式碼相當晦澀,要理解它不太容易,而且在2.8.2版本中沒有對其的註釋,我特意翻看了當前社群的最新版本3.9.0-SNAPSHOT,依舊沒有針對這個方法的註釋。不過我們還是需要花點精力去理解它的

第一行

val shift = 1 + (secondReplicaShift + replicaIndex) % (nBrokers - 1)

這行程式碼的作用是生成一個隨機值shift,因此shift的範圍是 0 <= shift < nBrokers,而隨著replicaIndex的增加,shift也會相應增加,當然這樣做的目的是為第二行程式碼做鋪墊

當然shift的值,只會與secondReplicaShift、replicaIndex相關,與partition無關

第二行

(firstReplicaIndex + shift) % nBrokers

這樣程式碼就保證了生成的follower index不會與Leader index重複,並且所有的follower index是向前遞增的

總結一下分配的規則:

  1. 隨機從Broker list中選擇一個作為第一個follower的起始位置(由變數secondReplicaShift控制)
  2. 後續的follower均基於步驟1的起始位置,依次向後+1
  3. follower的位置確保不會與Leader衝突,如果衝突則向後順延一位(由 (firstReplicaIndex + shift) % nBrokers 進行控制)
  4. 並非當前Topic的所有的partition均採用同一步調,一旦(PartitionNum%BrokerNum == 0),secondReplicaShift將會+1,導致第一個follower的起始位置+1,這樣就更加離散

我們看一個具體case:

Broker

1000

1001

1002

1003

1004

Leader

0

1

2

3

4

5

6

7

8

9

Follower 1

1

2

3

4

0

9

5

6

7

8

Follower 2

4

0

1

2

3

8

9

5

6

7

  • Partition 1:Leader在1001上,而2個Follower分別在1000、1002上。很明顯,Follower是從1000開始往後遍歷尋找的,因此2個Follower的分佈本來應該是1000、1001,但1001正好是Leader,因此往後順移,最終Follower的分佈也就是【1000、1002】
    • 此處注意:為什麼“Follower是從1000開始往後遍歷”? 這個就與kafka.admin.AdminUtils#replicaIndex方法中的shift變數有關,而shift則是由隨機變數secondReplicaShift而定的,因此“1000開始往後遍歷”是本次隨機執行後的一個結果,如果再跑一次程式,可能結果就不一致了
  • Partition 3:再看分割槽3,Leader在1003上,Follower是從1002開始的,因此Follower的分佈也就是【1002、1004】
  • Partition 7:因為從partition 5開始,超過了broker的總數,因此變數secondReplicaShift++,導致Follower的起始index也+1,因此Follower的分佈是【1003、1004】

為什麼要費盡九牛二虎之力,做這麼複雜的方案設定呢?直接將Leader Broker後面的N個Broker作為Follower不可以嗎?其實自然是可以的,不過可能帶來一些問題,比如如果Leader當機後,這些Leader Partition都會飄到某1個或某幾個Broker上,這樣可能帶來一些熱點隱患,導致存活的Broker不能均攤這些流量

3.3、手動制定策略

當然上述是Kafka幫助我們自動制定分割槽分配方案,另外我們可以手動制定策略:

bash kafka-topics.sh \
--bootstrap-server localhost:9092 \
--create --topic topicA \
--replica-assignment 1000,1000,1000,1000,1000

按照上述的命令建立Topic,我們會新建一個名稱為“topicA”的主題,它有5個分割槽,全部都建立在ID為1000的Broker上

另外Kafka還支援機架(rack)優先的分割槽分配方案,即儘量將某個partition的replica均勻地打散至N個rack中,這樣確保某個rack不可用後,不影響這個partition整體對外的服務能力。本文不再對這種case進行展開

四、狀態機

在分割槽分配方案制定完畢後,Controller便將此方案進行編碼,轉換為二進位制的byte[],進而持久化到ZooKeeper的路徑為/topics/topicXXX(其中topicXXX就是topic名稱)的path內,而後便向使用者返回建立成功的提示;然而真正建立Topic的邏輯並沒有結束,Controller會非同步執行後續建立Topic的操作,原始碼中邏輯寫的相對比較繞,不過不外乎做了以下兩件事兒:

  1. 更新後設資料並通知給所有Brokers
  2. 向各個Broker傳播ISR,並對應執行Make Leader、Make Follower操作

而實現上述操作則是透過兩個狀態機:

  • PartitionStateMachine.scala 分割槽狀態機
  • ReplicaStateMachine.scala 副本狀態機

Controll收到ZK非同步通知的入口為 kafka.controller.KafkaController#processTopicChange

4.1、分割槽狀態機

即一個partition的狀態,對應的申明類為kafka.controller.PartitionState,共有4種狀態:

  • NewPartition 新建狀態,其實只會在Controll中停留很短的時間,繼而轉換為OnlinePartition
  • OnlinePartition 線上狀態,只有處於線上狀態的partition才能對外提供服務
  • OfflinePartition 下線狀態,比如Topic刪除操作
  • NonExistentPartition 初始化狀態,如果新建Topic,partition預設則為此狀態

轉換關係如下

Kafka原理剖析之「Topic建立」

本文只討論新建Topic時,狀態轉換的過程,因此只涉及

  • NonExistentPartition -> NewPartition
  • NewPartition -> OnlinePartition

4.2、副本狀態機

所謂副本狀態機,對應的申明類為kafka.controller.ReplicaState,共有7種狀態:NewReplica、OnlineReplica、OfflineReplica、ReplicaDeletionStarted、ReplicaDeletionSuccessful、ReplicaDeletionIneligible、NonExistentReplica。在Topic新建的流程中,我們只會涉及其中的3種:NewReplica、OnlineReplica、NonExistentReplica,且副本狀態機在新建流程中發揮的空間有限,不是本文的重點,讀者對其有個大致概念即可

4.3、狀態流轉

首先要確認一點,Kafka的Controller是單執行緒的,所有的事件均是序列執行,以下所有的操作也均是序列執行

Kafka原理剖析之「Topic建立」

在真正執行狀態流轉前,需要執行2個前置步驟

  • 生產Topic ID。為新建的Topic生產唯一的TopicID,具體實現方法位置在kafka.zk.KafkaZkClient#setTopicIds內,其實就是簡單呼叫org.apache.kafka.common.Uuid#randomUuid來生成一個隨機串
  • 讀取分割槽分配策略。接著從zk(儲存路徑為/brokers/topics/topicName)中讀取這個Topic的分割槽分配策略,然後將分割槽分配策略放進快取中,快取的位置為kafka.controller.ControllerContext#partitionAssignments

上述兩個步驟其實沒啥好說的,只是為狀態流轉做一些前置鋪墊。接下來就要進入主方法的邏輯中了,即kafka.controller.KafkaController#onNewPartitionCreation,可簡單看一下此方法,主要執行4部分內容

    1. partition狀態機將狀態設定為NewPartition
    2. replica狀態機降狀態置為NewReplica
    3. partition狀態機將狀態設定為OnlinePartition
    4. replica狀態機降狀態置為OnlineReplica
// kafka.controller.KafkaController#onNewPartitionCreation
private def onNewPartitionCreation(newPartitions: Set[TopicPartition]): Unit = {
  info(s"New partition creation callback for ${newPartitions.mkString(",")}")
  partitionStateMachine.handleStateChanges(newPartitions.toSeq, NewPartition)
  replicaStateMachine.handleStateChanges(controllerContext.replicasForPartition(newPartitions).toSeq, NewReplica)
  partitionStateMachine.handleStateChanges(newPartitions.toSeq, OnlinePartition, Some(OfflinePartitionLeaderElectionStrategy(false)))
  replicaStateMachine.handleStateChanges(controllerContext.replicasForPartition(newPartitions).toSeq, OnlineReplica)
}

4.3.1、Partition狀態機NewPartition

partition狀態機將狀態設定為NewPartition。這一步就是維護kafka.controller.ControllerContext#partitionStates記憶體變數,將對應partition的狀態設定為NewPartition,其他什麼都不做

4.3.2、Replica狀態機NewReplica

replica狀態機降狀態置為NewReplica。這一步是維護kafka.controller.ControllerContext#replicaStates記憶體變數,將replica狀態設定為NewReplica

4.3.3、Partition狀態機OnlinePartition

這一步也是整個狀態機流轉中的核心部分,共分為以下5大步:

  1. 初始化Leader、ISR等資訊,並將這些資訊暫存至zk中
    1. 建立topic-partition在zk中的路徑,path為/brokers/topics/topicName/partitions
    2. 為每個partition建立路徑,path為/brokers/topics/topicName/partitions/xxx,例如
      1. /brokers/topics/topicName/partitions/0
      2. /brokers/topics/topicName/partitions/1
      3. /brokers/topics/topicName/partitions/2
    1. 將Leader及ISR的資訊持久化下來,path為/brokers/topics/topicName/partitions/0/state
  1. 而後將Leader、ISR等已經持久化到zk的資訊放入快取kafka.controller.ControllerContext#partitionLeadershipInfo
  2. 因為Leader、ISR這些後設資料發生了變化,因此將這些資訊記錄下來,放在記憶體結構kafka.controller.AbstractControllerBrokerRequestBatch#leaderAndIsrRequestMap中,表明這些資訊是需要同步給對應的Broker的
  3. 維護kafka.controller.ControllerContext#partitionStates記憶體變數,將狀態設定為OnlinePartition
  4. 呼叫介面ApiKeys.LEADER_AND_ISR,向對應的Broker傳送資料,當Broker接收到這個請求後,便會執行MakeLeader/MakeFollower相關操作

4.3.4、Replica狀態機OnlineReplica

replica狀態機降狀態置為OnlineReplica。維護kafka.controller.ControllerContext#replicaStates記憶體變數,將狀態設定為OnlineReplica

至此,一個 Kafka Topic 才算是真正被建立出來

相關文章