詳解Kafka Producer

cxuan發表於2019-11-15

上一篇文章我們主要介紹了什麼是 Kafka,Kafka 的基本概念是什麼,Kafka 單機和叢集版的搭建,以及對基本的配置檔案進行了大致的介紹,還對 Kafka 的幾個主要角色進行了描述,我們知道,不管是把 Kafka 用作訊息佇列、訊息匯流排還是資料儲存平臺來使用,最終是繞不過訊息這個詞的,這也是 Kafka 最最核心的內容,Kafka 的訊息從哪裡來?到哪裡去?都幹什麼了?彆著急,一步一步來,先說說 Kafka 的訊息從哪來。

生產者概述

在 Kafka 中,我們把產生訊息的那一方稱為生產者,比如我們經常回去淘寶購物,你開啟淘寶的那一刻,你的登陸資訊,登陸次數都會作為訊息傳輸到 Kafka 後臺,當你瀏覽購物的時候,你的瀏覽資訊,你的搜尋指數,你的購物愛好都會作為一個個訊息傳遞給 Kafka 後臺,然後淘寶會根據你的愛好做智慧推薦,致使你的錢包從來都禁不住誘惑,那麼這些生產者產生的訊息是怎麼傳到 Kafka 應用程式的呢?傳送過程是怎麼樣的呢?

儘管訊息的產生非常簡單,但是訊息的傳送過程還是比較複雜的,如圖

image.png

我們從建立一個ProducerRecord 物件開始,ProducerRecord 是 Kafka 中的一個核心類,它代表了一組 Kafka 需要傳送的 key/value 鍵值對,它由記錄要傳送到的主題名稱(Topic Name),可選的分割槽號(Partition Number)以及可選的鍵值對構成。

在傳送 ProducerRecord 時,我們需要將鍵值對物件由序列化器轉換為位元組陣列,這樣它們才能夠在網路上傳輸。然後訊息到達了分割槽器。

如果傳送過程中指定了有效的分割槽號,那麼在傳送記錄時將使用該分割槽。如果傳送過程中未指定分割槽,則將使用key 的 hash 函式對映指定一個分割槽。如果傳送的過程中既沒有分割槽號也沒有,則將以迴圈的方式分配一個分割槽。選好分割槽後,生產者就知道向哪個主題和分割槽傳送資料了。

ProducerRecord 還有關聯的時間戳,如果使用者沒有提供時間戳,那麼生產者將會在記錄中使用當前的時間作為時間戳。Kafka 最終使用的時間戳取決於 topic 主題配置的時間戳型別。

  • 如果將主題配置為使用 CreateTime,則生產者記錄中的時間戳將由 broker 使用。
  • 如果將主題配置為使用LogAppendTime,則生產者記錄中的時間戳在將訊息新增到其日誌中時,將由 broker 重寫。

然後,這條訊息被存放在一個記錄批次裡,這個批次裡的所有訊息會被髮送到相同的主題和分割槽上。由一個獨立的執行緒負責把它們發到 Kafka Broker 上。

Kafka Broker 在收到訊息時會返回一個響應,如果寫入成功,會返回一個 RecordMetaData 物件,它包含了主題和分割槽資訊,以及記錄在分割槽裡的偏移量,上面兩種的時間戳型別也會返回給使用者。如果寫入失敗,會返回一個錯誤。生產者在收到錯誤之後會嘗試重新傳送訊息,幾次之後如果還是失敗的話,就返回錯誤訊息。

建立 Kafka 生產者

要往 Kafka 寫入訊息,首先需要建立一個生產者物件,並設定一些屬性。Kafka 生產者有3個必選的屬性

  • bootstrap.servers

該屬性指定 broker 的地址清單,地址的格式為 host:port。清單裡不需要包含所有的 broker 地址,生產者會從給定的 broker 裡查詢到其他的 broker 資訊。不過建議至少要提供兩個 broker 資訊,一旦其中一個當機,生產者仍然能夠連線到叢集上。

  • key.serializer

broker 需要接收到序列化之後的 key/value 值,所以生產者傳送的訊息需要經過序列化之後才傳遞給 Kafka Broker。生產者需要知道採用何種方式把 Java 物件轉換為位元組陣列。key.serializer 必須被設定為一個實現了org.apache.kafka.common.serialization.Serializer 介面的類,生產者會使用這個類把鍵物件序列化為位元組陣列。這裡擴充一下 Serializer 類

Serializer 是一個介面,它表示類將會採用何種方式序列化,它的作用是把物件轉換為位元組,實現了 Serializer 介面的類主要有 ByteArraySerializerStringSerializerIntegerSerializer ,其中 ByteArraySerialize 是 Kafka 預設使用的序列化器,其他的序列化器還有很多,你可以通過 這裡 檢視其他序列化器。要注意的一點:key.serializer 是必須要設定的,即使你打算只傳送值的內容

  • value.serializer

與 key.serializer 一樣,value.serializer 指定的類會將值序列化。

下面程式碼演示瞭如何建立一個 Kafka 生產者,這裡只指定了必要的屬性,其他使用預設的配置

private Properties properties = new Properties();
properties.put("bootstrap.servers","broker1:9092,broker2:9092");
properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties = new KafkaProducer<String,String>(properties);

來解釋一下這段程式碼

  • 首先建立了一個 Properties 物件
  • 使用 StringSerializer 序列化器序列化 key / value 鍵值對
  • 在這裡我們建立了一個新的生產者物件,併為鍵值設定了恰當的型別,然後把 Properties 物件傳遞給他。

例項化生產者物件後,接下來就可以開始傳送訊息了,傳送訊息主要由下面幾種方式

直接傳送,不考慮結果

使用這種傳送方式,不會關心訊息是否到達,會丟失一些訊息,因為 Kafka 是高可用的,生產者會自動嘗試重發,這種傳送方式和 UDP 運輸層協議很相似。

同步傳送

同步傳送仍然使用 send() 方法傳送訊息,它會返回一個 Future 物件,呼叫 get() 方法進行等待,就可以知道訊息時候否傳送成功。

非同步傳送

非同步傳送指的是我們呼叫 send() 方法,並制定一個回撥函式,伺服器在返回響應時呼叫該函式。

下一節我們會重新討論這三種實現。

向 Kafka 傳送訊息

簡單訊息傳送

Kafka 最簡單的訊息傳送如下:

ProducerRecord<String,String> record =
                new ProducerRecord<String, String>("CustomerCountry","West","France");

producer.send(record);

程式碼中生產者(producer)的 send() 方法需要把 ProducerRecord 的物件作為引數進行傳送,ProducerRecord 有很多建構函式,這個我們下面討論,這裡呼叫的是

public ProducerRecord(String topic, K key, V value) {}

這個建構函式,需要傳遞的是 topic主題,key 和 value。

把對應的引數傳遞完成後,生產者呼叫 send() 方法傳送訊息(ProducerRecord物件)。我們可以從生產者的架構圖中看出,訊息是先被寫入分割槽中的緩衝區中,然後分批次傳送給 Kafka Broker。

image.png

傳送成功後,send() 方法會返回一個 Future(java.util.concurrent) 物件,Future 物件的型別是 RecordMetadata 型別,我們上面這段程式碼沒有考慮返回值,所以沒有生成對應的 Future 物件,所以沒有辦法知道訊息是否傳送成功。如果不是很重要的資訊或者對結果不會產生影響的資訊,可以使用這種方式進行傳送。

我們可以忽略傳送訊息時可能發生的錯誤或者在伺服器端可能發生的錯誤,但在訊息傳送之前,生產者還可能發生其他的異常。這些異常有可能是 SerializationException(序列化失敗)BufferedExhaustedException 或 TimeoutException(說明緩衝區已滿),又或是 InterruptedException(說明傳送執行緒被中斷)

同步傳送訊息

第二種訊息傳送機制如下所示

ProducerRecord<String,String> record =
                new ProducerRecord<String, String>("CustomerCountry","West","France");

try{
  RecordMetadata recordMetadata = producer.send(record).get();
}catch(Exception e){
  e.printStackTrace();
}

這種傳送訊息的方式較上面的傳送方式有了改進,首先呼叫 send() 方法,然後再呼叫 get() 方法等待 Kafka 響應。如果伺服器返回錯誤,get() 方法會丟擲異常,如果沒有發生錯誤,我們會得到 RecordMetadata 物件,可以用它來檢視訊息記錄。

生產者(KafkaProducer)在傳送的過程中會出現兩類錯誤:其中一類是重試錯誤,這類錯誤可以通過重發訊息來解決。比如連線的錯誤,可以通過再次建立連線來解決;無錯誤則可以通過重新為分割槽選舉首領來解決。KafkaProducer 被配置為自動重試,如果多次重試後仍無法解決問題,則會丟擲重試異常。另一類錯誤是無法通過重試來解決的,比如訊息過大對於這類錯誤,KafkaProducer 不會進行重試,直接丟擲異常。

非同步傳送訊息

同步傳送訊息都有個問題,那就是同一時間只能有一個訊息在傳送,這會造成許多訊息無法直接傳送,造成訊息滯後,無法發揮效益最大化。

比如訊息在應用程式和 Kafka 叢集之間一個來回需要 10ms。如果傳送完每個訊息後都等待響應的話,那麼傳送100個訊息需要 1 秒,但是如果是非同步方式的話,傳送 100 條訊息所需要的時間就會少很多很多。大多數時候,雖然Kafka 會返回 RecordMetadata 訊息,但是我們並不需要等待響應。

為了在非同步傳送訊息的同時能夠對異常情況進行處理,生產者提供了回掉支援。下面是回撥的一個例子

ProducerRecord<String, String> producerRecord = new ProducerRecord<String, String>("CustomerCountry", "Huston", "America");
        producer.send(producerRecord,new DemoProducerCallBack());


class DemoProducerCallBack implements Callback {

  public void onCompletion(RecordMetadata metadata, Exception exception) {
    if(exception != null){
      exception.printStackTrace();;
    }
  }
}

首先實現回撥需要定義一個實現了org.apache.kafka.clients.producer.Callback的類,這個介面只有一個 onCompletion方法。如果 kafka 返回一個錯誤,onCompletion 方法會丟擲一個非空(non null)異常,這裡我們只是簡單的把它列印出來,如果是生產環境需要更詳細的處理,然後在 send() 方法傳送的時候傳遞一個 Callback 回撥的物件。

生產者分割槽機制

Kafka 對於資料的讀寫是以分割槽為粒度的,分割槽可以分佈在多個主機(Broker)中,這樣每個節點能夠實現獨立的資料寫入和讀取,並且能夠通過增加新的節點來增加 Kafka 叢集的吞吐量,通過分割槽部署在多個 Broker 來實現負載均衡的效果。

上面我們介紹了生產者的傳送方式有三種:不管結果如何直接傳送傳送並返回結果傳送並回撥。由於訊息是存在主題(topic)的分割槽(partition)中的,所以當 Producer 生產者傳送產生一條訊息發給 topic 的時候,你如何判斷這條訊息會存在哪個分割槽中呢?

這其實就設計到 Kafka 的分割槽機制了。

分割槽策略

Kafka 的分割槽策略指的就是將生產者傳送到哪個分割槽的演算法。Kafka 為我們提供了預設的分割槽策略,同時它也支援你自定義分割槽策略。

如果要自定義分割槽策略的話,你需要顯示配置生產者端的引數 Partitioner.class,我們可以看一下這個類它位於 org.apache.kafka.clients.producer 包下

public interface Partitioner extends Configurable, Closeable {
  
  public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);

  public void close();
  
  default public void onNewBatch(String topic, Cluster cluster, int prevPartition) {}
}

Partitioner 類有三個方法,分別來解釋一下

  • partition(): 這個類有幾個引數: topic,表示需要傳遞的主題;key 表示訊息中的鍵值;keyBytes表示分割槽中序列化過後的key,byte陣列的形式傳遞;value 表示訊息的 value 值;valueBytes 表示分割槽中序列化後的值陣列;cluster表示當前叢集的原資料。Kafka 給你這麼多資訊,就是希望讓你能夠充分地利用這些資訊對訊息進行分割槽,計算出它要被髮送到哪個分割槽中。
  • close() : 繼承了 Closeable 介面能夠實現 close() 方法,在分割槽關閉時呼叫。
  • onNewBatch(): 表示通知分割槽程式用來建立新的批次

其中與分割槽策略息息相關的就是 partition() 方法了,分割槽策略有下面這幾種

順序輪訓

順序分配,訊息是均勻的分配給每個 partition,即每個分割槽儲存一次訊息。就像下面這樣

image.png

上圖表示的就是輪訓策略,輪訓策略是 Kafka Producer 提供的預設策略,如果你不使用指定的輪訓策略的話,Kafka 預設會使用順序輪訓策略的方式。

隨機輪訓

隨機輪訓簡而言之就是隨機的向 partition 中儲存訊息,如下圖所示

image.png

實現隨機分配的程式碼只需要兩行,如下

List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return ThreadLocalRandom.current().nextInt(partitions.size());

先計算出該主題總的分割槽數,然後隨機地返回一個小於它的正整數。

本質上看隨機策略也是力求將資料均勻地打散到各個分割槽,但從實際表現來看,它要遜於輪詢策略,所以如果追求資料的均勻分佈,還是使用輪詢策略比較好。事實上,隨機策略是老版本生產者使用的分割槽策略,在新版本中已經改為輪詢了。

按照 key 進行訊息儲存

這個策略也叫做 key-ordering 策略,Kafka 中每條訊息都會有自己的key,一旦訊息被定義了 Key,那麼你就可以保證同一個 Key 的所有訊息都進入到相同的分割槽裡面,由於每個分割槽下的訊息處理都是有順序的,故這個策略被稱為按訊息鍵保序策略,如下圖所示

image.png

實現這個策略的 partition 方法同樣簡單,只需要下面兩行程式碼即可:

List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return Math.abs(key.hashCode()) % partitions.size();

上面這幾種分割槽策略都是比較基礎的策略,除此之外,你還可以自定義分割槽策略。

生產者壓縮機制

壓縮一詞簡單來講就是一種互換思想,它是一種經典的用 CPU 時間去換磁碟空間或者 I/O 傳輸量的思想,希望以較小的 CPU 開銷帶來更少的磁碟佔用或更少的網路 I/O 傳輸。如果你還不瞭解的話我希望你先讀完這篇文章 程式設計師需要了解的硬核知識之壓縮演算法,然後你就明白壓縮是怎麼回事了。

Kafka 壓縮是什麼

Kafka 的訊息分為兩層:訊息集合 和 訊息。一個訊息集合中包含若干條日誌項,而日誌項才是真正封裝訊息的地方。Kafka 底層的訊息日誌由一系列訊息集合日誌項組成。Kafka 通常不會直接操作具體的一條條訊息,它總是在訊息集合這個層面上進行寫入操作。

在 Kafka 中,壓縮會發生在兩個地方:Kafka Producer 和 Kafka Consumer,為什麼啟用壓縮?說白了就是訊息太大,需要變小一點 來使訊息發的更快一些。

Kafka Producer 中使用 compression.type 來開啟壓縮

private Properties properties = new Properties();
properties.put("bootstrap.servers","192.168.1.9:9092");
properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("compression.type", "gzip");

Producer<String,String> producer = new KafkaProducer<String, String>(properties);

ProducerRecord<String,String> record =
  new ProducerRecord<String, String>("CustomerCountry","Precision Products","France");

上面程式碼表明該 Producer 的壓縮演算法使用的是 GZIP

有壓縮必有解壓縮,Producer 使用壓縮演算法壓縮訊息後併傳送給伺服器後,由 Consumer 消費者進行解壓縮,因為採用的何種壓縮演算法是隨著 key、value 一起傳送過去的,所以消費者知道採用何種壓縮演算法。

Kafka 重要引數配置

在上一篇文章 帶你漲姿勢的認識一下kafka中,我們主要介紹了一下 kafka 叢集搭建的引數,本篇文章我們來介紹一下 Kafka 生產者重要的配置,生產者有很多可配置的引數,在文件裡(http://kafka.apache.org/docum...)都有說明,我們介紹幾個在記憶體使用、效能和可靠性方面對生產者影響比較大的引數進行說明

key.serializer

用於 key 鍵的序列化,它實現了 org.apache.kafka.common.serialization.Serializer 介面

value.serializer

用於 value 值的序列化,實現了 org.apache.kafka.common.serialization.Serializer 介面

acks

acks 引數指定了要有多少個分割槽副本接收訊息,生產者才認為訊息是寫入成功的。此引數對訊息丟失的影響較大

  • 如果 acks = 0,就表示生產者也不知道自己產生的訊息是否被伺服器接收了,它才知道它寫成功了。如果傳送的途中產生了錯誤,生產者也不知道,它也比較懵逼,因為沒有返回任何訊息。這就類似於 UDP 的運輸層協議,只管發,伺服器接受不接受它也不關心。
  • 如果 acks = 1,只要叢集的 Leader 接收到訊息,就會給生產者返回一條訊息,告訴它寫入成功。如果傳送途中造成了網路異常或者 Leader 還沒選舉出來等其他情況導致訊息寫入失敗,生產者會受到錯誤訊息,這時候生產者往往會再次重發資料。因為訊息的傳送也分為 同步非同步,Kafka 為了保證訊息的高效傳輸會決定是同步傳送還是非同步傳送。如果讓客戶端等待伺服器的響應(通過呼叫 Future 中的 get() 方法),顯然會增加延遲,如果客戶端使用回撥,就會解決這個問題。
  • 如果 acks = all,這種情況下是隻有當所有參與複製的節點都收到訊息時,生產者才會接收到一個來自伺服器的訊息。不過,它的延遲比 acks =1 時更高,因為我們要等待不只一個伺服器節點接收訊息。

buffer.memory

此引數用來設定生產者記憶體緩衝區的大小,生產者用它緩衝要傳送到伺服器的訊息。如果應用程式傳送訊息的速度超過傳送到伺服器的速度,會導致生產者空間不足。這個時候,send() 方法呼叫要麼被阻塞,要麼丟擲異常,具體取決於 block.on.buffer.null 引數的設定。

compression.type

此引數來表示生產者啟用何種壓縮演算法,預設情況下,訊息傳送時不會被壓縮。該引數可以設定為 snappy、gzip 和 lz4,它指定了訊息傳送給 broker 之前使用哪一種壓縮演算法進行壓縮。下面是各壓縮演算法的對比

image.png

image.png

retries

生產者從伺服器收到的錯誤有可能是臨時性的錯誤(比如分割槽找不到首領),在這種情況下,reteis 引數的值決定了生產者可以重發的訊息次數,如果達到這個次數,生產者會放棄重試並返回錯誤。預設情況下,生產者在每次重試之間等待 100ms,這個等待引數可以通過 retry.backoff.ms 進行修改。

batch.size

當有多個訊息需要被髮送到同一個分割槽時,生產者會把它們放在同一個批次裡。該引數指定了一個批次可以使用的記憶體大小,按照位元組數計算。當批次被填滿,批次裡的所有訊息會被髮送出去。不過生產者井不一定都會等到批次被填滿才傳送,任意條數的訊息都可能被髮送。

client.id

此引數可以是任意的字串,伺服器會用它來識別訊息的來源,一般配置在日誌裡

max.in.flight.requests.per.connection

此引數指定了生產者在收到伺服器響應之前可以傳送多少訊息,它的值越高,就會佔用越多的記憶體,不過也會提高吞吐量。把它設為1 可以保證訊息是按照傳送的順序寫入伺服器。

timeout.ms、request.timeout.ms 和 metadata.fetch.timeout.ms

request.timeout.ms 指定了生產者在傳送資料時等待伺服器返回的響應時間,metadata.fetch.timeout.ms 指定了生產者在獲取後設資料(比如目標分割槽的首領是誰)時等待伺服器返回響應的時間。如果等待時間超時,生產者要麼重試傳送資料,要麼返回一個錯誤。timeout.ms 指定了 broker 等待同步副本返回訊息確認的時間,與 asks 的配置相匹配----如果在指定時間內沒有收到同步副本的確認,那麼 broker 就會返回一個錯誤。

max.block.ms

此引數指定了在呼叫 send() 方法或使用 partitionFor() 方法獲取後設資料時生產者的阻塞時間當生產者的傳送緩衝區已捕,或者沒有可用的後設資料時,這些方法就會阻塞。在阻塞時間達到 max.block.ms 時,生產者會丟擲超時異常。

max.request.size

該引數用於控制生產者傳送的請求大小。它可以指能傳送的單個訊息的最大值,也可以指單個請求裡所有訊息的總大小。

receive.buffer.bytes 和 send.buffer.bytes

Kafka 是基於 TCP 實現的,為了保證可靠的訊息傳輸,這兩個引數分別指定了 TCP Socket 接收和傳送資料包的緩衝區的大小。如果它們被設定為 -1,就使用作業系統的預設值。如果生產者或消費者與 broker 處於不同的資料中心,那麼可以適當增大這些值。

文章參考:

《Kafka 權威指南》

極客時間 -《Kafka 核心技術與實戰》

Kafka官方文件

Kafka 原始碼

Squeezing the firehose: getting the most from Kafka compression

https://github.com/facebook/zstd

相關文章