一、前言
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部分,分別是
- 使用者呼叫對應的API,然後由Controller指定分割槽分配策略,並將其持久化至Zookeeper中
- Controller負責監聽Zookeeper的回撥函式拿到後設資料變更後,觸發狀態機並真正執行副本分配
使用者發起一個Topic新建的請求,Controller收到請求後,開始制定分割槽分配方案,繼而將分配方案持久化到Zookeeper中,然後就向使用者返回結果
而在Controller中專門監聽Zookeeper節點變化的執行緒(當然這個執行緒與建立Topic的執行緒是非同步的),當發現有變更後,將會非同步觸發狀態機進行狀態流轉,後續會將對應的Broker設定為Leader或Follower
三、Topic分割槽分配方案
在模組一中,主要的流程是3部分:
- 使用者向Controller發起新增Topic請求
- Controller收到請求後,開始制定該Topic的分割槽分配策略
- 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的分配策略是相對簡單的,我們再簡單概括一下它的流程
- 從Broker List中隨機選取一個Broker,作為 Partition 0 的 Leader
- 之後開始遍歷Broker List,依次建立Partition 1、Partition 2、Partition 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是向前遞增的
總結一下分配的規則:
- 隨機從Broker list中選擇一個作為第一個follower的起始位置(由變數secondReplicaShift控制)
- 後續的follower均基於步驟1的起始位置,依次向後+1
- follower的位置確保不會與Leader衝突,如果衝突則向後順延一位(由
(firstReplicaIndex + shift) % nBrokers
進行控制) - 並非當前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的操作,原始碼中邏輯寫的相對比較繞,不過不外乎做了以下兩件事兒:
- 更新後設資料並通知給所有Brokers
- 向各個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預設則為此狀態
轉換關係如下
本文只討論新建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是單執行緒的,所有的事件均是序列執行,以下所有的操作也均是序列執行
在真正執行狀態流轉前,需要執行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部分內容
- partition狀態機將狀態設定為NewPartition
- replica狀態機降狀態置為NewReplica
- partition狀態機將狀態設定為OnlinePartition
- 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大步:
- 初始化Leader、ISR等資訊,並將這些資訊暫存至zk中
- 建立topic-partition在zk中的路徑,path為/brokers/topics/topicName/partitions
- 為每個partition建立路徑,path為/brokers/topics/topicName/partitions/xxx,例如
- /brokers/topics/topicName/partitions/0
- /brokers/topics/topicName/partitions/1
- /brokers/topics/topicName/partitions/2
- 將Leader及ISR的資訊持久化下來,path為/brokers/topics/topicName/partitions/0/state
- 而後將Leader、ISR等已經持久化到zk的資訊放入快取
kafka.controller.ControllerContext#partitionLeadershipInfo
中 - 因為Leader、ISR這些後設資料發生了變化,因此將這些資訊記錄下來,放在記憶體結構
kafka.controller.AbstractControllerBrokerRequestBatch#leaderAndIsrRequestMap
中,表明這些資訊是需要同步給對應的Broker的 - 維護
kafka.controller.ControllerContext#partitionStates
記憶體變數,將狀態設定為OnlinePartition - 呼叫介面ApiKeys.LEADER_AND_ISR,向對應的Broker傳送資料,當Broker接收到這個請求後,便會執行MakeLeader/MakeFollower相關操作
4.3.4、Replica狀態機OnlineReplica
replica狀態機降狀態置為OnlineReplica。維護kafka.controller.ControllerContext#replicaStates
記憶體變數,將狀態設定為OnlineReplica
至此,一個 Kafka Topic 才算是真正被建立出來