1. 程式人生 > >分散式系統基礎-訊息佇列之Kafka

分散式系統基礎-訊息佇列之Kafka

相對於前面所說的那些MQ前輩們,Kafka可謂是不走尋常路的“天才少年”。與久負盛名的前輩們不同,Kafka從一開始就是走“網際網路的野路子”,它拋棄了很多華而不實的企業級特性,專注於高效能與大規模這兩個網際網路應用的核心需求,並全面採用了新一代的分散式架構 的設計理念,從基因和技術兩方面拉開了與前輩們的距離。

我們先來看看Kafka的一些激動人心的特性。

  1. 高吞吐量、低延遲:Kafka每秒可以處理幾十萬條訊息,它的延遲最低只有幾毫秒,每個topic可以分多個partition, consumer group 對partition進行consume操作。
  2. 可擴充套件性:Kafka叢集支援熱擴充套件。
  3. 永續性、可靠性:訊息被持久化到本地磁碟,並且支援資料備份防止資料丟失。
  4. 容錯性:允許叢集中節點失敗(若副本數量為n,則允許n-1個節點失敗)。
  5. 高併發:支援數千個客戶端同時讀寫。

Kafka能擁有這樣優異的特性,與它的優良設計與編碼是分不開的。為了在做到高效能的訊息持久化及海量訊息時仍能保持常數時間複雜度的訪問效能,Kafka特地設計了一個精巧的訊息儲存系統。Kafka儲存訊息的檔案被稱為log,進入Kafka的訊息被不斷地append到檔案末尾,不論檔案資料檔案有多大,這個操作永遠都是0(1)的,而且訊息是不可變的,這種簡單的log形式的檔案結構很好地支撐了高吞吐量、多副本、訊息快速持久化的設計目標。此外,訊息的主體部分的格式在網路傳輸中和在log檔案上是一致的,也就是說訊息的主體部分可以從網路中讀取後直接寫入本地檔案中,也可以直接從檔案複製到網路中,而不需要在程式中二次加工,這有利於減少伺服器端的開銷。Kafka還採用了zero-copy (Java NIO File API)傳輸技術來提高I/O速度。如果log檔案很大,那麼查詢位於某處的Message就需要遍歷整個log檔案,效率會很低。為了解決這個問題,Kafka釆用了訊息分片 (Partition)、log分段(Segment)及增加索引(index)的 “組合拳”,如下圖所示。

這裡寫圖片描述

每個Topic上的訊息可以被分為N個獨立的Partion(分割槽)儲存,每個Partion裡的訊息Offset(可以理解為訊息的ID)從0開始不斷遞增,M個訊息為一組並形成一個單獨的Segment,每個Segment由兩個檔案所組成,其中start_offsetN.index為該Segment的索引檔案,而對應的 start_offsetN.log則是儲存具體Message內容的log檔案。log檔案的大小預設為1GB,每個Partion 裡的Segment檔名從0 開始編號,後續每個Segment的檔名為上一個Segment檔案最後一條訊息的offset值,比如上圖的00000000000000368769.log檔案中儲存訊息的Offset是從368769+1開始的,直到 368769+N。為了快速定位每個Segment裡的某條訊息,我們需要知道這個訊息在此檔案中的物理儲存位置,即是從第幾個位元組開始的。上圖中的00000000000000368769.index索引檔案就完成了上述目標,檔案中的每一行記錄了一個訊息的編號與它在log檔案中的儲存起始位置,比如圖中的3497這條記錄表明Segment裡的第3條訊息在log檔案中的儲存起始位置為 497。那麼,要查詢某個Partion裡的任意訊息,則如何知道去查哪個index檔案呢?其實很簡單, index檔名用二分法查詢即可,可見程式設計基礎多麼重要。為了加速index檔案的操作,又可以採用記憶體對映(MMAP) 的方式將整個或者一部分index檔案對映到記憶體中操作。

接下來我們說說Kafka分散式設計的核心亮點之一的訊息分割槽 。如下圖所示,與之前的MQ不同,Kafka裡Topic中的訊息可以被分為多個Partion,不同的Topic也可以指定不同的分割槽數,這裡的關鍵點是一個Topic的多個Partion可以分佈在不同的Broker上,而位於不同節點上的producer可以同時將訊息寫入多個Partion中,隨後這些訊息又被部署在不同的機器上對多個 Consumer分別消費,因此大大增加了系統的Scale out能力。

這裡寫圖片描述

在訊息分割槽的情況下,怎麼確定一個訊息應該進入哪個分割槽呢?正常情況下是通過訊息的key的 Hash值與分割槽數取模運算的結果來確定放入哪個分割槽的,如果key為 Null,則採用依次輪詢的簡單方式確定目標分割槽。此外,我們還可以自定義分割槽來將符合某種規則的訊息傳送到同一個分割槽。Kafka要求每個分割槽只能被一個Consumer消費,所以N個分割槽只能對應最多N個 Consumer,同時,訊息分割槽後只能保證每個分割槽下訊息消費的區域性順序性,而不能保證一個 Topic下多個分割槽訊息的全域性順序性。在消費訊息時,Consumer可以累積確認(Acknowledge)它所接收到的訊息,當它確認了某個offset的訊息時,就意味著之前的訊息也都被成功接收到, 此時Broker會更新ZooKeeper上此Broker所消費的offset記錄資訊,當 consumer意外宕機後, 由於可能沒有確認之前消費過的某些訊息,因此ZooKeeper上仍然記錄著舊的Offset資訊,當 consumer恢復以後,最近消費的訊息可能會被重複投遞,這就是Kafka所承諾的 “訊息至少投遞一次”(at-least-once delivery)的背後原因。

我們知道傳統的訊息系統有兩種模型:點對點與釋出-訂閱模式,Kafka則提供了一種單一抽象模型,從而將這兩種模型統一起來,即consumer group。consumer group可以被理解為Topic訂閱者的角色,Topic中的每個分割槽只會被此group中的一個consumer消費,但一個consumer可以同時消費Topic中的多個分割槽中的訊息。比如某個group訂閱了一個具有100個分割槽的topic,它下面有20個 consumer,則正常情況下Kafka平均會為每個consumer分配5個分割槽。此外,如果一個Topic被多個consumer group訂閱,則類似於訊息廣播。下圖中有4個分割槽的Topic中的每條訊息都會被廣播到Group A與 Group B中。

這裡寫圖片描述

當一個consumer group的成員發生變更時,例如新consumer加入組、己有consumer宕機或離開、分割槽數發生變化等,都會涉及分割槽與group成員的重新配對過程,這個過程就叫作 rebalance。下圖給出了新consumer加入一個group後的rebalance結果。

這裡寫圖片描述

我們再看看Kafka是如何解決HA問題的。

由於訊息都是儲存在每個Broker的本地磁碟中的,所以當某個Broker所在的機器磁碟損壞時,這些資料就永久性地丟失了。對於一個靠譜的訊息系統來說,這顯然是不可接受的缺陷。通過前面的學習,我們知道,分割槽結合副本的設計思路已經成為新一代分散式系統中的經典設計套路了,網際網路血統出身的Kafka毫無懸念地也採用了這種設計。下圖給出了一個分割槽結合副本模式的高可靠Kafka叢集,從圖中我們看到Topic A分為兩個分割槽(Partion 1與 2),每個分割槽都有3 個副本,其中一個副本為Leader,用於寫入訊息,其他副本為Follower,都從Leader同步訊息,這些副本分散在4個Broker上,任何一個Broker失效,都不會影響叢集的可用性, 如果這個Broker上恰好承擔著某個分割槽的Leader角色,則通過Leader選舉機制重新選擇下一 任 Leader。

這裡寫圖片描述

每個分割槽的Leader會跟蹤與其保持同步的Follower節點,該列表被稱為ISR ( 即 in-sync Replica) ,如果一個Follower巖機,或者掉隊太多(指 Follower複製的訊息落後於Leader的條數太多或者Follower的響應太慢),則 Leader將把它從ISR中移除。Producer釋出訊息到某個Partition之前,會先通過ZooKeeper找到該Partition的Leader,然後寫入訊息,Leader會將該訊息寫入其本地Log,而每個Follower都從Leader拉取訊息資料,Follower儲存的訊息順序與 Leader保持一致。Follower在收到該訊息並寫入其Log後,向 Leader傳送ACK, 一旦 Leader 收到了ISR中所有Follower 的ACK 應答,則該訊息被認為已經commit 了,Leader隨後將向 Producer傳送ACK,確認訊息釋出成功。

Kafka的複製機制很有趣,它既不是完全意義的同步複製,也不是簡單的非同步複製。完全同步複製要求所有能工作的Follower都複製完 (而不是僅僅來自ISR列表中的那些Follower), 才能確認訊息寫入成功,這種複製方式會極大地影響系統吞吐率,而高吞吐率是Kafka追求的非常重要的一個特性,因此這種模式顯然不能被接受。而非同步複製方式下,資料只要被Leader 寫入Log就被認為己經commit,在這種情況下所有Follower都落後於Leader是一個大概率事件,此時如果Leader突然罷工,則很可能會丟失資料,因此也是不能被接受的。而 Kafka使用 ISR名單的同步方式很巧妙地均衡了效能與可靠性這兩方面的要求。