大多數Kafka解釋止步于“這是一個分布式提交日志”和“它快是因為順序磁盤I/O”。這話沒錯,但只是冰山一角。本文打算完整穿過Kafka的內部——拓撲結構、存儲、復制、集群元數據、生產者路徑、消費者路徑以及線路協議——寫給那些已經知道什么是broker,并且想知道真正令人困惑的部分的人。
有一個念頭幾乎貫穿下面所有內容:Kafka拒絕隨機訪問。消費者從不請求某個鍵,只要求“偏移量N之后的所有內容”。這條單一約束解釋了存儲引擎為什么長成那樣,持久性為什么是一個復制故事而非磁盤刷盤故事,以及生產者與消費者的那么多機制為什么純粹是為了處理這個約束所制造的邊緣情況。
![]()
如果你已經走過“生產一條消息,消費一條消息”的教程,你手上已經有了這些詞匯——只是還沒看見它們底下到底是什么。本文按五層組織,每一層都建立在前一層之上:第一部分——物理模型,一個broker、主題和分區到底是什么,一個分區在磁盤上到底放了什么;第二部分——速度與安全,讀如何變快(頁緩存、零拷貝),寫如何實現持久(靠復制,而不是fsync);第三部分——協調,broker們如何就上面所有這些達成一致——誰還活著、誰是領導者、配置是什么;第四部分——客戶端契約,生產者的acks設置和消費者組的重平衡在機械層面到底干了什么;第五部分——底層,將這一切綁在一起的線路協議。
每一節都先從教程級別的概念說起,然后走得更遠——所以,如果你看到某一節開頭出現你已經了如指掌的內容,那是一個信號,你可以直接掠過,從“但是”那里接著往下讀。
下面我們先看一張總圖,然后一個部分一個部分地走:一條單一記錄從生產者到已提交偏移的整個路徑,KRaft控制器法定人數在一旁協調領導者關系。
第一部分:物理模型
1. 基本形狀:broker、主題、分區。簡單說,“在一個broker內部,你有主題。主題是消息流,為了可伸縮性,它被切分成多個分區。”沒錯,但是這三個詞,到底哪個指一臺實在的機器?因為本文的其余部分全系于這一點:Broker——一臺物理或虛擬服務器。它擁有CPU、內存、磁盤和網卡——這是集群里真實的硬件。主題——一個邏輯名稱,比如“user-signups”。它不是任何地方的文件,也不存在于任何一個單獨的broker上。分區——那個真正的東西。一個主題被分成若干分區,每個分區都是一個有序的、只能追加的記錄序列,它作為一個真實的目錄,坐落在某一臺特定broker的磁盤上,并且被復制到少數其他broker上以確保安全。
Kafka伸縮方式的一切,都源自分區作為物理存儲的真正單元這一事實。一個只有一個分區的主題,永遠只能在一個broker上伸展。而一個擁有上百個分區的主題,可以把工作鋪開到上百臺broker上去,因為每一個分區都是一個獨立的日志,獨立地被復制,獨立地由某個broker來領導。當你創建一個主題并指定分區數為12,你其實就是在告訴Kafka:“為我制造12個獨立的日志,將它們分散到我的broker上,并確保每個日志都有幾份副本。”
2. 分區在磁盤上是什么樣。如果你ssh到一臺broker并打開數據目錄,你會看到一堆以主題名加連字符加分數字命名的文件夾——“user-signups-0”、“user-signups-1”等等。進到里面,有三種文件:.log文件存著真正的消息;.index文件將偏移量映射到.log文件的字節位置,這樣就不用掃描整個文件;.timeindex文件將時間戳映射到偏移量。這些文件被分成段,當.log文件達到一定大小,就會滾動生成一個新的,舊的段可以被壓縮或刪除。你永遠看不到一個主題的“全局”文件,只有分區的本地文件。
第二部分:速度與安全
Kafka的快,不是因為它自己做磁盤I/O做得有多高明,而是因為它讓操作系統來替它做。生產者追加記錄到一個分區的leader,broker將這些記錄寫到一個內存映射的頁緩存區域,然后操作系統決定何時將它們刷到磁盤。讀這邊,消費者請求偏移量N之后的所有數據,broker直接從頁緩存里把數據復制到網絡套接字——如果數據還沒被清出緩存的話,這里根本不會發生磁盤讀。而零拷貝,借助sendfile()系統調用,讓數據從文件描述符直接流到套接字描述符,連用戶態內存都不用經過,這就把CPU拷貝量砍掉了一大截。
但速度歸速度,持久性怎么辦?如果你內存里有一大堆還沒寫入磁盤的數據,服務器斷電了怎么辦?Kafka的答案是:不是靠刷磁盤,而是靠復制。一個分區有一個leader和多個follower。生產者把消息發給leader,leader把這批消息寫進自己的頁緩存,并且同時把同一批數據推送給所有的follower,follower收到后也寫進自己的頁緩存。當leader看到足夠的follower已經確認收到(在acks=all模式下是所有同步副本),它就向生產者報告這批消息已提交。如果leader掛了,一個同步副本會被選為新leader,而不會丟失已被確認的消息。fsync是昂貴的,它把連續性變成了隨機波動。Kafka避開了這條路,把持久性問題轉化成了一個復制問題,于是它就能在保持順序寫入的同時,繼續跑得飛快。
第三部分:協調
前面的一切都依賴一個前提:集群自己知道誰是leader,誰還活著,配置是什么。這就是控制器和KRaft的用武之地。過去有一個單獨的ZooKeeper集合負責維護這些元數據,但如今的Kafka已經轉向了KRaft——KRaft將集群元數據作為一個內部主題來管理,由一個控制器法定人數通過共識來維護。當一個新的broker加入,它向控制器注冊自己。當一個broker失聯,控制器會重新分配它之前負責領導的分區,并告訴其他follower開始從新leader那里復制。當你想創建一個主題或改變配置,命令會發給控制器,控制器將它寫進元數據日志,然后推送給集群里的所有broker。一切關于狀態的決定,都是通過這個元數據日志按順序廣播的,這樣每個broker都能獨立得到同一個世界觀。
第四部分:客戶端契約
從生產者的視角看,acks設置就是那個決定你愿不愿意為速度犧牲持久度的旋鈕。acks=0,生產者把消息發出去,不等任何確認,最快,但消息可能就在網絡里丟了。acks=1,leader收到消息并寫入本地日志后就確認,這快且相對安全,但如果leader在將數據復制出去之前掛掉,消息就沒了。acks=all(或acks=-1),leader必須等所有同步副本都確認收到,才告訴生產者成功,這是最安全的,但延遲會增加,因為要等待最慢的那個follower跟上。
消費者這邊,消費者組里的每個消費者負責一個或多個分區的消費。當一個消費者加入或離開組,就會觸發一次重平衡:控制器命令整組暫停,重新為每個分區分配一個消費者,然后恢復。重平衡期間,不會有任何消息被處理。這就是為什么“靜態成員”和增量協作式重平衡被引入,以減少服務中斷。消費者通過提交偏移量到__consumer_offsets內部主題,來追蹤自己的進度,下一次它醒來時,從上次提交的偏移量處接著讀——這又回到了開篇那個根本約束:沒有隨機訪問,只有從某個偏移量開始的順序追趕。
第五部分:底層線路協議
最后,是連接所有這些組件的導線。Kafka使用一個二進制的、基于TCP的協議,它被設計成低開銷且可流水線化。請求由一個API鍵、一個相關性ID和一個請求體組成。生產者用ProduceRequest向leader分區發送批量消息。消費者用FetchRequest拉取一批消息。控制器與broker之間用專門的API調用來交換元數據和狀態。協議本身是可擴展的,新字段可以被追加到請求的尾部,老客戶端只是忽略它們,這就讓版本演進可以不中斷服務。而請求的最前面總是有一個整數,告訴broker這一整塊有多大,使得接收方能直接從網絡緩沖區中切出完整的一個請求,而無需做任何復雜的解析。
所有這些決定——拒絕隨機訪問、用復制替代刷盤、將狀態變化建模為不可變日志——最終都回溯到同一件事:Kafka在每一個層面上,都是一個日志優先的系統。生產者寫日志,消費者讀日志,broker復制日志,控制器也通過日志來協商集群狀態。一旦你把一切都看作日志,你就不難理解它為什么能這樣工作——以及為什么教程在講完“分布式提交日志”以后,總有這么多東西還沒說。
特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.