對Kafka的探索

1.kafka名詞解釋

Broker:一個kafka節點就是一個broker,多個kafka節點可以組成一個kafka集羣

Topic:每條kafka消息都有它的topic,kafka根據topic做消息的分類

Producer:消息的生產者,負責向broker發送消息的客戶端

Consumer:消息的消費者,負責從broker拉取消息來進行消費(解析消息然後執行自己的邏輯)

Consumer Group:一個group裏可以有一個或多個consumer,一種topic可以制定哪個group能消費這類消息,這條消息發出來之後,指定的group中只能有一個consumer去消費它

Partition:物理上的概念,一種topic的消息發出來之後會經過hash操作落到不同的partition中

offset:用來標記消息順序的序號,每個partition之內會根據offset按順序執行消息的操作

一個topic對應多個partition,partition分佈在多broker上,多broker一起提供kafka服務

 

2.kafka的設計

持久化:從大學上操作系統課時,老師一直在強調的一個觀點就是,cpu最快,內存其次,磁盤最慢,導致很多人印象中磁盤很慢很慢,再加上現在一些技術,像redis之類的是運行在內存中,在內存中做實時的數據增刪改查,異步持久化到磁盤,更加印證了人們的觀點。但是實際上,正確地去使用磁盤,速度將會比我們通常認爲的要快的多:

 

從kafka官方文檔中的這段話可以看出,在磁盤中的隨機寫確實很慢,但是如果是順序寫,其實是很快的,結合之前大學時候上的課也可以知道這是因爲磁盤順序寫可以節省尋道時間,大大提升磁盤使用效率。

基於這些來考慮,kafka使用了文件來存儲消息,這個文件的名字是Appender Log,同時,它沒有使用我們在數據庫索引中或是別的場景中使用的B/B+樹之類的數據結構(時間複雜度O(logN)),而是直接使用隊列,對日誌文件進行讀取並附加的操作,這樣能使得操作的時間複雜度是O(1),而且由於是附加操作,也就是順序寫,從而省去了我上文提到的磁盤尋道的時間,大大提升時間效率。

這樣設計之後,因爲磁盤非常的廉價,所以如果有足夠大的磁盤,我們可以近乎無限地去存儲消息的記錄,然後對消息存檔做定期刪除,kafka適合用來做大數據、日誌系統這個就是原因之一。

 

消息整合發送:對磁盤效率上的考慮除了順序寫之外,還有多次IO的問題,kafka在這一點上的設計很好理解,一個形象的比喻就是,一次拉一車蘋果去賣和一次拿一個蘋果去賣,當然是前者比較合適,可以類比的像tcp分組發送,當然這種設計理念也會造成一些問題,比如正是tcp這樣做,我們在使用NIO開發的時候才需要考慮半包和拆包的問題,kafka當然不用考慮半包和拆包(因爲kafka就是基於tcp協議,而且從邏輯上考慮,粒度再小也不會小於一條消息的粒度,總不可能給別人發半條消息過去,這裏只是和tcp分組做個類比),kafka需要考慮的是消息的即時性問題,也就是說,有可能會出現producer發了一條消息,這條消息需要等到一整個分組都發過去的時候才能發過去,但是其實這個分組發送是非常快的,而且消息隊列本身就不是爲了即時操作而生的,如果想要服務之間通信的即時性,還是使用RPC框架更合適。

這樣設計之後,kafka不僅能夠減少網絡上的開銷,同時還能一次性向文件中寫入這一整個分組的消息,這樣也是減少了磁盤上的開銷。

 

使用sendfile優化字節複製:由於我們是將消息存儲在文件當中,因此也需要考慮這個過程中是否有可以優化性能的點,傳統的操作是這樣的:

1、調用read函數,文件數據被copy到內核緩衝區
2、read函數返回,文件數據從內核緩衝區copy到用戶緩衝區
3、write函數調用,將文件數據從用戶緩衝區copy到內核與socket相關的緩衝區。
4、數據從socket緩衝區copy到相關協議引擎。

(最後一步在kafka的操作中是:操作系統將數據從套接字緩衝區複製到通過網絡發送的NIC緩衝區)

這樣是比較低效的,因爲會有4個副本、2個系統調用、4次上下文切換:

1、系統調用 read() 產生一個上下文切換:從 user mode 切換到 kernel mode,然後 DMA 執行拷貝,把文件數據從硬盤讀到一個 kernel buffer 裏。
2、數據從 kernel buffer 拷貝到 user buffer,然後系統調用 read() 返回,這時又產生一個上下文切換:從kernel mode 切換到 user mode。
3、系統調用 write() 產生一個上下文切換:從 user mode 切換到 kernel mode,然後把步驟2讀到 user buffer 的數據拷貝到 kernel buffer(數據第2次拷貝到 kernel buffer),不過這次是個不同的 kernel buffer,這個 buffer 和 socket 相關聯。
4、系統調用 write() 返回,產生一個上下文切換:從 kernel mode 切換到 user mode(第4次切換了),然後 DMA 從 kernel buffer 拷貝數據到協議棧(第4次拷貝了)。

而通過sendfile函數,我們可以直接通過允許OS將數據從頁面緩存直接發送到網絡來避免這種重新複製。因此,在此優化路徑中,僅需要最終複製到NIC緩衝區:

1、sendfile系統調用,文件數據被copy至內核緩衝區
2、再從內核緩衝區copy至內核中socket相關的緩衝區
3、最後再socket相關的緩衝區copy到協議引擎

 

壓縮消息:

在某些情況下,瓶頸實際上不是CPU或磁盤,而是網絡帶寬。對於需要通過廣域網在數據中心之間發送消息的數據管道而言,尤其如此。當然,用戶總是可以一次壓縮其消息,而無需Kafka的任何支持,但這可能導致非常糟糕的壓縮率,因爲大量冗餘是由於相同類型消息之間的重複(例如, Web日誌中的JSON或用戶代理或通用字符串值)。高效壓縮需要將多個消息壓縮在一起,而不是分別壓縮每個消息。

 

Kafka以有效的批處理格式支持此操作。一批消息可以壓縮在一起,然後以這種形式發送到服務器。這批消息將以壓縮形式寫入,並保持壓縮在日誌中,並且僅由使用者解壓縮。

 

Kafka支持GZIP,Snappy,LZ4和ZStandard壓縮協議。

Pull和Push:在消息隊列中比較避不開的一個問題就是pull還是push,也就是消息是被主動地推送給消費者,還是被消費者主動地去拉取,下面是Pull和Push的區別:

push方式

消息保存在服務端。容易造成消息堆積。
服務端需要維護每次傳輸狀態,遇到問題需要重試
非常實時
服務端需要依據訂閱者消費能力做流控(流轉機制)


pull方式

保存在消費端。獲取消息方便。
傳輸失敗,不需要重試
默認的端短詢方式的實時性依賴於pull間隔時間,間隔越大,實時性越低,長輪詢方式和push一致
消費端可以根據自身消費能力決定是否pull(流轉機制)
 

從中可以看到,push方式還是有不少的缺陷的,kafka也是推崇使用pull模式,只不過消息是被推到broker,然後消費者從broker拉取:

基於拉取的系統的另一個優點是,它有助於對發送給使用者的數據進行積極的批處理。基於推送的系統必須選擇立即發送請求或累積更多數據,然後在不知道下游使用者是否能夠立即處理請求的情況下稍後發送。如果針對低延遲進行了調整,這將導致每次僅發送一條消息,以使傳輸最終無論如何都被緩衝,這很浪費。基於拉取的設計可解決此問題,因爲使用者始終將所有可用消息拉至其在日誌中的當前位置之後(或達到某些可配置的最大大小)。這樣一來,您可以在不引入不必要延遲的情況下獲得最佳批處理。

不過pull模式也不是沒有缺陷,當消費者通過輪詢拉取消息的時候,如果沒有消息被髮送過來,就會造成拉取操作會一直“空轉”,比較簡單的解決方案就是在發現空轉的時候及時制止拉取,直到有新的消息到達,也就是先把消費者拉取操作阻塞,kafka也是這麼做的:

基於拉取的系統的不足之處在於,如果broker沒有數據,則消費者可能會在緊密的循環中進行輪詢,從而實際上忙於等待數據到達。爲避免這種情況,我們在拉取請求中有一些參數,這些參數允許消費者請求阻塞在“長時間輪詢”中,直到數據到達爲止(並可選地等待直到給定數量的字節可用以確保較大的傳輸大小)。

kafka對消息的追蹤:如果希望消息被消費者消費後不會被重複消費,那麼有兩種選擇:1.在消息被從broker中拉取的時候,在broker中做下記錄,或者乾脆從broker中刪除該條消息;2.在消息被消費者拉取消費了之後,讓消費者對broker進行一步確認操作,broker收到確認之後才刪除消息。

如果使用第一種,可能會出現的問題就是,消費者拉取了消息,broker就把消息刪除了,但是消費者在消費消息的過程中出現了異常,沒有消費成功,這時候如果再想執行一些類似重新消費的操作的時候會發現broker中的消息已經被刪除了,也就是丟掉了這條消息;如果使用第二種,可能出現的情況就是,消費者消費成功了,但是在發回確認結果的時候出現了異常,導致broker沒有收到消費者的確認,這時候消費者再去broker中拿消息的時候,會重複消費之前明明已經消費過的消息,想杜絕這種現象只能在broker中爲消息加上“已發送”和“已消費”這樣的狀態,但是這樣又會使邏輯變得複雜,同時影響了性能。

kafka並沒有選擇這樣兩種方式,它用了上文名詞解釋中提到的offset,如果把broker看成消息組成的數組,那麼offset就相當於數組的索引下標,kafka在每個consumer中存儲了下次要消費的消息的offset,當從broker中拉取並消息成功的時候,直接遞增offset然後再用offset從broker中拉取對應的消息,如果消費失敗了需要重試,那就不遞增offset重新從broker中拉取上次的消息就可以了。同時,broker中不會刪除消息(是一直不刪除還是定期刪除這點不太清楚,推測是可以進行配置,歡迎指正),這樣可以根據offset隨時取出之前的消息

 

3.kafka的高可用性

kafka消息語義:

  • 最多一次-消息可能會丟失,但永遠不會重新發送。
  • 至少一次-消息永不丟失,但可以重新傳遞。
  • 恰好一次 -人們真正想要的是,每條消息只傳遞一次,也只有一次。

kafka的replica副本機制:

 

如圖所示,kafka通過副本機制保證可用性,一個topic的消息會落到不同的partition中,每個partition對應多個broker,其中一個broker是leader,另外的broker是follower,follower會從leader中拉取消息到自己這裏,來完成和leader的同步,消費者消費消息和生產者發送消息的時候,都是和leader打交道,一旦發生宕機問題,某個leader掛了,那麼就會通過選舉從follower中重新選出一個leader來,由於之前leader的消息已經同步到follower中,因此現在的follower是leader的replica(副本),所有的消息和之前leader中的一致。kafka判斷節點“活着”的標準是:

  1. 節點必須能夠與ZooKeeper保持會話(通過ZooKeeper的心跳機制)
  2. 如果是追隨者,則必須複製在領導者上發生的寫入,並且不要落後太遠

kafka的選舉機制:通常的分佈式多選舉機制像zab、raft、paxos之類的使用的多選票機制對於kafka來說並不完全適合(日後會整理一篇關於選舉機制的詳細文章),因爲傳統的機制下,容忍一個故障需要數據的三個副本,而容忍兩個故障則需要數據的五個副本。kafka認爲對於一個實際的系統而言,僅具有足夠的冗餘度來容忍單個故障是不夠的,對於大容量數據問題而言,每次寫入五次(磁盤空間需求爲5倍,吞吐量爲1/5)並不十分實際,所以kafka的選舉有一些區別。

上面說到,follower會從leader拉取消息做同步,但是這個拉取是有滯後的,也就是不可能每時每刻每臺follower都和leader的消息完全一樣,kafka把這些滯後的follower叫做OSR,把相對來說同步比較及時的follower叫做ISR,把所有的follower統稱爲AR,也就是說:AR = ISR + OSR

在kafka選舉過程中,只有ISR纔有資格被選舉爲leader,如果ISR中的一個follower落後了太多,將會被leader從ISR中剔除;同時,在寫入消息的時候,只有當ISR中所有follower都向leader發送了ack之後,纔算是寫入完成,leader纔會告訴客戶端消息寫入完成了。

這裏可能有個前後矛盾的問題,既然leader要等ISR中的follower都寫入完成,那ISR中怎麼還會有因爲滯後太多被剔除的follower?實際上,因爲之前提到的消息是被分組批量發送,所以leader一次性接受的實際上是多條消息,當leader接收了1000條消息,發現ISR中有一個follower只同步了500條,其他的follower都同步了999條的時候,會先把500條的follower剔除掉,然後再等待剩下的999條follower都同步完發送了ack之後,才向客戶端返回成功結果。

常見的導致follower同步跟不上的原因主要是下面幾個:

1、加入新的副本(每個新的副本加入都需要一段信息同步的追趕時期) 2、網絡IO等原因,某些機器IO處理速度變慢所導致持續消費落後。 3、進程卡住(Kafka 是Java 寫出來的,Java 進程容易出現Full GC過多問題,及高頻次GC)

ISR是如何伸縮的:

Kafka在啓動的時候會開啓兩個與ISR相關的定時任務,名稱分別爲“isr-expiration"和”isr-change-propagation".。isr-expiration任務會週期性的檢測每個分區是否需要縮減其ISR集合。這個週期和“replica.lag.time.max.ms”參數有關。大小是這個參數一半。默認值爲5000ms,當檢測到ISR中有是失效的副本的時候,就會縮減ISR集合。如果某個分區的ISR集合發生變更, 則會將變更後的數據記錄到ZooKerper對應/brokers/topics//partition//state節點中。節點中數據示例如下:

{“controller_cpoch":26,“leader”:0,“version”:1,“leader_epoch”:2,“isr”:{0,1}}

其中controller_epoch表示的是當前的kafka控制器epoch.leader表示當前分區的leader副本所在的broker的id編號,version表示版本號,(當前版本固定爲1),leader_epoch表示當前分區的leader紀元,isr表示變更後的isr列表。

除此之外,當ISR集合發生變更的時候還會將變更後的記錄緩存到isrChangeSet中,isr-change-propagation任務會週期性(固定值爲2500ms)地檢查isrChangeSet,如果發現isrChangeSet中有ISR 集合的變更記錄,那麼它會在Zookeeper的/isr_change_notification的路徑下創建一個以isr_change開頭的持久順序節點(比如/isr_change_notification/isr_change_0000000000), 並將isrChangeSet中的信息保存到這個節點中。kafka控制器爲/isr_change_notification添加了一個Watcher,當這個節點中有子節點發生變化的時候會觸發Watcher動作,以此通知控制器更新相關的元數據信息並向它管理的broker節點發送更新元數據信息的請求。最後刪除/isr_change_notification的路徑下已經處理過的節點。頻繁的觸發Watcher會影響kafka控制器,zookeeper甚至其他的broker性能。爲了避免這種情況,kafka添加了指定的條件,當檢測到分區ISR集合發生變化的時候,還需要檢查一下兩個條件:

(1).上一次ISR集合發生變化距離現在已經超過5秒,

(2).上一次寫入zookeeper的時候距離現在已經超過60秒。

滿足以上兩個條件之一者可以將ISR寫入集合的變化的目標節點。

有縮減就會有補充,那麼kafka何時擴充ISR的?

隨着follower副本不斷進行消息同步,follower副本要拉取的消息的offset也會逐漸後移,並且最終趕上leader副本,此時follower副本就有資格進入ISR集合,追趕上leader副本的判定準則是此副本的offset是否小於leader副本HW(HW是high watermark,下面會介紹這個參數的含義),這裏並不是和leader副本LEO相比。ISR擴充之後同樣會更新ZooKeeper中的/broker/topics//partition//state節點和isrChangeSet,之後的步驟就和ISR收縮的時候相同。

 

 

如果所有節點都宕機了怎麼辦:一種最極端的情況就是,無論leader還是follower都掛了,這時候kafka提供了兩種恢復方案:1.等待ISR中任意一個follower恢復,選它當leader,這樣會等待時間較長,而且如果ISR中的所有Replica都無法恢復或者數據丟失,則該Partition將永不可用,但是這樣會讓消息的一致性較高 2.選擇第一個恢復的Replica爲新的Leader,無論它是否在ISR中,這樣如果選擇了OSR中的副本,可能會造成比較大量的消息丟失,但是這樣會讓可用性比較高。默認情況下,kafka使用第一種策略,這個策略是可以通過配置更改的。

 

kafka的水位:

HW是High Watermark的縮寫, 俗稱高水位,它表示了一個特定消息的偏移量(offset),消費者之只能拉取到這個offset之前的消息;

LEO是Log End Offset的縮寫,它表示了當前日誌文件中下一條待寫入消息的offset

 

當ISR集合發生增減時,或者ISR集合中任一副本LEO發生變化時,都會影響整個分區的HW。

 

4.使用Kafka時可能會出現的問題

消息順序性:雖然kafka提供了offset的概念對消息做順序性處理,但是在一些情況下依然會出現消費順序亂掉的問題,例如:想讓mysql服務對一條商品訂單數據做增、改、刪三步操作,於是給對應的服務發送了消息,發過去的消息順序是增改刪順序,如果沒有使用唯一id例如這個商品的id作爲hash依據的話,三條消息可能會落到不同的partition中,造成取出的時候消息順序錯亂,如果使用唯一id作爲hash憑證,到同一個partition中offset的順序也是增改刪的順序沒問題,但是取出的時候如果消費者使用多線程去處理消息執行邏輯,順序就不可控了,也許會出現刪、增、改這樣的消費順序,這樣本來刪掉的數據被保留,由於沒有報錯還不容易查出這樣的bug。

解決方法有兩種,一種是用唯一id,一個partition,一個線程去消費,但是這樣相當於串行化了,少量消息操作不太耗時還可以,消息多了之後一定會出現消息擠壓;另一種方法是,使用多個內存queue,從partition中取出消息的時候,將有順序的三條消息根據id散列到同一個內存queue裏,每個處理線程對應一個內存queue進行消息處理邏輯操作,如圖:

 

消息積壓:如果消費端發生了異常,導致消息無法消費,一直積壓在mq中怎麼辦?一種方法是把消費端恢復正常,然後慢慢等它消費完成,但是如果消費的速率比增長的速率要慢,而且如果算算時間,在業務高峯期之前無法消費完積壓的消息或者乾脆問題就是發生在業務高峯期的時候,那就要考慮 第二種辦法,就是讓消息加速消費,首先要修復消費端的問題,然後新建一個topic,topic的partition是原來的十倍,然後建立十倍於原來的內存queue,寫一個臨時分發消息的consumer程序,程序什麼邏輯操作都不做,只是單純地消費積壓的消息然後把消息均勻灌到十倍queue中,然後再臨時徵用十倍於原來的consumer機器,每批consumer用和正常consumer一樣的邏輯去消費對應queue中的消息,這樣可以做到以十倍的速度消費積壓的消息,等到消費全部完成之後,恢復原有架構即可。(不清楚有沒有更好的辦法)

 

消息重複消費:儘管kafka在設計上盡力爲我們保證不會重複消費消息,但是在一些極端情況下還是有可能出現這種情況,例如直接終止程序運行,有可能會導致消費完了消息之後offset還沒有來的及更新,當重新啓動程序的時候,就會出現重複消費之前消費過的消息的情況,這種情況比較簡單的解決方式就是保證消息的冪等性就好了,而且是從業務的層面上去保證,例如,消費消息做數據庫插入操作,如果再插入之前判一下重,即使重複消費了,也沒有關係。

 

Topic多了kafka性能會顯著下降:因爲Kafka的每個Topic、每個分區都會對應一個物理文件。當Topic數量增加時,消息分散的落盤策略會導致磁盤IO競爭激烈成爲瓶頸。而一些別的mq例如RocketMQ所有的消息是保存在同一個物理文件中的,Topic和分區數對RocketMQ也只是邏輯概念上的劃分,所以Topic數的增加對RocketMQ的性能不會造成太大的影響。

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章