Kafka生產者:寫消息到Kafka

本章我們將會討論Kafka生產者是如何發送消息到Kafka的。Kafka項目有一個生產者客戶端,我們可以通過這個客戶端的API來發送消息。

概要

當我們發送消息之前,先問幾個問題:每條消息都是很關鍵且不能容忍丟失么?偶爾重復消息可以么?我們關注的是消息延遲還是寫入消息的吞吐量?
舉個例子,有一個信用卡交易處理系統,當交易發生時會發送一條消息到Kafka,另一個服務來讀取消息并根據規則引擎來檢查交易是否通過,將結果通過Kafka返回。對于這樣的業務,消息既不能丟失也不能重復,由于交易量大因此吞吐量需要盡可能大,延遲可以稍微高一點。
再舉個例子,假如我們需要收集用戶在網頁上的點擊數據,對于這樣的場景,少量消息丟失或者重復是可以容忍的,延遲多大都不重要只要不影響用戶體驗,吞吐則根據實時用戶數來決定。
不同的業務需要使用不同的寫入方式和配置。后面我們將會討論這些API,現在先看下生產者寫消息的基本流程:


image.png

流程如下:

  1. 首先,我們需要創建一個ProducerRecord,這個對象需要包含消息的主題(topic)和值(value),可以選擇性指定一個鍵值(key)或者分區(partition)。
  2. 發送消息時,生產者會對鍵值和值序列化成字節數組,然后發送到分配器(partitioner)。
  3. 如果我們指定了分區,那么分配器返回該分區即可;否則,分配器將會基于鍵值來選擇一個分區并返回。
  4. 選擇完分區后,生產者知道了消息所屬的主題和分區,它將這條記錄添加到相同主題和分區的批量消息中,另一個線程負責發送這些批量消息到對應的Kafka broker。
  5. 當broker接收到消息后,如果成功寫入則返回一個包含消息的主題、分區及位移的RecordMetadata對象,否則返回異常。
  6. 生產者接收到結果后,對于異常可能會進行重試。

創建Kafka生產者

創建Kafka生產者有三個基本屬性:

  • bootstrap.servers:屬性值是一個host:port的broker列表。這個屬性指定了生產者建立初始連接的broker列表,這個列表不需要包含所有的broker,因為生產者建立初始連接后會從相應的broker獲取到集群信息。但建議指定至少包含兩個broker,這樣一個broker宕機后生產者可以連接到另一個broker。
  • key.serializer:屬性值是類的名稱。這個屬性指定了用來序列化鍵值(key)的類。Kafka broker只接受字節數組,但生產者的發送消息接口允許發送任何的Java對象,因此需要將這些對象序列化成字節數組。key.serializer指定的類需要實現org.apache.kafka.common.serialization.Serializer接口,Kafka客戶端包中包含了幾個默認實現,例如ByteArraySerializer、StringSerializer和IntegerSerializer。
  • value.serializer:屬性值是類的名稱。這個屬性指定了用來序列化消息記錄的類,與key.serializer差不多。

Maven依賴

<dependency>
      <groupId>org.apache.kafka</groupId>
      <artifactId>kafka-clients</artifactId>
      <version>0.11.0.0</version>
    </dependency>

下面是一個樣例代碼:

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

KafkaProducer<String, String> producer = new KafkaProducer<String, String>(kafkaProps);

創建完生產者后,我們可以發送消息。Kafka中有三種發送消息的方式:

  • 只發不管結果(fire-and-forget):只調用接口發送消息到Kafka服務器,但不管成功寫入與否。由于Kafka是高可用的,因此大部分情況下消息都會寫入,但在異常情況下會丟消息。
  • 同步發送(Synchronous send):調用send()方法返回一個Future對象,我們可以使用它的get()方法來判斷消息發送成功與否。
  • 異步發送(Asynchronous send):調用send()時提供一個回調方法,當接收到broker結果后回調此方法。

本章的例子都是單線程發送的,但生產者對象是線程安全的,它支持多線程發送消息來提高吞吐。需要的話,我們可以使用多個生產者對象來進一步提高吞吐。

發送消息到Kafka

最簡單的發送消息方式如下:

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

try {
  producer.send(record);
} catch (Exception e) {
  e.printStackTrace();
}

這里做了如下幾件事:

  1. 我們創建了一個ProducerRecord,并且指定了主題以及消息的key/value。主題總是字符串類型的,但key/value則可以是任意類型,在本例中也是字符串。需要注意的是,這里的key/value的類型需要與serializer和生產者的類型匹配。
  2. 使用send()方法來發送消息,該方法會返回一個RecordMetadata的Future對象,但由于我們沒有跟蹤Future對象,因此并不知道發送結果。如前所述,這種方式可能會丟失消息。
  3. 雖然我們忽略了發送消息到broker的異常,但是我們調用send()方法時仍然可能會遇到一些異常,例如序列化異常、發送緩沖區溢出異常等等。

同步發送消息

同步發送方式可以簡單修改如下:

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

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

注意到,這里使用了Future.get()來獲取發送結果,如果發送消息失敗則會拋出異常,否則返回一個RecordMetadata對象。發送失敗異常包含:1)broker返回不可恢復異常,生產者直接拋出該異常;2)對于broker其他異常,生產者會進行重試,如果重試超過一定次數仍不成功則拋出異常。

可恢復異常指的是,如果生產者進行重試可能會成功,例如連接異常;不可恢復異常則是進行重試也不會成功的異常,例如消息內容過大。

異步發送消息

首先了解下什么場景下需要異步發送消息。假如生產者與broker之間的網絡延時為10ms,我們發送100條消息,發送每條消息都等待結果,那么需要1秒的時間。而如果我們采用異步的方式,幾乎沒有任何耗時,而且我們還可以通過回調知道消息的發送結果。

異步發送消息的樣例如下:

public class DemoProducerCallback implements Callback {
  @Override
  public void onCompletion(RecordMetadata recordMetadata, Exception e) {
    if (e != null) {
      e.printStackTrace();
    }
  }
}

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

producer.send(record, new DemoProducerCallback());

異步回調的類需要實現org.apache.kafka.clients.producer.Callback接口,這個接口只有一個onCompletion方法。當Kafka返回異常時,異常值不為null,代碼中只是簡單的打印,但我們可以采取其他處理方式。

kafka生產者 配置

  1. ackstimeout.ms
  • timeout.ms(0.9.0.0版本中就被棄用)
    指定了 broker 等待同步副本返回消息確認的時間,與 asks 的配置相匹配——如果在指定時間內沒有收到同步副本的確認,那么 broker 就會返回一個錯誤。

  • acks = 1
    指定了必須要有多少個分區副本收到消息,生產者才會認為消息寫入是成功的。這個參數對消
    息丟失的可能性有重要影響。該參數有如下選項:

    • acks=0,生產者在成功寫入消息之前不會等待任何來自服務器的響應。也就是說,如果當中
      出現了問題,導致服務器沒有收到消息,那么生產者就無從得知,消息也就丟失了。不過,因為
      生產者不需要等待服務器的響應,所以它可以以網絡能夠支持的最大速度發送消息,從而達到很
      高的吞吐量。

    • acks=1,只要集群的 Leader 節點收到消息,生產者就會收到一個來自服務器的成功響應。如果消息無法到達 Leader 節點(比如首領節點崩潰,新的 Leader 還沒有被選舉出來),生產者會收到一個錯誤響應,為了避免數據丟失,生產者會重發消息。不過,如果一個沒有收到消息的節點成為新Leader,消息還是會丟失。這個時候的吞吐量取決于使用的是同步發送還是異步發送。如果讓發送客戶端等待服務器的響應(通過調用 Future 對象的 get() 方法),顯然會增加延遲(在網絡上傳輸一個來回的延遲)。如果客戶端使用回調,延遲問題就可以得到緩解,不過吞吐量還是會受發送中消息數量的限制(比如,生產者在收到服務器響應之前可以發送多少個消息)。

    • 如果 acks=all,只有當所有參與復制的節點全部收到消息時,生產者才會收到一個來自服務器的成功響應。這種模式是最安全的,它可以保證不止一個服務器收到消息,就算有服務器發生崩潰,整個集群仍然可以運行。不過,它的延遲比 acks=1 時更高,因為我們要等待不只一個服務器節點接收消息。

  1. buffer.memory=33554432
    該參數用來設置生產者內存緩沖區的大小,生產者用它緩沖要發送到服務器的消息。如果生產消息的速度超過發送的速度,會導致生產者空間不足。這個時候, send()方法調用要么被阻塞,要么拋出異常,取決于如何設置 block.on.buffer.full 參數(在 0.9.0.0 版本里被替換成了max.block.ms,表示在拋出異常之前可以阻塞一段時間)

  2. compression.type=none
    默認情況下,消息發送時不會被壓縮。該參數可以設置為 snappygziplz4,它指定了消息被發送給 broker 之前使用哪一種壓縮算法進行壓縮。

  • snappy 壓縮算法由 Google 發明,占用較少的 CPU,卻能提供較好的性能和相當可觀的壓縮比,如果比較關注性能和網絡帶寬,可以使用這種算法。
  • gzip 壓縮算法一般會占用較多的 CPU,但會提供更高的壓縮比,所以如果網絡帶寬比較有限,可以使用這種算法。

使用壓縮可以降低網絡傳輸開銷和存儲開銷,而這往往是向 Kafka 發送消息的瓶頸所在。

  1. retriesretry.backoff.ms
  • retries=0
    生產者從服務器收到的錯誤有可能是臨時性的錯誤(比如分區找不到 Leader)。在這種情況下,retries
    參數的值決定了生產者可以重發消息的次數,如果達到這個次數,生產者會放棄重試并返回錯誤。

  • retry.backoff.ms=100
    默認情況下,生產者會在每次重試之間等待 100ms,不過可以通過 retry.backoff.ms 參數來改變這個時間間隔。建議在設置重試次數和重試時間間隔之前,先測試一下恢復一個崩潰節點需要多少時間(比如所有分區選舉出 Leader 需要多長時間),讓總的重試時間比 Kafka 集群從崩潰中恢復的時間長,否則生產者會過早地放棄重試。

  • 不過有些錯誤不是臨時性錯誤,沒辦法通過重試來解決(比如“消息太大”錯誤)。一般情況下,因為生產者會自動進行重試,所以就沒必要在代碼邏輯里處理那些可重試的錯誤。你只需要處理那些不可重試的錯誤和重試次數超出上限的情況。

  1. batch.sizelinger.ms
  • batch.size:=16384
    當有多個消息需要被發送到同一個分區時,生產者會把它們放在同一個批次里。該參數指定了一個批次可以使用的內存大小,按照字節數計算(而不是消息個數)。
  • linger.ms:=0
    指定了生產者在每次發送消息的時間間隔

當批次被填滿 或者 等待時間達到 linger.ms設置的間隔時間,批次里的所有消息會被發送出去,哪怕此時該批次只有一條消息。
所以就算把批次大小設置得很大,也不會造成延遲,只是會占用更多的內存而已。但如果設置得太小,因為生產者需要更頻繁地發送消息,會增加一些額外的開銷。

  1. client.id=''
    該參數可以是任意的字符串,服務器會用它來識別消息的來源

  2. max.in.flight.requests.per.connection=5
    該參數指定了生產者在收到服務器響應之前可以發送多少個消息。它的值越高,就會占用越多的內存,不過也會提升吞吐量。把它設為 1 可以保證消息是按照發送的順序寫入服務器的,即使發生了重試。

如何保證順序性:如果把 retries 設為非零整數,同時把 max.in.flight.requests.per.connection 設為比 1 大的數,那么,如果第一個批次消息寫入失敗,而第二個批次寫入成功,broker 會重試寫入第一個批次。如果此時第一個批次也寫入成功,那么兩個批次的順序就反過來了。

一般來說,如果某些場景要求消息是有序的,那么消息是否寫入成功也是很關鍵的,所以不建議把retries設為 0。可以把 max.in.flight.requests.per.connection 設為 1,這樣在生產者嘗試發送第一批消息時,就不會有其他的消息發送給broker。不過這樣會嚴重影響生產者的吞吐量,所以只有在對消息的順序有嚴格要求的情況下才能這么做。

  1. request.timeout.msmetadata.fetch.timeout.ms
  • request.timeout.ms=305000
    指定了生產者在發送數據時等待服務器返回響應的時間
  • metadata.fetch.timeout.ms (0.9.0.0版本中就被棄用)
    指定了生產者在獲取元數據(比如目標分區的 Leader 是誰)時等待服務器返回響應的時間。如果等待響應超時,那么生產者要么重試發送數據,要么返回一個錯誤(拋出異常或執行回調)。
  1. max.request.size=1048576
    該參數用于控制生產者發送的請求大小。它可以指能發送的單個消息的最大值,也可以指單個請求里所有消息總的大小。例如,假設這個值為 1MB,那么可以發送的單個最大消息為 1MB,或者生產者可以在單個請求里發送一個批次,該批次包含了 1000 個消息,每個消息大小為 1KB。另外,broker 對可接收的消息最大值也有自己的限制(message.max.bytes),所以兩邊的配置最好可以匹配,避免生產者發送的消息被 broker 拒絕。

注意區分 batch.size只是針對一個 topic 的 partition,而 max.request.size針對單次請求的。

  1. receive.buffer.bytes=32768 和 send.buffer.bytes=131072
    這兩個參數分別指定了 TCP socket 接收和發送數據包的緩沖區大小。如果它們被設為 -1,就使用操作系統的默認值。如果生產者或消費者與 broker 處于不同的數據中心,那么可以適當增大這些值,因為跨數據中心的網絡一般都有比較高的延遲和比較低的帶寬。

關于更多的配置信息,可以查看:http://kafka.apachecn.org/documentation.html#configuration

完整實例

package com.neuedu;

import java.util.Properties;
import org.apache.kafka.clients.producer.*;

public class Producer {

    public static void main(String[] args) {
        Properties props = new Properties();
        props.put("bootstrap.servers",
                "hadoop03:9092,hadoop05:9092,hadoop06:9092");//該地址是集群的子集,用來探測集群。
        props.put("acks", "all");// 記錄完整提交,最慢的但是最大可能的持久化
        props.put("retries", 3);// 請求失敗重試的次數
        props.put("batch.size", 16384);// batch的大小
        props.put("linger.ms", 1);// 默認情況即使緩沖區有剩余的空間,也會立即發送請求,設置一段時間用來等待從而將緩沖區填的更多,單位為毫秒,producer發送數據會延遲1ms,可以減少發送到kafka服務器的請求數據
        props.put("buffer.memory", 33554432);// 提供給生產者緩沖內存總量
        props.put("key.serializer",
                "org.apache.kafka.common.serialization.StringSerializer");// 序列化的方式,
        // ByteArraySerializer或者StringSerializer
        props.put("value.serializer",
                "org.apache.kafka.common.serialization.StringSerializer");

        KafkaProducer<String, String> producer = new KafkaProducer<>(props);

        for (int i = 0; i < 100; i++)
        {
            producer.send(new ProducerRecord<String, String>("payment", Integer.toString(i), Integer.toString(i)));
        }
        producer.close();

    }
}

通過上面的一些講解,應該已經可以比較友好的使用 kafka生產者了,接下來我們還剩下最后一個部分,kafka的分區

分區
我們創建消息的時候,必須要提供主題和消息的內容,而消息的key是可選的,當不指定key時默認為null。消息的key有兩個重要的作用:1)提供描述消息的額外信息;2)用來決定消息寫入到哪個分區,所有具有相同key的消息會分配到同一個分區中。
如果key為null,那么生產者會使用默認的分配器,該分配器使用輪詢(round-robin)算法來將消息均衡到所有分區。
如果key不為null而且使用的是默認的分配器,那么生產者會對key進行哈希并根據結果將消息分配到特定的分區。注意的是,在計算消息與分區的映射關系時,使用的是全部的分區數而不僅僅是可用的分區數。這也意味著,如果某個分區不可用(雖然使用復制方案的話這極少發生),而消息剛好被分配到該分區,那么將會寫入失敗。另外,如果需要增加額外的分區,那么消息與分區的映射關系將會發生改變,因此盡量避免這種情況。
自定義分配器
在kafka配置參數時設置分區器的類
//設置自定義分區
kafkaProps.put("partitioner.class", "com.chb.partitioner.MyPartitioner");

現在來看下如何自定義一個分配器,下面將key為Banana的消息單獨放在一個分區,與其他的消息進行分區隔離:

import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.record.InvalidRecordException;
import org.apache.kafka.common.utils.Utils;

public class BananaPartitioner implements Partitioner {
    public void configure(Map<String, ?> configs) {}
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
    
    List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
    int numPartitions = partitions.size();
    if ((keyBytes == null) || (!(key instanceOf String)))
        throw new InvalidRecordException("We expect all messages to have customer name as key")
    if (((String) key).equals("Banana"))
        return numPartitions - 1; // Banana will always go to last partition
   
     // Other records will get hashed to the rest of the partitions
    return (Math.abs(Utils.murmur2(keyBytes)) % numPartitions)
    }
    
    public void close() {}
 
}
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容