這篇文章,專治MQ中介軟體各種疑難雜症

資料和雲發表於2019-03-18

今天這篇文章為大家總結下 MQ 應用中的一些疑難雜症。


訊息佇列有什麼優點和缺點?

為什麼使用訊息佇列?假設你的業務場景遇到個技術挑戰,如果不用 MQ 可能會很麻煩,但是你用了 MQ 之後會帶給你很多好處。

訊息佇列 MQ 的常見使用場景其實有很多,但是比較核心的有如下三個:

  • 解耦

  • 非同步

  • 削峰

解耦:A 系統傳送個資料到 BCD 三個系統,介面呼叫傳送,那如果 E 系統也要這個資料呢?那如果 C 系統現在不需要了呢?

現在 A 系統又要傳送第二種資料了呢?而且 A 系統要時時刻刻考慮 BCDE 四個系統如果掛了咋辦?要不要重發?我要不要把訊息存起來?

你需要去考慮一下你負責的系統中是否有類似的場景,就是一個系統或者一個模組,呼叫了多個系統或者模組,互相之間的呼叫很複雜,維護起來很麻煩。

但是,這個呼叫是不需要直接同步呼叫介面的,如果用 MQ 給他非同步化解耦,也是可以的。你就只需要去考慮在你的專案裡,是不是可以運用這個 MQ 去進行系統的解耦。

非同步:A 系統接收一個請求,需要在自己本地寫庫,還需要在 BCD 三個系統寫庫,自己本地寫庫要 30ms,BCD 三個系統分別寫庫要 300ms、450ms、200ms。

最終請求總延時是 30 + 300 + 450 + 200 = 980ms,接近 1s,非同步後,BCD 三個系統分別寫庫的時間,A 系統就不再考慮了。

削峰:每天 0 點到 16 點,A 系統風平浪靜,每秒併發請求數量就 100 個。結果每次一到 16 點~23 點,每秒併發請求數量突然會暴增到 10000 條。

但是系統最大的處理能力就只能是每秒鐘處理 1000 個請求啊。怎麼辦?需要我們進行流量的削峰,讓系統可以平緩的處理突增的請求。

優點上面已經說了,就是在特殊場景下有其對應的好處,解耦、非同步、削峰,那麼訊息佇列有什麼缺點?

系統可用性降低:系統引入的外部依賴越多,越容易掛掉,本來你就是 A 系統呼叫 BCD 三個系統的介面就好了。

ABCD 四個系統好好的,沒啥問題,你偏加個 MQ 進來,萬一 MQ 掛了怎麼辦?MQ 掛了,整套系統崩潰了,業務也就停頓了。

系統複雜性提高:硬生生加個 MQ 進來,怎麼保證訊息沒有重複消費?怎麼處理訊息丟失的情況?怎麼保證訊息傳遞的順序性? 

一致性問題:A 系統處理完了直接返回成功了,大家都以為你這個請求就成功了。

但問題是,要是 BCD 三個系統那裡,BD 兩個系統寫庫成功了,結果 C 系統寫庫失敗了,你這資料就不一致了。

所以訊息佇列實際是一種非常複雜的架構,你引入它有很多好處,但是也得針對它帶來的壞處做各種額外的技術方案和架構來規避掉。 

常見訊息佇列的比較如下圖:

這篇文章,專治MQ中介軟體各種疑難雜症

如何解決重複消費?

訊息重複的原因

訊息傳送端應用的訊息重複傳送,有以下幾種情況:

  • 訊息傳送端傳送訊息給訊息中介軟體,訊息中介軟體收到訊息併成功儲存,而這時訊息中介軟體出現了問題,導致應用端沒有收到訊息傳送成功的返回因而進行重試產生了重複。

  • 訊息中介軟體因為負載高響應變慢,成功把訊息儲存到訊息儲存中後,返回“成功”這個結果時超時。

  • 訊息中介軟體將訊息成功寫入訊息儲存,在返回結果時網路出現問題,導致應用傳送端重試,而重試時網路恢復,由此導致重複。

可以看到,透過訊息傳送端產生訊息重複的主要原因是訊息成功進入訊息儲存後,因為各種原因使得訊息傳送端沒有收到“成功”的返回結果,並且又有重試機制,因而導致重複。

訊息到達了訊息儲存,由訊息中介軟體進行向外的投遞時產生重複,有以下幾種情況:

  • 訊息被投遞到訊息接收者應用進行處理,處理完畢後應用出問題了,訊息中介軟體不知道訊息處理結果,會再次投遞。

  • 訊息被投遞到訊息接收者應用進行處理,處理完畢後網路出現問題了,訊息中介軟體沒有收到訊息處理結果,會再次投遞。

  • 訊息被投遞到訊息接收者應用進行處理,處理時間比較長,訊息中介軟體因為訊息超時會再次投遞。

  • 訊息被投遞到訊息接收者應用進行處理,處理完畢後訊息中介軟體出問題了,沒能收到訊息結果並處理,會再次投遞。

  • 訊息被投遞到訊息接收者應用進行處理,處理完畢後訊息中介軟體收到結果但是遇到訊息儲存故障,沒能更新投遞狀態,會再次投遞。


可以看到,在投遞過程中產生的訊息重複接收主要是因為訊息接收者成功處理完訊息後,訊息中介軟體不能及時更新投遞狀態造成的。

如何解決重複消費

那麼有什麼辦法可以解決呢?主要是要求訊息接收者來處理這種重複的情況,也就是要求訊息接收者的訊息處理是冪等操作。

什麼是冪等性?對於訊息接收端的情況,冪等的含義是採用同樣的輸入多次呼叫處理函式,得到同樣的結果。

例如,一個 SQL 操作:

update stat_table set count10 where id =1


這個操作多次執行,id 等於 1 的記錄中的 count 欄位的值都為 10,這個操作就是冪等的,我們不用擔心這個操作被重複。

再來看另外一個 SQL 操作:

update stat_table set countcount +1 where id1;


這樣的 SQL 操作就不是冪等的,一旦重複,結果就會產生變化。

因此應對訊息重複的辦法是使訊息接收端的處理是一個冪等操作。這樣的做法降低了訊息中介軟體的整體複雜性,不過也給使用訊息中介軟體的訊息接收端應用帶來了一定的限制和門檻。

①MVCC

多版本併發控制,樂觀鎖的一種實現,在生產者傳送訊息時進行資料更新時需要帶上資料的版本號,消費者去更新時需要去比較持有資料的版本號,版本號不一致的操作無法成功。

例如部落格點贊次數自動 +1 的介面:

public boolean addCount(Long id, Long version);
update blogTable set countcount+1,version=version+1 where id=321 and version=123

每一個 version 只有一次執行成功的機會,一旦失敗了生產者必須重新獲取資料的最新版本號再次發起更新。

②去重表

利用資料庫表單的特性來實現冪等,常用的一個思路是在表上構建唯一性索引,保證某一類資料一旦執行完畢,後續同樣的請求不再重複處理了(利用一張日誌表來記錄已經處理成功的訊息的 id,如果新到的訊息 id 已經在日誌表中,那麼就不再處理這條訊息。)

以電商平臺為例子,電商平臺上的訂單 id 就是最適合的 token。當使用者下單時,會經歷多個環節,比如生成訂單,減庫存,減優惠券等等。

每一個環節執行時都先檢測一下該訂單 id 是否已經執行過這一步驟,對未執行的請求,執行操作並快取結果,而對已經執行過的 id,則直接返回之前的執行結果,不做任何操作。

這樣可以在最大程度上避免操作的重複執行問題,快取起來的執行結果也能用於事務的控制等。

如何保證訊息的可靠性傳輸?

ActiveMQ

要保證訊息的可靠性,除了訊息的持久化,還包括兩個方面:

  • 生產者傳送的訊息可以被 ActiveMQ 收到。

  • 消費者收到了 ActiveMQ 傳送的訊息。

①生產者
非持久化又不在事務中的訊息,可能會有訊息的丟失。為保證訊息可以被 ActiveMQ 收到,我們應該採用事務訊息或持久化訊息。

②消費者

消費者對訊息的確認有四種機制:
  • AUTO_ACKNOWLEDGE=1:自動確認

  • CLIENT_ACKNOWLEDGE=2:客戶端手動確認   

  • DUPS_OK_ACKNOWLEDGE=3:自動批次確認

  • SESSION_TRANSACTED=0:事務提交併確認

ACK_MODE 描述了 Consumer 與 Broker 確認訊息的方式(時機),比如當訊息被 Consumer 接收之後,Consumer 將在何時確認訊息。

所以 ack_mode 描述的不是 Producer 與 Broker 之間的關係,而是 Customer 與 Broker 之間的關係。

對於 Broker 而言,只有接收到 ACK 指令,才會認為訊息被正確的接收或者處理成功了。透過 ACK,可以在 Consumer 與 Broker 之間建立一種簡單的“擔保”機制。

AUTO_ACKNOWLEDGE:自動確認,“同步”(receive)方法返回 message 給訊息時會立即確認。
在"非同步"(messageListener)方式中,將會首先呼叫listener.onMessage(message)。

如果 onMessage 方法正常結束,訊息將會正常確認;如果 onMessage 方法異常,將導致消費者要求 ActiveMQ 重發訊息。

CLIENT_ACKNOWLEDGE:客戶端手動確認,這就意味著 AcitveMQ 將不會“自作主張”的為你 ACK 任何訊息,開發者需要自己擇機確認。

我們可以在當前訊息處理成功之後,立即呼叫 message.acknowledge() 方法來"逐個"確認訊息,這樣可以儘可能的減少因網路故障而導致訊息重發的個數。


當然也可以處理多條訊息之後,間歇性的呼叫 ACKNOWLEDGE 方法來一次確認多條訊息,減少 ACK 的次數來提升 Consumer 的效率,不過需要自行權衡。

DUPS_OK_ACKNOWLEDGE:類似於 AUTO_ACK 確認機制,為自動批次確認而生,而且具有“延遲”確認的特點,ActiveMQ 會根據內部演算法,在收到一定數量的訊息自動進行確認。

在此模式下,可能會出現重複訊息,什麼時候?當 Consumer 故障重啟後,那些尚未 ACK 的訊息會重新傳送過來。

SESSION_TRANSACTED:當 Session 使用事務時,就是使用此模式。當決定事務中的訊息可以確認時,必須呼叫 session.commit() 方法,Commit 方法將會導致當前 Session 的事務中所有訊息立即被確認。

在事務開始之後的任何時機呼叫 rollback(),意味著當前事務的結束,事務中所有的訊息都將被重發。當然在 Commit 之前丟擲異常,也會導致事務的 rollback。

RabbitMQ

①生產者弄丟了資料

生產者將資料傳送到 RabbitMQ 的時候,可能資料就在半路給搞丟了,因為網路啥的問題,都有可能。

此時可以選擇用 RabbitMQ 提供的事務功能,就是生產者傳送資料之前開啟 RabbitMQ 事務(channel.txSelect),然後傳送訊息,如果訊息沒有成功被 RabbitMQ 接收到,那麼生產者會收到異常報錯。

此時就可以回滾事務(channel.txRollback),然後重試傳送訊息;如果收到了訊息,那麼可以提交事務(channel.txCommit)。

但是問題是,RabbitMQ 事務機制一搞,基本上吞吐量會下來,因為太耗效能。

所以一般來說,如果要確保 RabbitMQ 的訊息別丟,可以開啟 Confirm 模式。

在生產者那裡設定開啟 Confirm 模式之後,你每次寫的訊息都會分配一個唯一的 id,然後如果寫入了 RabbitMQ 中,RabbitMQ 會給你回傳一個 ACK 訊息,告訴你說這個訊息 OK 了。

如果 RabbitMQ 沒能處理這個訊息,會回撥你一個 nack 介面,告訴你這個訊息接收失敗,你可以重試。

而且你可以結合這個機制,自己在記憶體裡維護每個訊息 id 的狀態,如果超過一定時間還沒接收到這個訊息的回撥,那麼你可以重發。

事務機制和 Cnofirm 機制最大的不同在於:事務機制是同步的,你提交一個事務之後會阻塞在那兒。

但是 Confirm 機制是非同步的,你傳送個訊息之後就可以傳送下一個訊息,然後那個訊息 RabbitMQ 接收了之後會非同步回撥你一個介面通知你這個訊息接收到了。

所以一般在生產者這塊避免資料丟失,都是用 Confirm 機制的。

②RabbitMQ 弄丟了資料

就是 RabbitMQ 自己弄丟了資料,這個你必須開啟 RabbitMQ 的持久化,就是訊息寫入之後會持久化到磁碟,哪怕是 RabbitMQ 自己掛了,恢復之後會自動讀取之前儲存的資料,一般資料不會丟。

除非極其罕見的是,RabbitMQ 還沒持久化,自己就掛了,可能導致少量資料會丟失的,但是這個機率較小。

設定持久化有兩個步驟:

  • 建立 queue 和交換器的時候將其設定為持久化的,這樣就可以保證 RabbitMQ 持久化相關的後設資料,但是不會持久化 queue 裡的資料。

  • 傳送訊息的時候將訊息的 deliveryMode 設定為 2,就是將訊息設定為持久化的,此時 RabbitMQ 就會將訊息持久化到磁碟上去。

必須要同時設定這兩個持久化才行,RabbitMQ 哪怕是掛了,再次重啟,也會從磁碟上重啟恢復 queue,恢復這個 queue 裡的資料。

而且持久化可以跟生產者那邊的 Confirm 機制配合起來,只有訊息被持久化到磁碟之後,才會通知生產者 ACK 了。

所以哪怕是在持久化到磁碟之前,RabbitMQ 掛了,資料丟了,生產者收不到 ACK,你也是可以自己重發的。

哪怕是你給 RabbitMQ 開啟了持久化機制,也有一種可能,就是這個訊息寫到了 RabbitMQ 中,但是還沒來得及持久化到磁碟上,結果不巧,此時 RabbitMQ 掛了,就會導致記憶體裡的一點點資料會丟失。

③消費端弄丟了資料

RabbitMQ 如果丟失了資料,主要是因為你消費的時候,剛消費到,還沒處理,結果程式掛了,比如重啟了,那麼就尷尬了,RabbitMQ 認為你都消費了,這資料就丟了。

這個時候得用 RabbitMQ 提供的 ACK 機制,簡單來說,就是你關閉 RabbitMQ 自動 ACK,可以透過一個 API 來呼叫就行,然後每次你自己程式碼裡確保處理完的時候,再程式裡 ACK 一把。

這樣的話,如果你還沒處理完,不就沒有 ACK?那 RabbitMQ 就認為你還沒處理完,這個時候 RabbitMQ 會把這個消費分配給別的 Consumer 去處理,訊息是不會丟的。

Kafka

①消費端弄丟了資料

唯一可能導致消費者弄丟資料的情況,就是說,你那個消費到了這個訊息,然後消費者那邊自動提交了 Offset,讓 Kafka 以為你已經消費好了這個訊息。

其實你剛準備處理這個訊息,你還沒處理,你自己就掛了,此時這條訊息就丟咯。

大家都知道 Kafka 會自動提交 Offset,那麼只要關閉自動提交 Offset,在處理完之後自己手動提交 Offset,就可以保證資料不會丟。

但是此時確實還是會重複消費,比如你剛處理完,還沒提交 Offset,結果自己掛了,此時肯定會重複消費一次,自己保證冪等性就好了。

生產環境碰到的一個問題,就是說我們的 Kafka 消費者消費到了資料之後是寫到一個記憶體的 queue 裡先緩衝一下,結果有的時候,你剛把訊息寫入記憶體 queue,然後消費者會自動提交 Offset。

然後此時我們重啟了系統,就會導致記憶體 queue 裡還沒來得及處理的資料就丟失了。

②Kafka 弄丟了資料

這塊比較常見的一個場景,就是 Kafka 某個 Broker 當機,然後重新選舉 Partition 的 Leader 時。

大家想想,要是此時其他的 Follower 剛好還有些資料沒有同步,結果此時 Leader 掛了,然後選舉某個 Follower 成 Leader 之後,他不就少了一些資料?這就丟了一些資料啊。

所以此時一般是要求起碼設定如下四個引數:

  • 給這個 Topic 設定 replication.factor 引數:這個值必須大於 1,要求每個 Partition 必須有至少 2 個副本。

  • 在 Kafka 服務端設定 min.insync.replicas 引數:這個值必須大於 1,這個是要求一個 Leader 至少感知到有至少一個 Follower 還跟自己保持聯絡,沒掉隊,這樣才能確保 Leader 掛了還有一個 Follower 吧。

  • 在 Producer 端設定 acks=all:這個是要求每條資料,必須是寫入所有 Replica 之後,才能認為是寫成功了。

  • 在 Producer 端設定 retries=MAX(很大很大很大的一個值,無限次重試的意思):這個是要求一旦寫入失敗,就無限重試,卡在這裡了。

③生產者會不會弄丟資料

如果按照上述的思路設定了 ack=all,一定不會丟,要求是,你的 Leader 接收到訊息,所有的 Follower 都同步到了訊息之後,才認為本次寫成功了。如果沒滿足這個條件,生產者會自動不斷的重試,重試無限次。

訊息的順序性

從根本上說,非同步訊息是不應該有順序依賴的,在 MQ 上估計是沒法解決。

要實現嚴格的順序訊息,簡單且可行的辦法就是:保證生產者、MQServer、消費者是一對一對一的關係。

ActiveMQ

①透過高階特性 Consumer 獨有消費者(exclusive consumer)

queue = new ActiveMQQueue("TEST.QUEUE?consumer.exclusive=true");
consumer = session.createConsumer(queue);

當在接收資訊的時候,有多個獨佔消費者的時候,只有一個獨佔消費者可以接收到訊息。

獨佔訊息就是在有多個消費者同時消費一個 queue 時,可以保證只有一個消費者可以消費訊息。

這樣雖然保證了訊息的順序問題,不過也帶來了一個問題,就是這個 queue 的所有訊息將只會在這一個主消費者上消費,其他消費者將閒置,達不到負載均衡分配。

而實際業務我們可能更多的是這樣的場景,比如一個訂單會發出一組順序訊息,我們只要求這一組訊息是順序消費的,而訂單與訂單之間又是可以並行消費的,不需要順序,因為順序也沒有任何意義。

有沒有辦法做到呢?可以利用 ActiveMQ 的另一個高階特性之 messageGroup。

②利用 ActiveMQ 的高階特性:Message Groups

Message Groups 特性是一種負載均衡的機制。在一個訊息被分發到 Consumer 之前,Broker 首先檢查訊息 JMSXGroupID 屬性。

如果存在,那麼 Broker 會檢查是否有某個 Consumer 擁有這個 Message Group。

如果沒有,那麼 Broker 會選擇一個 Consumer,並將它關聯到這個 Message Group。

此後,這個 Consumer 會接收這個 Message Group 的所有訊息,直到 Consumer 被關閉。

Message Group 被關閉,透過傳送一個訊息,並設定這個訊息的 JMSXGroupSeq 為 -1。

bytesMessage.setStringProperty("JMSXGroupID""constact-20100000002");
bytesMessage.setIntProperty("JMSXGroupSeq", -1);

如上圖所示,同一個 queue 中,擁有相同 JMSXGroupID 的訊息將發往同一個消費者,解決順序問題;不同分組的訊息又能被其他消費者並行消費,解決負載均衡的問題。

RabbitMQ

如果有順序依賴的訊息,要保證訊息有一個 hashKey,類似於資料庫表分割槽的的分割槽 key 列。保證對同一個 key 的訊息傳送到相同的佇列。

A 使用者產生的訊息(包括建立訊息和刪除訊息)都按 A 的 hashKey 分發到同一個佇列。

只需要把強相關的兩條訊息基於相同的路由就行了,也就是說經過 m1 和 m2 的在路由表裡的路由是一樣的,那自然 m1 會優先於 m2 去投遞。而且一個 queue 只對應一個 Consumer。

Kafka

一個 Topic,一個 Partition,一個 Consumer,內部單執行緒消費。

如何解決訊息佇列的延時以及過期失效問題?RabbitMQ 是可以設定過期時間的,就是 TTL。

如果訊息在 queue 中積壓超過一定的時間,而又沒有設定死信佇列機制,就會被 RabbitMQ 給清理掉,這個資料就沒了。ActiveMQ 則透過更改配置,支援訊息的定時傳送。

有幾百萬訊息持續積壓幾小時怎麼解決?


發生了線上故障,幾千萬條資料在 MQ 裡積壓很久。是修復 Consumer 的問題,讓他恢復消費速度,然後等待幾個小時消費完畢?這是個解決方案,不過有時候我們還會進行臨時緊急擴容。

一個消費者一秒是 1000 條,3 個消費者一秒是 3000 條,一分鐘是 18 萬條。

所以如果積壓了幾百萬到上千萬的資料,即使消費者恢復了,也需要大概一小時的時間才能恢復過來。

一般這個時候,只能操作臨時緊急擴容了,具體操作步驟和思路如下:

  • 先修復 Consumer 的問題,確保其恢復消費速度,然後將現有 Consumer 都停掉。

  • 新建一個 Topic,Partition 是原來的 10 倍,臨時建立好原先 10 倍或者 20 倍的 queue 數量。

    然後寫一個臨時的分發資料的 Consumer 程式,這個程式部署上去消費積壓的資料,消費之後不做耗時的處理,直接均勻輪詢寫入臨時建立好的 10 倍數量的 queue。

  • 接著臨時徵用 10 倍的機器來部署 Consumer,每一批 Consumer 消費一個臨時 queue 的資料。

  • 這種做法相當於是臨時將 queue 資源和 Consumer 資源擴大 10 倍,以正常的 10 倍速度來消費資料。

  • 等快速消費完積壓資料之後,再恢復原先部署架構,重新用原先的 Consumer 機器來消費訊息。

Kafka是如何實現高效能的?

①宏觀架構層面利用 Partition 實現並行處理

Kafka 中每個 Topic 都包含一個或多個 Partition,不同 Partition 可位於不同節點。

同時 Partition 在物理上對應一個本地資料夾,每個 Partition 包含一個或多個 Segment,每個 Segment 包含一個資料檔案和一個與之對應的索引檔案。

在邏輯上,可以把一個 Partition 當作一個非常長的陣列,可透過這個“陣列”的索引(Offset)去訪問其資料。

一方面,由於不同 Partition 可位於不同機器,因此可以充分利用叢集優勢,實現機器間的並行處理。

另一方面,由於 Partition 在物理上對應一個資料夾,即使多個 Partition 位於同一個節點,也可透過配置讓同一節點上的不同 Partition 置於不同的 disk drive 上,從而實現磁碟間的並行處理,充分發揮多磁碟的優勢。

利用多磁碟的具體方法是,將不同磁碟 mount 到不同目錄,然後在 server.properties 中,將 log.dirs 設定為多目錄(用逗號分隔)。

Kafka 會自動將所有 Partition 儘可能均勻分配到不同目錄也即不同目錄(也即不同 disk)上。

Partition 是最小併發粒度,Partition 個數決定了可能的最大並行度。

②ISR 實現可用性與資料一致性的動態平衡

常用資料複製及一致性方案有如下幾種:

Master-Slave:
  • RDBMS 的讀寫分離即為典型的 Master-Slave 方案。

  • 同步複製可保證強一致性但會影響可用性。

  • 非同步複製可提供高可用性但會降低一致性。

WNR:
  • 主要用於去中心化的分散式系統中。

  • N 代表總副本數,W 代表每次寫操作要保證的最少寫成功的副本數,R 代表每次讀至少要讀取的副本數。

  • 當 W+R>N 時,可保證每次讀取的資料至少有一個副本擁有最新的資料。

  • 多個寫操作的順序難以保證,可能導致多副本間的寫操作順序不一致。Dynamo 透過向量時鐘保證最終一致性。

Paxos 及其變種:
  • Google 的 Chubby,Zookeeper 的原子廣播協議(Zab),RAFT 等。

基於 ISR 的資料複製方案:Kafka 的資料複製是以 Partition 為單位的。而多個備份間的資料複製,透過 Follower 向 Leader 拉取資料完成。

從這一點來講,Kafka 的資料複製方案接近於上文所講的 Master-Slave 方案。

不同的是,Kafka 既不是完全的同步複製,也不是完全的非同步複製,而是基於 ISR 的動態複製方案。

ISR,也即 In-Sync Replica。每個 Partition 的 Leader 都會維護這樣一個列表,該列表中,包含了所有與之同步的 Replica(包含 Leader 自己)。

每次資料寫入時,只有 ISR 中的所有 Replica 都複製完,Leader 才會將其置為 Commit,它才能被 Consumer 所消費。

這種方案,與同步複製非常接近。但不同的是,這個 ISR 是由 Leader 動態維護的。

如果 Follower 不能緊“跟上”Leader,它將被 Leader 從 ISR 中移除,待它又重新“跟上”Leader 後,會被 Leader 再次加到 ISR 中。每次改變 ISR 後,Leader 都會將最新的 ISR 持久化到 Zookeeper 中。

由於 Leader 可移除不能及時與之同步的 Follower,故與同步複製相比可避免最慢的 Follower 拖慢整體速度,也即 ISR 提高了系統可用性。

ISR 中的所有 Follower 都包含了所有 Commit 過的訊息,而只有 Commit 過的訊息才會被 Consumer 消費。

故從 Consumer 的角度而言,ISR 中的所有 Replica 都始終處於同步狀態,從而與非同步複製方案相比提高了資料一致性。

ISR 可動態調整,極限情況下,可以只包含 Leader,極大提高了可容忍的當機的 Follower 的數量。

與 Majority Quorum 方案相比,容忍相同個數的節點失敗,所要求的總節點數少了近一半。

③具體實現層面高效使用磁碟特性和作業系統特性

將寫磁碟的過程變為順序寫

Kafka 的整個設計中,Partition 相當於一個非常長的陣列,而 Broker 接收到的所有訊息順序寫入這個大陣列中。

同時 Consumer 透過 Offset 順序消費這些資料,並且不刪除已經消費的資料,從而避免了隨機寫磁碟的過程。

由於磁碟有限,不可能儲存所有資料,實際上作為訊息系統 Kafka 也沒必要儲存所有資料,需要刪除舊的資料。

而這個刪除過程,並非透過使用“讀-寫”模式去修改檔案,而是將 Partition 分為多個 Segment,每個 Segment 對應一個物理檔案,透過刪除整個檔案的方式去刪除 Partition 內的資料。

這種方式清除舊資料的方式,也避免了對檔案的隨機寫操作。在儲存機制上,使用了 Log Structured Merge Trees(LSM) 。

注:Log Structured Merge Trees(LSM),谷歌 “BigTable” 的論文中提出,LSM 是當前被用在許多產品的檔案結構策略:HBase,Cassandra,LevelDB,SQLite,Kafka。

LSM 被設計來提供比傳統的 B+ 樹或者 ISAM 更好的寫操作吞吐量,透過消去隨機的本地更新操作來達到這個目標。

這個問題的本質還是磁碟隨機操作慢,順序讀寫快。這兩種操作存在巨大的差距,無論是磁碟還是 SSD,而且快至少三個數量級。

充分利用 Page Cache

使用 Page Cache 的好處如下:

  • I/O Scheduler 會將連續的小塊寫組裝成大塊的物理寫從而提高效能。

  • I/O Scheduler 會嘗試將一些寫操作重新按順序排好,從而減少磁碟頭的移動時間。

  • 充分利用所有空閒記憶體(非 JVM 記憶體)。如果使用應用層 Cache(即 JVM 堆記憶體),會增加 GC 負擔。

  • 讀操作可直接在 Page Cache 內進行。如果消費和生產速度相當,甚至不需要透過物理磁碟(直接透過 Page Cache)交換資料。

  • 如果程式重啟,JVM 內的 Cache 會失效,但 Page Cache 仍然可用。

Broker 收到資料後,寫磁碟時只是將資料寫入 Page Cache,並不保證資料一定完全寫入磁碟。

從這一點看,可能會造成機器當機時,Page Cache 內的資料未寫入磁碟從而造成資料丟失。

但是這種丟失只發生在機器斷電等造成作業系統不工作的場景,而這種場景完全可以由 Kafka 層面的 Replication 機制去解決。

如果為了保證這種情況下資料不丟失而強制將 Page Cache 中的資料 Flush 到磁碟,反而會降低效能。

也正因如此,Kafka 雖然提供了 flush.messages 和 flush.ms 兩個引數將 Page Cache 中的資料強制 Flush 到磁碟,但是 Kafka 並不建議使用。

如果資料消費速度與生產速度相當,甚至不需要透過物理磁碟交換資料,而是直接透過 Page Cache 交換資料。同時,Follower 從 Leader Fetch 資料時,也可透過 Page Cache 完成。

注:Page Cache,又稱 pcache,其中文名稱為頁高速緩衝儲存器,簡稱頁高緩。

Page Cache 的大小為一頁,通常為 4K。在 Linux 讀寫檔案時,它用於快取檔案的邏輯內容,從而加快對磁碟上映像和資料的訪問。 這是 Linux 作業系統的一個特色。

支援多 Disk Drive

Broker 的 log.dirs 配置項,允許配置多個資料夾。如果機器上有多個 Disk Drive,可將不同的 Disk 掛載到不同的目錄,然後將這些目錄都配置到 log.dirs 裡。

Kafka 會盡可能將不同的 Partition 分配到不同的目錄,也即不同的 Disk 上,從而充分利用了多 Disk 的優勢。

零複製

Kafka 中存在大量的網路資料持久化到磁碟(Producer 到 Broker)和磁碟檔案透過網路傳送(Broker 到 Consumer)的過程。這一過程的效能直接影響 Kafka 的整體吞吐量。

傳統模式下的四次複製與四次上下文切換,以將磁碟檔案透過網路傳送為例。

傳統模式下,一般使用如下虛擬碼所示的方法先將檔案資料讀入記憶體,然後透過 Socket 將記憶體中的資料傳送出去。

buffer = File.readSocket.send(buffer)

這一過程實際上發生了四次資料複製:

  • 首先透過系統呼叫將檔案資料讀入到核心態 Buffer(DMA 複製)。

  • 然後應用程式將記憶體態 Buffer 資料讀入到使用者態 Buffer(CPU 複製)。

  • 接著使用者程式透過 Socket 傳送資料時將使用者態 Buffer 資料複製到核心態 Buffer(CPU 複製)。

  • 最後透過 DMA 複製將資料複製到 NIC Buffer。同時,還伴隨著四次上下文切換。

而 Linux 2.4+ 核心透過 sendfile 系統呼叫,提供了零複製。資料透過 DMA 複製到核心態 Buffer 後,直接透過 DMA 複製到 NIC Buffer,無需 CPU 複製。這也是零複製這一說法的來源。

除了減少資料複製外,因為整個讀檔案-網路傳送由一個 sendfile 呼叫完成,整個過程只有兩次上下文切換,因此大大提高了效能。

從具體實現來看,Kafka 的資料傳輸透過 Java NIO 的 FileChannel 的 transferTo 和 transferFrom 方法實現零複製。

注: transferTo 和 transferFrom 並不保證一定能使用零複製。實際上是否能使用零複製與作業系統相關,如果作業系統提供 sendfile 這樣的零複製系統呼叫,則這兩個方法會透過這樣的系統呼叫充分利用零複製的優勢,否則並不能透過這兩個方法本身實現零複製。

減少網路開銷批處理

批處理是一種常用的用於提高 I/O 效能的方式。對 Kafka 而言,批處理既減少了網路傳輸的 Overhead,又提高了寫磁碟的效率。

Kafka 的 send 方法並非立即將訊息傳送出去,而是透過 batch.size 和 linger.ms 控制實際傳送頻率,從而實現批次傳送。

由於每次網路傳輸,除了傳輸訊息本身以外,還要傳輸非常多的網路協議本身的一些內容(稱為 Overhead),所以將多條訊息合併到一起傳輸,可有效減少網路傳輸的 Overhead,進而提高了傳輸效率。

資料壓縮降低網路負載

Kafka 從 0.7 開始,即支援將資料壓縮後再傳輸給 Broker。除了可以將每條訊息單獨壓縮然後傳輸外,Kafka 還支援在批次傳送時,將整個 Batch 的訊息一起壓縮後傳輸。

資料壓縮的一個基本原理是,重複資料越多壓縮效果越好。因此將整個 Batch 的資料一起壓縮能更大幅度減小資料量,從而更大程度提高網路傳輸效率。

Broker 接收訊息後,並不直接解壓縮,而是直接將訊息以壓縮後的形式持久化到磁碟。Consumer Fetch 到資料後再解壓縮。

因此 Kafka 的壓縮不僅減少了 Producer 到 Broker 的網路傳輸負載,同時也降低了 Broker 磁碟操作的負載,也降低了 Consumer 與 Broker 間的網路傳輸量,從而極大得提高了傳輸效率,提高了吞吐量。

高效的序列化方式

Kafka 訊息的 Key 和 Payload(或者說 Value)的型別可自定義,只需同時提供相應的序列化器和反序列化器即可。

因此使用者可以透過使用快速且緊湊的序列化-反序列化方式(如 Avro,Protocal Buffer)來減少實際網路傳輸和磁碟儲存的資料規模,從而提高吞吐率。

這裡要注意,如果使用的序列化方法太慢,即使壓縮比非常高,最終的效率也不一定高。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31556440/viewspace-2638674/,如需轉載,請註明出處,否則將追究法律責任。

相關文章