前言
在上文 設計一個百萬級的訊息推送系統 中提到訊息流轉採用的是 Kafka
作為中介軟體。
其中有朋友諮詢在大量訊息的情況下 Kakfa
是如何保證訊息的高效及一致性呢?
正好以這個問題結合 Kakfa
的原始碼討論下如何正確、高效的傳送訊息。
內容較多,對原始碼感興趣的朋友請繫好安全帶?(原始碼基於
v0.10.0.0
版本分析)。同時最好是有一定的 Kafka 使用經驗,知曉基本的用法。
簡單的訊息傳送
在分析之前先看一個簡單的訊息傳送是怎麼樣的。
以下程式碼基於 SpringBoot 構建。
首先建立一個 org.apache.kafka.clients.producer.Producer
的 bean。
主要關注 bootstrap.servers
,它是必填引數。指的是 Kafka 叢集中的 broker 地址,例如 127.0.0.1:9094
。
其餘幾個引數暫時不做討論,後文會有詳細介紹。
接著注入這個 bean 即可呼叫它的傳送函式傳送訊息。
這裡我給某一個 Topic 傳送了 10W 條資料,執行程式訊息正常傳送。
但這僅僅只是做到了訊息傳送,對訊息是否成功送達完全沒管,等於是純非同步
的方式。
同步
那麼我想知道訊息到底傳送成功沒有該怎麼辦呢?
其實 Producer
的 API
已經幫我們考慮到了,傳送之後只需要呼叫它的 get()
方法即可同步獲取傳送結果。
傳送結果:
這樣的傳送效率其實是比較低下的,因為每次都需要同步等待訊息傳送的結果。
非同步
為此我們應當採取非同步的方式傳送,其實 send()
方法預設則是非同步的,只要不手動呼叫 get()
方法。
但這樣就沒法獲知傳送結果。
所以檢視 send()
的 API 可以發現還有一個引數。
Future<RecordMetadata> send(ProducerRecord<K, V> producer, Callback callback);
複製程式碼
Callback
是一個回撥介面,在訊息傳送完成之後可以回撥我們自定義的實現。
執行之後的結果:
同樣的也能獲取結果,同時發現回撥的執行緒並不是上文同步時的主執行緒
,這樣也能證明是非同步回撥的。
同時回撥的時候會傳遞兩個引數:
RecordMetadata
和上文一致的訊息傳送成功後的後設資料。Exception
訊息傳送過程中的異常資訊。
但是這兩個引數並不會同時都有資料,只有傳送失敗才會有異常資訊,同時傳送後設資料為空。
所以正確的寫法應當是:
至於為什麼會只有引數一個有值,在下文的原始碼分析中會一一解釋。
原始碼分析
現在只掌握了基本的訊息傳送,想要深刻的理解傳送中的一些引數配置還是得原始碼說了算。
首先還是來談談訊息傳送時的整個流程是怎麼樣的,Kafka
並不是簡單的把訊息通過網路傳送到了 broker
中,在 Java 內部還是經過了許多優化和設計。
傳送流程
為了直觀的瞭解傳送的流程,簡單的畫了幾個在傳送過程中關鍵的步驟。
從上至下依次是:
- 初始化以及真正傳送訊息的
kafka-producer-network-thread
IO 執行緒。 - 將訊息序列化。
- 得到需要傳送的分割槽。
- 寫入內部的一個快取區中。
- 初始化的 IO 執行緒不斷的消費這個快取來傳送訊息。
步驟解析
接下來詳解每個步驟。
初始化
呼叫該構造方法進行初始化時,不止是簡單的將基本引數寫入 KafkaProducer
。比較麻煩的是初始化 Sender
執行緒進行緩衝區消費。
初始化 IO 執行緒處:
可以看到 Sender 執行緒有需要成員變數,比如:
acks,retries,requestTimeout
複製程式碼
等,這些引數會在後文分析。
序列化訊息
在呼叫 send()
函式後其實第一步就是序列化,畢竟我們的訊息需要通過網路才能傳送到 Kafka。
其中的 valueSerializer.serialize(record.topic(), record.value());
是一個介面,我們需要在初始化時候指定序列化實現類。
我們也可以自己實現序列化,只需要實現 org.apache.kafka.common.serialization.Serializer
介面即可。
路由分割槽
接下來就是路由分割槽,通常我們使用的 Topic
為了實現擴充套件性以及高效能都會建立多個分割槽。
如果是一個分割槽好說,所有訊息都往裡面寫入即可。
但多個分割槽就不可避免需要知道寫入哪個分割槽。
通常有三種方式。
指定分割槽
可以在構建 ProducerRecord
為每條訊息指定分割槽。
這樣在路由時會判斷是否有指定,有就直接使用該分割槽。
這種一般在特殊場景下會使用。
自定義路由策略
如果沒有指定分割槽,則會呼叫 partitioner.partition
介面執行自定義分割槽策略。
而我們也只需要自定義一個類實現 org.apache.kafka.clients.producer.Partitioner
介面,同時在建立 KafkaProducer
例項時配置 partitioner.class
引數。
通常需要自定義分割槽一般是在想盡量的保證訊息的順序性。
或者是寫入某些特有的分割槽,由特別的消費者來進行處理等。
預設策略
最後一種則是預設的路由策略,如果我們啥都沒做就會執行該策略。
該策略也會使得訊息分配的比較均勻。
來看看它的實現:
簡單的來說分為以下幾步:
- 獲取 Topic 分割槽數。
- 將內部維護的一個執行緒安全計數器 +1。
- 與分割槽數取模得到分割槽編號。
其實這就是很典型的輪詢演算法,所以只要分割槽數不頻繁變動這種方式也會比較均勻。
寫入內部快取
在 send()
方法拿到分割槽後會呼叫一個 append()
函式:
該函式中會呼叫一個 getOrCreateDeque()
寫入到一個內部快取中 batches
。
消費快取
在最開始初始化的 IO 執行緒其實是一個守護執行緒,它會一直消費這些資料。
通過圖中的幾個函式會獲取到之前寫入的資料。這塊內容可以不必深究,但其中有個 completeBatch
方法卻非常關鍵。
呼叫該方法時候肯定已經是訊息傳送完畢了,所以會呼叫 batch.done()
來完成之前我們在 send()
方法中定義的回撥介面。
從這裡也可以看出為什麼之前說傳送完成後後設資料和異常資訊只會出現一個。
Producer 引數解析
傳送流程講完了再來看看 Producer
中比較重要的幾個引數。
acks
acks
是一個影響訊息吞吐量的一個關鍵引數。
主要有 [all、-1, 0, 1]
這幾個選項,預設為 1。
由於 Kafka
不是採取的主備模式,而是採用類似於 Zookeeper 的主備模式。
前提是
Topic
配置副本數量replica > 1
。
當 acks = all/-1
時:
意味著會確保所有的 follower 副本都完成資料的寫入才會返回。
這樣可以保證訊息不會丟失!
但同時效能和吞吐量卻是最低的。
當 acks = 0
時:
producer 不會等待副本的任何響應,這樣最容易丟失訊息但同時效能卻是最好的!
當 acks = 1
時:
這是一種折中的方案,它會等待副本 Leader 響應,但不會等到 follower 的響應。
一旦 Leader 掛掉訊息就會丟失。但效能和訊息安全性都得到了一定的保證。
batch.size
這個引數看名稱就知道是內部快取區的大小限制,對他適當的調大可以提高吞吐量。
但也不能極端,調太大會浪費記憶體。小了也發揮不了作用,也是一個典型的時間和空間的權衡。
上圖是幾個使用的體現。
retries
retries
該引數主要是來做重試使用,當發生一些網路抖動都會造成重試。
這個引數也就是限制重試次數。
但也有一些其他問題。
- 因為是重發所以訊息順序可能不會一致,這也是上文提到就算是一個分割槽訊息也不會是完全順序的情況。
- 還是由於網路問題,本來訊息已經成功寫入了但是沒有成功響應給 producer,進行重試時就可能會出現
訊息重複
。這種只能是消費者進行冪等處理。
高效的傳送方式
如果訊息量真的非常大,同時又需要儘快的將訊息傳送到 Kafka
。一個 producer
始終會收到快取大小等影響。
那是否可以建立多個 producer
來進行傳送呢?
- 配置一個最大 producer 個數。
- 傳送訊息時首先獲取一個
producer
,獲取的同時判斷是否達到最大上限,沒有就新建一個同時儲存到內部的List
中,儲存時做好同步處理防止併發問題。 - 獲取傳送者時可以按照預設的分割槽策略使用輪詢的方式獲取(保證使用均勻)。
這樣在大量、頻繁的訊息傳送場景中可以提高傳送效率減輕單個 producer
的壓力。
關閉 Producer
最後則是 Producer
的關閉,Producer 在使用過程中消耗了不少資源(執行緒、記憶體、網路等)因此需要顯式的關閉從而回收這些資源。
預設的 close()
方法和帶有超時時間的方法都是在一定的時間後強制關閉。
但在過期之前都會處理完剩餘的任務。
所以使用哪一個得視情況而定。
總結
本文內容較多,從例項和原始碼的角度分析了 Kafka 生產者。
希望看完的朋友能有收穫,同時也歡迎留言討論。
不出意外下期會討論 Kafka 消費者。
如果對你有幫助還請分享讓更多的人看到。
歡迎關注公眾號一起交流: