《Kafka官方文件》設計(一)

青衫無名發表於2017-05-18

Design

1. Motivation

我們設計Kafka用來作為統一的平臺來處理大公司可能擁有的所有實時資料來源。為了做到這點,我們必須思考大量的使用場景。

它必須有高吞吐去支援大資料流,例如實時日誌聚合。

它必須優雅的處理資料積壓,以支援定期從離線系統載入資料。

這也以為這系統必須支援低延遲的分發來處理傳統訊息系統的場景。

我們想支援分割槽的、分散式的、實時的處理資料來源並建立新的資料來源,這推動了我們的分割槽和消費模型。

最後,將流反饋到其他系統進行服務的情況下,我們知道系統必須能夠保證容錯性,在部分機器故障的時候提供服務。

支援這些使用推動我們做了一些列特殊的元素設計,比起傳統的訊息系統更像是資料庫日誌。我們將在以下章節介紹一些設計要素。

2. Persistence

Don’t fear the filesystem!

Kafka強依賴檔案系統來儲存和快取訊息。“磁碟是緩慢的”是一個通常的認知,這是人們懷疑持久化的結構能否提供強大的效能。事實上,磁碟比人們想象的更慢也更快,這基於如何去使用它們;一個合適的設計可以使磁碟和網路一樣的快速。

影響磁碟效能的核心因素是磁碟驅動的吞吐和過去十年磁碟的查詢方式不同了。使用六個7200rpm的SATA RAID-5陣列的JBOD配置線性寫入能力為600MB/sec,而隨機寫的效能僅僅是100k/sec,相差了6000倍。線性寫入和讀取是最可預測的,並且被作業系統大量的優化。現代作業系統提供read-ahead和write-behind技術,他們大塊預讀資料,並將較小的羅機械合併成較大的物理寫入。在ACM Queue的文章中可以找到此問題相關的進一步討論;他們實際上發現順序訪問磁碟在某些情況下比隨機訪問記憶體還快。

為了彌補效能的差異,現代作業系統在使用主記憶體來做磁碟快取時變的越來越激進。當記憶體被回收時,現代作業系統將樂意將所有可用記憶體轉移到磁碟快取,而且效能會降低很多。所有的磁碟讀寫都需要通過這層快取。這個功能不會被輕易關閉,除非使用Direct IO,因此儘管在程式內快取了資料,這些資料也有可能在作業系統的pagecache中快取,從而被快取了兩次。

此外,我們建立在JVM之上,任何在Java記憶體上花費過時間的人都知道兩件事情:

物件的記憶體開銷非常大,通常將儲存的資料大小翻倍(或更多)。

Java的記憶體回收隨著堆內資料的增多變的越來越緩慢。

由於這些因素,使用檔案系統並依賴於pagecache要優於維護記憶體中快取或其他結構——我們至少可以通過直接訪問記憶體來是可用記憶體增加一倍,並通過儲存位元組碼而不是物件的方式來節約更多的記憶體。這樣做將可以在32G的機器上使用28-30GB的記憶體,而不需要承受GC的問題。此外,及時重啟服務,記憶體會保持有效,而程式內快取將需要重建(對於10G的資料可能需要10分鐘),否則需要從冷資料載入(可怕的初始化效能)。這也大大簡化了程式碼,因為保持快取和檔案之間的一致性是由作業系統負責的,這比程式中操作更不容易出錯。

這是一個簡單的設計:在程式內儘量緩衝資料,空間不足時將所有資料刷寫到磁碟,我們採用了相反的方式。資料並儘快寫入一個持久化的日誌而不需要立即刷到磁碟。實際上這只是意味著資料被轉移到了核心的pagecache。

(以pagecache為中心的設計風格)

Constant Time Suffices

在訊息系統中使用持久化資料通常是具有關聯的BTree或其他隨機訪問的資料結構,以維護訊息的後設資料。BTree是最通用的資料結構,可以在訊息系統中支援各種各樣的語義。BTree的操作時間複雜度是O(log N)。通常O(log N)被認為是固定時間的,但是在磁碟操作中卻不是。每個磁碟一次只能執行一個seek,所以並行度受到限制。因此即使少量的磁碟搜尋也會導致非常高的開銷。由於作業系統將快速的快取操作和非常慢的磁碟操作相結合,所以觀察到樹結構的操作通常是超線性的,因為資料隨固定快取增加。

直觀的,持久化佇列可以像日誌的解決方案一樣,簡單的讀取和追加資料到檔案的結尾。這個結構的優勢是所有的操作都是O(1)的,並且讀取是可以並行不會阻塞的。這具有明顯的效能優勢,因為效能與資料大小完全分離,可以使用低速的TB級SATA驅動器。雖然這些驅動器的搜尋效能不佳,但是對於大量讀寫而言,他們的效能是可以接受的,並且價格是三分之一容量是原來的三倍。

無需任何的效能代價就可以訪問幾乎無限的磁碟空間,這意味著我們可以提供一些在訊息系統中非尋常的功能。例如,在Kafka中,我們可以將訊息保留較長的時間(如一週),而不是在消費後就儘快刪除。這位消費者帶來了很大的靈活性。

3. Efficiency

我們在效率上付出了很大的努力。主要的用例是處理web的資料,這個資料量非常大:每個頁面可能會生成十幾個寫入。此外我們假設每個釋出的訊息至少被一個Consumer消費,因此我們儘可能使消費的開銷小一些。

從構建和執行一些類似的系統的經驗發現,效率是多租戶操作的關鍵。如果下游基礎服務成為瓶頸,那麼應用程式的抖動將會引起問題。我們確保應用程式不會引起基礎服務的Load問題,這個非常重要的,當一個叢集服務上百個應用程式的時候,因為應用的使用模式的變化時非常頻繁的。

我們在之前的章節中討論過磁碟的效率。一旦不良的磁碟訪問模式被消除,這種型別的系統有兩個低效的原因:太多太小的IO操作和過多的資料拷貝。

太小的IO操作問題存在於客戶端和服務端之間,也存在於服務端自身的持久化當中。

為了避免這個問題,我們的協議圍繞“message set”抽象,通常是將訊息聚合到一起。這允許網路請求將訊息聚合到一起,並分攤網路往返的開銷,而不是一次傳送單個訊息。服務端依次將大塊訊息追加到日誌中,消費者一次線性獲取一批資料。

這種簡單的優化產生了一個數量級的加速。分批帶來了更大的網路包,連續的磁碟操作,連續的記憶體塊等等,這些都使得Kafka將隨機訊息寫入轉化為線性的寫入並流向Consumer。

其他低效的地方是字元複製。在訊息少時不是問題,但是對負載的影響是顯而易見的。為了避免這種情況,我們採用被producer、broker、Consumer共享的標準的二進位制訊息格式(所以資料可以在傳輸時不需要進行修改)。

由Broker維護的訊息日誌本身只是一批檔案,每個檔案由一系列以相同格式寫入的訊息構成。保持相同的格式保證了最重要的優化:網路傳輸和持久化日誌塊。現在UNIX作業系統提供了高度優化的程式碼路徑用於將pagecache的資料傳輸到網路;在Linux中,這有sendfile實現。

要劉姐sendfile的影響,瞭解從檔案到網路傳輸資料的data path非常重要:

  1. 作業系統從磁碟讀取檔案資料到pagecache,在核心空間
  2. 使用者從核心空間將資料讀到使用者空間的buffer
  3. 作業系統重新將使用者buffer資料讀取到核心空間寫入到socket中
  4. 作業系統拷貝socket buffer資料到NIC buffer併傳送到網路

這顯然是低效的,有四個副本和兩個系統呼叫。使用sendfile,允許作業系統直接將資料從pagecache寫入到網路,而避免不必要的拷貝。在這個過程中,只有最終將資料拷貝到NIC buffer是必要的。

我們期望一個共同的場景是多個Consumer消費一個Topic資料,使用zero-copy優化,資料被拷貝到pagecache並且被多次使用,而不是每次讀取的時候拷貝到記憶體。這允許以接近網路連線的速度消費訊息。

pagecache和sendfile的組合意味著在消費者追上寫入的情況下,將看不到磁碟上的任何讀取活動,因為他們都將從快取讀取資料。

sendfile和更多的zero-copy背景知識見zero-copy

End-to-end Batch Compression

在一些場景下,CPU核磁碟並不是效能瓶頸,而是網路頻寬。在資料中心和廣域網上傳輸資料尤其如此。當然,使用者可以壓縮它的訊息而不需要Kafka的支援,但是這可能導致非常差的壓縮比,因為冗餘的大部分是由於相同型別的訊息之間的重複(例如JSON的欄位名)。多個訊息進行壓縮比單獨壓縮每條訊息效率更高。

Kafka通過允許遞迴訊息來支援這一點。一批訊息可以一起壓縮並以此方式傳送到服務端。這批訊息將以壓縮的形式被寫入日誌,只能在消費端解壓縮。

Kafka支援GZIP,Snappy和LZ4壓縮協議。更多的壓縮相關的細節在這裡

4. The Producer

Load balancing

Producer直接向Leader Partition所在的Broker傳送資料而不需要經過任何路由的干預。為了支援Producer直接向Leader Partition寫資料,所有的Kafka服務節點都支援Topic Metadata的請求,返回哪些Server節點存活的、Partition的Leader節點的分佈情況。

由客戶端控制將資料寫到哪個Partition。這可以通過隨機或者一些負載均衡的策略來實現(即客戶端去實現Partition的選擇策略)。Kafka暴露了一個介面用於使用者去指定一個Key,通過Key hash到一個具體的Partition。例如,如果Key是User id,那麼同一個User的資料將被髮送到同一個分割槽。這樣就允許消費者在消費時能夠對消費的資料做一些特定的處理。這樣的設計被用於處理“區域性敏感”的資料(結合上面的場景,Partition內的資料是可以保持順序消費的,那麼同一個使用者的資料在一個分割槽,那麼就可以保證對任何一個使用者的處理都是順序的)。

Asynchronous send

批處理是提升效率的主要方式一致,為了支援批處理,Kafka允許Producer在記憶體聚合資料並在一個請求中發出。批處理的大小可以是通過訊息數量指定的,也可以是通過等待的時間決定的(例如64K或者10ms)。這樣允許聚合更多的資料後傳送,減少了IO操作。緩衝的資料大小是可以配置了,這樣能適當增加延遲來提升吞吐。

更多的細節可以在Producer的配合和API文件中找到。

5 The Consumer

Kafka Consumer通過給Leader Partition所在的Broker傳送“fetch”請求來進行消費。Consumer在請求中指定Offset,並獲取從指定的Offset開始的一段資料。因此Consumer對消費的位置有絕對的控制權,通過重新設定Offset就可以重新消費資料。

Push vs Pull

我們考慮的一個初步問題是Consumer應該從Broker拉取資料還是Broker將資料推送給Consumer。在這方面,Kafka和大多數訊息系統一樣,採用傳統的設計方式,由Producer想Broker推送資料,Consumer從Broker上拉取資料。一些日誌中心繫統,如Scribe和Apache Flume,遵循資料向下遊推送的方式。兩種方式各有利弊。基於推送的方式,由於是由Broker控制速率,不能很好對不同的Consumer做處理。Consumer的目標通常是以最大的速率消費訊息,不幸的是,在一個基於推送的系統中,當Consumer消費速度跟不上生產速度 時,推送的方式將使Consumer“過載”。基於拉取的系統在這方面做的更好,Consumer只是消費落後並在允許時可以追上進度。消費者通過某種協議來緩解這種情況,消費者可以通過這種方式來表明它的負載,這讓消費者獲得充分的利用但不會“過載”。以上原因最終使我們使用更為傳統的Pull的方式。

Pull模型的另一個優勢是可以聚合資料批量傳送給Consumer。Push模型必須考慮是立即推送資料給Consumer還是等待聚合一批資料之後傳送。如果調整為低延遲,這將導致每次只傳送一條訊息(增加了網路互動)。基於Pull的模式,Consumer每次都會盡可能多的獲取訊息(受限於可消費的訊息數和配置的每一批資料最大的訊息數),所以可以優化批處理而不增加不必要的延遲。

基於Pull模式的一個缺陷是如果Broker沒有資料,Consumer可能需要busy-waiting的輪訓方式來保證高效的資料獲取(在資料到達後快速的響應)。為了避免這種情況,我們在Pull請求中可以通過引數配置“long poll”的等待時間,可以在服務端等待資料的到達(可選的等待資料量的大小以保證每次傳輸的資料量,減少網路互動)。

你可以想象其他一些從端到端,採用Pull的可能的設計。Producer把資料寫到本地日誌,Broker拉取這些Consumer需要的資料。一個相似的被稱為“store-and-forward”的Producer經常被提及。這是有趣的,但是我們覺得不太適合我們可能會有成千上萬個Producer的目標場景。我們維護持久化資料系統的經驗告訴我們,在系統中使多應用涉及到上千塊磁碟將會使事情變得不可靠並且會使操作它們變成噩夢。最後再實踐中,我們發現可以大規模的執行強大的SLAs通道,而不需要生產者持久化。

Consumer Position

記錄哪些訊息被消費過是訊息系統的關鍵效能點。

大多數訊息系統在Broker上儲存哪些訊息已經被消費的後設資料。也就是說,Broker可以在消費傳遞給Consumer後立即記錄或等待消費者確認之後記錄。這是一個直觀的選擇,並且對於單個伺服器而言並沒有更好的方式可以儲存這個狀態。大多數訊息系統中的儲存裝置並不能很好的伸縮,所以這也是務實的選擇——當Broker確認訊息被消費後就立即刪除,以保證儲存較少的資料。

讓Broker和Consumer關於那些訊息已經被消費了達成一致並不是一個簡單的問題。如果Broker在將訊息寫到網路之後就立即認為訊息已經被消費,那麼如果Consumer消費失敗(Consumer在消費訊息之前Crash或者網路問題等)訊息將丟失。為了解決這個問題,一些訊息系統增加了ACK機制,訊息被標記為只是傳送出去而不是已經被消費,Broker需要等待Consumer傳送的ACK請求之後標記具體哪些訊息已經被消費了。這個策略修復了訊息丟失的問題,但是引起了新的問題。第一,如果Consumer處理了訊息,但是在傳送ACK給Broker之前出現問題,那麼訊息會被重複訊息。第二,Broker需要維護每一條訊息的多個狀態(是否被髮送、是否被消費)。棘手的問題是要處理被髮送出去但是沒有被ACK的訊息。

Kafka採用不同的方式處理。Topic被劃分為多個內部有序的分割槽,每個分割槽任何時刻只會被一個group內的一個Consumer消費。這意味著一個Partition的Position資訊只是一個數字,標識下一條要消費的訊息的偏移量。這使得哪些訊息已經被消費的狀態變成了一個簡單的資料。這個位置可以定期做CheckPoint。這使得訊息的ACK的代價非常小。

這個方案還有其他的好處。消費者可以優雅的指定一箇舊的偏移量並重新消費這些資料。這和通常的訊息系統的觀念相違背,但對很多消費者來說是一個很重要的特性。比如,如果Consumer程式存在BUG,在發現並修復後,可以通過重新消費來保證資料都正確的處理。

Offline Data Load

可擴充套件的持久化儲存的能力,是消費者可以定期的將資料匯入到像Hadoop這樣的離線系統或關係型資料倉儲中。

在Hadoop的場景中,我們通過把資料分發到獨立的任務中進行並行處理,按照node/topic/partition組合,充分使用另行能力載入資料。Hadoop提供任務管理,失敗的任務可以重新啟動,而不需要擔心重複資料的危險——任務會從原始位置重新啟動。

轉載自 併發程式設計網 – ifeve.com


相關文章