Kafka設計解析(二)- Kafka High Availability (上)

郭俊JasonGuo發表於2015-04-26
問題導讀

1.kafka從哪個版本後有了HA?
2.Kafka為何需要High Available?
3.如何將所有Replica均勻分佈到整個叢集?
4.HA相關ZooKeeper結構有什麼變化?
5.broker failover包含哪些過程?







Kafka在0.8以前的版本中,並不提供High Availablity機制,一旦一個或多個Broker當機,則當機期間其上所有Partition都無法繼續提供服務。若該Broker永遠不能再恢復,亦或磁碟故障,則其上資料將丟失。而Kafka的設計目標之一即是提供資料持久化,同時對於分散式系統來說,尤其當叢集規模上升到一定程度後,一臺或者多臺機器當機的可能性大大提高,對Failover要求非常高。因此,Kafka從0.8開始提供High Availability機制。本文從Data Replication和Leader Election兩方面介紹了Kafka的HA機制。

Kafka為何需要High Available

為何需要Replication
在Kafka在0.8以前的版本中,是沒有Replication的,一旦某一個Broker當機,則其上所有的Partition資料都不可被消費,這與Kafka資料永續性及Delivery Guarantee的設計目標相悖。同時Producer都不能再將資料存於這些Partition中。
  • 如果Producer使用同步模式則Producer會在嘗試重新傳送message.send.max.retries(預設值為3)次後丟擲Exception,使用者可以選擇停止傳送後續資料也可選擇繼續選擇傳送。而前者會造成資料的阻塞,後者會造成本應發往該Broker的資料的丟失。
  • 如果Producer使用非同步模式,則Producer會嘗試重新傳送message.send.max.retries(預設值為3)次後記錄該異常並繼續傳送後續資料,這會造成資料丟失並且使用者只能通過日誌發現該問題。同時,Kafka的Producer並未對非同步模式提供callback介面。
由此可見,在沒有Replication的情況下,一旦某機器當機或者某個Broker停止工作則會造成整個系統的可用性降低。隨著叢集規模的增加,整個叢集中出現該類異常的機率大大增加,因此對於生產系統而言Replication機制的引入非常重要。

為何需要Leader Election
注意:本文所述Leader Election主要指Replica之間的Leader Election。
引入Replication之後,同一個Partition可能會有多個Replica,而這時需要在這些Replication之間選出一個Leader,Producer和Consumer只與這個Leader互動,其它Replica作為Follower從Leader中複製資料。
因為需要保證同一個Partition的多個Replica之間的資料一致性(其中一個當機後其它Replica必須要能繼續服務並且即不能造成資料重複也不能造成資料丟失)。如果沒有一個Leader,所有Replica都可同時讀/寫資料,那就需要保證多個Replica之間互相(N×N條通路)同步資料,資料的一致性和有序性非常難保證,大大增加了Replication實現的複雜性,同時也增加了出現異常的機率。而引入Leader後,只有Leader負責資料讀寫,Follower只向Leader順序Fetch資料(N條通路),系統更加簡單且高效。

Kafka HA設計解析


如何將所有Replica均勻分佈到整個叢集
為了更好的做負載均衡,Kafka儘量將所有的Partition均勻分配到整個叢集上。一個典型的部署方式是一個Topic的Partition數量大於Broker的數量。同時為了提高Kafka的容錯能力,也需要將同一個Partition的Replica儘量分散到不同的機器。實際上,如果所有的Replica都在同一個Broker上,那一旦該Broker當機,該Partition的所有Replica都無法工作,也就達不到HA的效果。同時,如果某個Broker當機了,需要保證它上面的負載可以被均勻的分配到其它倖存的所有Broker上。
Kafka分配Replica的演算法如下:
  • 將所有Broker(假設共n個Broker)和待分配的Partition排序
  • 將第i個Partition分配到第(i mod n)個Broker上
  • 將第i個Partition的第j個Replica分配到第((i + j) mode n)個Broker上

Data Replication
Kafka的Data Replication需要解決如下問題:
  • 怎樣Propagate訊息
  • 在向Producer傳送ACK前需要保證有多少個Replica已經收到該訊息
  • 怎樣處理某個Replica不工作的情況
  • 怎樣處理Failed Replica恢復回來的情況

Propagate訊息
Producer在釋出訊息到某個Partition時,先通過ZooKeeper找到該Partition的Leader,然後無論該Topic的Replication Factor為多少(也即該Partition有多少個Replica),Producer只將該訊息傳送到該Partition的Leader。Leader會將該訊息寫入其本地Log。每個Follower都從Leader pull資料。這種方式上,Follower儲存的資料順序與Leader保持一致。Follower在收到該訊息並寫入其Log後,向Leader傳送ACK。一旦Leader收到了ISR中的所有Replica的ACK,該訊息就被認為已經commit了,Leader將增加HW並且向Producer傳送ACK。

為了提高效能,每個Follower在接收到資料後就立馬向Leader傳送ACK,而非等到資料寫入Log中。因此,對於已經commit的訊息,Kafka只能保證它被存於多個Replica的記憶體中,而不能保證它們被持久化到磁碟中,也就不能完全保證異常發生後該條訊息一定能被Consumer消費。但考慮到這種場景非常少見,可以認為這種方式在效能和資料持久化上做了一個比較好的平衡。在將來的版本中,Kafka會考慮提供更高的永續性。
Consumer讀訊息也是從Leader讀取,只有被commit過的訊息(offset低於HW的訊息)才會暴露給Consumer。
Kafka Replication的資料流如下圖所示:



ACK前需要保證有多少個備份
和大部分分散式系統一樣,Kafka處理失敗需要明確定義一個Broker是否“活著”。對於Kafka而言,Kafka存活包含兩個條件,一是它必須維護與ZooKeeper的session(這個通過ZooKeeper的Heartbeat機制來實現)。二是Follower必須能夠及時將Leader的訊息複製過來,不能“落後太多”。

Leader會跟蹤與其保持同步的Replica列表,該列表稱為ISR(即in-sync Replica)。如果一個Follower當機,或者落後太多,Leader將把它從ISR中移除。這裡所描述的“落後太多”指Follower複製的訊息落後於Leader後的條數超過預定值(該值可在$KAFKA_HOME/config/server.properties中通過replica.lag.max.messages配置,其預設值是4000)或者Follower超過一定時間(該值可在$KAFKA_HOME/config/server.properties中通過replica.lag.time.max.ms來配置,其預設值是10000)未向Leader傳送fetch請求。

Kafka的複製機制既不是完全的同步複製,也不是單純的非同步複製。事實上,完全同步複製要求所有能工作的Follower都複製完,這條訊息才會被認為commit,這種複製方式極大的影響了吞吐率(高吞吐率是Kafka非常重要的一個特性)。而非同步複製方式下,Follower非同步的從Leader複製資料,資料只要被Leader寫入log就被認為已經commit,這種情況下如果Follower都複製完都落後於Leader,而如果Leader突然當機,則會丟失資料。而Kafka的這種使用ISR的方式則很好的均衡了確保資料不丟失以及吞吐率。Follower可以批量的從Leader複製資料,這樣極大的提高複製效能(批量寫磁碟),極大減少了Follower與Leader的差距。

需要說明的是,Kafka只解決fail/recover,不處理“Byzantine”(“拜占庭”)問題。一條訊息只有被ISR裡的所有Follower都從Leader複製過去才會被認為已提交。這樣就避免了部分資料被寫進了Leader,還沒來得及被任何Follower複製就當機了,而造成資料丟失(Consumer無法消費這些資料)。而對於Producer而言,它可以選擇是否等待訊息commit,這可以通過request.required.acks來設定。這種機制確保了只要ISR有一個或以上的Follower,一條被commit的訊息就不會丟失。

Leader Election演算法
上文說明了Kafka是如何做Replication的,另外一個很重要的問題是當Leader當機了,怎樣在Follower中選舉出新的Leader。因為Follower可能落後許多或者crash了,所以必須確保選擇“最新”的Follower作為新的Leader。一個基本的原則就是,如果Leader不在了,新的Leader必須擁有原來的Leader commit過的所有訊息。這就需要作一個折衷,如果Leader在標明一條訊息被commit前等待更多的Follower確認,那在它當機之後就有更多的Follower可以作為新的Leader,但這也會造成吞吐率的下降。

一種非常常用的選舉leader的方式是“Majority Vote”(“少數服從多數”),但Kafka並未採用這種方式。這種模式下,如果我們有2f+1個Replica(包含Leader和Follower),那在commit之前必須保證有f+1個Replica複製完訊息,為了保證正確選出新的Leader,fail的Replica不能超過f個。因為在剩下的任意f+1個Replica裡,至少有一個Replica包含有最新的所有訊息。這種方式有個很大的優勢,系統的latency只取決於最快的幾個Broker,而非最慢那個。Majority Vote也有一些劣勢,為了保證Leader Election的正常進行,它所能容忍的fail的follower個數比較少。如果要容忍1個follower掛掉,必須要有3個以上的Replica,如果要容忍2個Follower掛掉,必須要有5個以上的Replica。也就是說,在生產環境下為了保證較高的容錯程度,必須要有大量的Replica,而大量的Replica又會在大資料量下導致效能的急劇下降。這就是這種演算法更多用在ZooKeeper這種共享叢集配置的系統中而很少在需要儲存大量資料的系統中使用的原因。例如HDFS的HA Feature是基於majority-vote-based journal,但是它的資料儲存並沒有使用這種方式。

實際上,Leader Election演算法非常多,比如ZooKeeper的ZabRaftViewstamped Replication。而Kafka所使用的Leader Election演算法更像微軟的PacificA演算法。

Kafka在ZooKeeper中動態維護了一個ISR(in-sync replicas),這個ISR裡的所有Replica都跟上了leader,只有ISR裡的成員才有被選為Leader的可能。在這種模式下,對於f+1個Replica,一個Partition能在保證不丟失已經commit的訊息的前提下容忍f個Replica的失敗。在大多數使用場景中,這種模式是非常有利的。事實上,為了容忍f個Replica的失敗,Majority Vote和ISR在commit前需要等待的Replica數量是一樣的,但是ISR需要的總的Replica的個數幾乎是Majority Vote的一半。

雖然Majority Vote與ISR相比有不需等待最慢的Broker這一優勢,但是Kafka作者認為Kafka可以通過Producer選擇是否被commit阻塞來改善這一問題,並且節省下來的Replica和磁碟使得ISR模式仍然值得。

如何處理所有Replica都不工作
上文提到,在ISR中至少有一個follower時,Kafka可以確保已經commit的資料不丟失,但如果某個Partition的所有Replica都當機了,就無法保證資料不丟失了。這種情況下有兩種可行的方案:
  • 等待ISR中的任一個Replica“活”過來,並且選它作為Leader
  • 選擇第一個“活”過來的Replica(不一定是ISR中的)作為Leader

這就需要在可用性和一致性當中作出一個簡單的折衷。如果一定要等待ISR中的Replica“活”過來,那不可用的時間就可能會相對較長。而且如果ISR中的所有Replica都無法“活”過來了,或者資料都丟失了,這個Partition將永遠不可用。選擇第一個“活”過來的Replica作為Leader,而這個Replica不是ISR中的Replica,那即使它並不保證已經包含了所有已commit的訊息,它也會成為Leader而作為consumer的資料來源(前文有說明,所有讀寫都由Leader完成)。Kafka0.8.*使用了第二種方式。根據Kafka的文件,在以後的版本中,Kafka支援使用者通過配置選擇這兩種方式中的一種,從而根據不同的使用場景選擇高可用性還是強一致性。


如何選舉Leader
最簡單最直觀的方案是,所有Follower都在ZooKeeper上設定一個Watch,一旦Leader當機,其對應的ephemeral znode會自動刪除,此時所有Follower都嘗試建立該節點,而建立成功者(ZooKeeper保證只有一個能建立成功)即是新的Leader,其它Replica即為Follower。
但是該方法會有3個問題:
  • split-brain 這是由ZooKeeper的特性引起的,雖然ZooKeeper能保證所有Watch按順序觸發,但並不能保證同一時刻所有Replica“看”到的狀態是一樣的,這就可能造成不同Replica的響應不一致
  • herd effect 如果當機的那個Broker上的Partition比較多,會造成多個Watch被觸發,造成叢集內大量的調整
  • ZooKeeper負載過重 每個Replica都要為此在ZooKeeper上註冊一個Watch,當叢集規模增加到幾千個Partition時ZooKeeper負載會過重。
Kafka 0.8.*的Leader Election方案解決了上述問題,它在所有broker中選出一個controller,所有Partition的Leader選舉都由controller決定。controller會將Leader的改變直接通過RPC的方式(比ZooKeeper Queue的方式更高效)通知需為為此作為響應的Broker。同時controller也負責增刪Topic以及Replica的重新分配。


HA相關ZooKeeper結構
首先宣告本節所示ZooKeeper結構中,實線框代表路徑名是固定的,而虛線框代表路徑名與業務相關
admin (該目錄下znode只有在有相關操作時才會存在,操作結束時會將其刪除)


/admin/preferred_replica_election資料結構


  1. {
  2.    "fields":[
  3.       {
  4.          "name":"version",
  5.          "type":"int",
  6.          "doc":"version id"
  7.       },
  8.       {
  9.          "name":"partitions",
  10.          "type":{
  11.             "type":"array",
  12.             "items":{
  13.                "fields":[
  14.                   {
  15.                      "name":"topic",
  16.                      "type":"string",
  17.                      "doc":"topic of the partition for which preferred replica election should be triggered"
  18.                   },
  19.                   {
  20.                      "name":"partition",
  21.                      "type":"int",
  22.                      "doc":"the partition for which preferred replica election should be triggered"
  23.                   }
  24.                ],
  25.             }
  26.             "doc":"an array of partitions for which preferred replica election should be triggered"
  27.          }
  28.       }
  29.    ]
  30. }

  31. Example:

  32. {
  33.   "version": 1,
  34.   "partitions":
  35.      [
  36.         {
  37.             "topic": "topic1",
  38.             "partition": 8         
  39.         },
  40.         {
  41.             "topic": "topic2",
  42.             "partition": 16        
  43.         }
  44.      ]            
  45. }
複製程式碼


/admin/reassign_partitions用於將一些Partition分配到不同的broker集合上。對於每個待重新分配的Partition,Kafka會在該znode上儲存其所有的Replica和相應的Broker id。該znode由管理程式建立並且一旦重新分配成功它將會被自動移除。其資料結構如下:


  1. "fields":[ 

  2. "name":"version", 
  3. "type":"int", 
  4. "doc":"version id" 
  5. }, 

  6. "name":"partitions", 
  7. "type":{ 
  8. "type":"array", 
  9. "items":{ 
  10. "fields":[ 

  11. "name":"topic", 
  12. "type":"string", 
  13. "doc":"topic of the partition to be reassigned" 
  14. }, 

  15. "name":"partition", 
  16. "type":"int", 
  17. "doc":"the partition to be reassigned" 
  18. }, 

  19. "name":"replicas", 
  20. "type":"array", 
  21. "items":"int", 
  22. "doc":"a list of replica ids" 

  23. ], 

  24. "doc":"an array of partitions to be reassigned to new replicas" 



  25. }
複製程式碼

  1. Example:
  2. {
  3.   "version": 1,
  4.   "partitions":
  5.      [
  6.         {
  7.             "topic": "topic3",
  8.             "partition": 1,
  9.             "replicas": [1, 2, 3]
  10.         }
  11.      ]            
  12. }
複製程式碼
/admin/delete_topics資料結構:
  1. Schema:
  2. { "fields":
  3.     [ {"name": "version", "type": "int", "doc": "version id"},
  4.       {"name": "topics",
  5.        "type": { "type": "array", "items": "string", "doc": "an array of topics to be deleted"}
  6.       } ]
  7. }

  8. Example:
  9. {
  10.   "version": 1,
  11.   "topics": ["topic4", "topic5"]
  12. }
複製程式碼
brokers


broker(即/brokers/ids/[brokerId])儲存“活著”的broker資訊。資料結構如下:

  1. Schema:
  2. { "fields":
  3.     [ {"name": "version", "type": "int", "doc": "version id"},
  4.       {"name": "host", "type": "string", "doc": "ip address or host name of the broker"},
  5.       {"name": "port", "type": "int", "doc": "port of the broker"},
  6.       {"name": "jmx_port", "type": "int", "doc": "port for jmx"}
  7.     ]
  8. }

  9. Example:
  10. {
  11.     "jmx_port":-1,
  12.     "host":"node1",
  13.     "version":1,
  14.     "port":9092
  15. }
複製程式碼


topic註冊資訊(/brokers/topics/[topic]),儲存該topic的所有partition的所有replica所在的broker id,第一個replica即為preferred replica,對一個給定的partition,它在同一個broker上最多隻有一個replica,因此broker id可作為replica id。資料結構如下:
  1. Schema:
  2. { "fields" :
  3.     [ {"name": "version", "type": "int", "doc": "version id"},
  4.       {"name": "partitions",
  5.        "type": {"type": "map",
  6.                 "values": {"type": "array", "items": "int", "doc": "a list of replica ids"},
  7.                 "doc": "a map from partition id to replica list"},
  8.       }
  9.     ]
  10. }
  11. Example:
  12. {
  13.     "version":1,
  14.     "partitions":
  15.         {"12":[6],
  16.         "8":[2],
  17.         "4":[6],
  18.         "11":[5],
  19.         "9":[3],
  20.         "5":[7],
  21.         "10":[4],
  22.         "6":[8],
  23.         "1":[3],
  24.         "0":[2],
  25.         "2":[4],
  26.         "7":[1],
  27.         "3":[5]}
  28. }
複製程式碼


partition state(/brokers/topics/[topic]/partitions/[partitionId]/state) 結構如下:
  1. Schema:
  2. { "fields":
  3.     [ {"name": "version", "type": "int", "doc": "version id"},
  4.       {"name": "isr",
  5.        "type": {"type": "array",
  6.                 "items": "int",
  7.                 "doc": "an array of the id of replicas in isr"}
  8.       },
  9.       {"name": "leader", "type": "int", "doc": "id of the leader replica"},
  10.       {"name": "controller_epoch", "type": "int", "doc": "epoch of the controller that last updated the leader and isr info"},
  11.       {"name": "leader_epoch", "type": "int", "doc": "epoch of the leader"}
  12.     ]
  13. }

  14. Example:
  15. {
  16.     "controller_epoch":29,
  17.     "leader":2,
  18.     "version":1,
  19.     "leader_epoch":48,
  20.     "isr":[2]
  21. }
複製程式碼


controller 
/controller -> int (broker id of the controller)儲存當前controller的資訊

  1. Schema:
  2. { "fields":
  3.     [ {"name": "version", "type": "int", "doc": "version id"},
  4.       {"name": "brokerid", "type": "int", "doc": "broker id of the controller"}
  5.     ]
  6. }
  7. Example:
  8. {
  9.     "version":1,
  10.   "brokerid":8
  11. }
複製程式碼
/controller_epoch -> int (epoch)直接以整數形式儲存controller epoch,而非像其它znode一樣以JSON字串形式儲存。

broker failover過程簡介
  • Controller在ZooKeeper註冊Watch,一旦有Broker當機(這是用當機代表任何讓系統認為其die的情景,包括但不限於機器斷電,網路不可用,GC導致的Stop The World,程式crash等),其在ZooKeeper對應的znode會自動被刪除,ZooKeeper會fire Controller註冊的watch,Controller讀取最新的倖存的Broker。
  • Controller決定set_p,該集合包含了當機的所有Broker上的所有Partition。
  • 對set_p中的每一個Partition
    3.1 從/brokers/topics/[topic]/partitions/[partition]/state讀取該Partition當前的ISR
    3.2 決定該Partition的新Leader。如果當前ISR中有至少一個Replica還倖存,則選擇其中一個作為新Leader,新的ISR則包含當前ISR中所有幸存的Replica。否則選擇該Partition中任意一個倖存的Replica作為新的Leader以及ISR(該場景下可能會有潛在的資料丟失)。如果該Partition的所有Replica都當機了,則將新的Leader設定為-1。
    3.3 將新的Leader,ISR和新的leader_epoch及controller_epoch寫入/brokers/topics/[topic]/partitions/[partition]/state。注意,該操作只有其version在3.1至3.3的過程中無變化時才會執行,否則跳轉到3.1
  • 直接通過RPC向set_p相關的Broker傳送LeaderAndISRRequest命令。Controller可以在一個RPC操作中傳送多個命令從而提高效率。
    broker failover順序圖如下所示。

作者簡介
郭俊(Jason),碩士,從事大資料平臺研發工作,精通Kafka等分散式訊息系統及Storm等流式處理系統。

相關文章