我花了一週讀了Kafka Producer的原始碼

think123發表於2019-08-27

talk is easy,show me the code,先來看一段建立producer的程式碼

public class KafkaProducerDemo {

  public static void main(String[] args) {

    KafkaProducer<String,String> producer = createProducer();

    //指定topic,key,value
    ProducerRecord<String,String> record = new ProducerRecord<>("test1","newkey1","newvalue1");

    //非同步傳送
    producer.send(record);
    producer.close();

    System.out.println("傳送完成");

  }

  public static KafkaProducer<String,String> createProducer() {
    Properties props = new Properties();

    //bootstrap.servers 必須設定
    props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.239.131:9092");

    // key.serializer   必須設定
    props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

    // value.serializer  必須設定
    props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

    //client.id
    props.put(ProducerConfig.CLIENT_ID_CONFIG, "client-0");

    //retries
    props.put(ProducerConfig.RETRIES_CONFIG, 3);

    //acks
    props.put(ProducerConfig.ACKS_CONFIG, "all");

    //max.in.flight.requests.per.connection
    props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 1);
    
    //linger.ms
    props.put(ProducerConfig.LINGER_MS_CONFIG, 100);

    //batch.size
    props.put(ProducerConfig.BATCH_SIZE_CONFIG, 10240);

    //buffer.memory
    props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 10240);

    return new KafkaProducer<>(props);
  }
}
複製程式碼

生產者的API使用還是比較簡單,建立一個ProducerRecord物件(這個物件包含目標主題和要傳送的內容,當然還可以指定鍵以及分割槽),然後呼叫send方法就把訊息傳送出去了。在傳送ProducerRecord物件時,生產者要先把鍵和值物件序列化成位元組陣列,這樣才能在網路上進行傳輸。 在深入原始碼之前,我先給出一張原始碼分析圖給大家(其實應該在結尾的時候給出來),這樣看著圖再看原始碼跟容易些

流程圖

簡要說明:

  1. new KafkaProducer()後建立一個後臺執行緒KafkaThread(實際執行執行緒是Sender,KafkaThread是對Sender的封裝)掃描RecordAccumulator中是否有訊息

  2. 呼叫KafkaProducer.send()傳送訊息,實際是將訊息儲存到RecordAccumulator中,實際上就是儲存到一個Map中(ConcurrentMap<TopicPartition, Deque<ProducerBatch>>),這條訊息會被記錄到同一個記錄批次(相同主題相同分割槽算同一個批次)裡面,這個批次的所有訊息會被髮送到相同的主題和分割槽上

  3. 後臺的獨立執行緒掃描到RecordAccumulator中有訊息後,會將訊息傳送到kafka叢集中(不是一有訊息就傳送,而是要看訊息是否ready)

  4. 如果傳送成功(訊息成功寫入kafka),就返回一個RecordMetaData物件,它包換了主題和分割槽資訊,以及記錄在分割槽裡的偏移量。

  5. 如果寫入失敗,就會返回一個錯誤,生產者在收到錯誤之後會嘗試重新傳送訊息(如果允許的話,此時會將訊息在儲存到RecordAccumulator中),幾次之後如果還是失敗就返回錯誤訊息

原始碼分析

後臺執行緒的建立

KafkaClient client = new NetworkClient(...);
this.sender = new Sender(.,client,...);
String ioThreadName = "kafka-producer-network-thread" + " | " + clientId;
this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
this.ioThread.start();
複製程式碼

上面的程式碼就是構造KafkaProducer時核心邏輯,它會構造一個KafkaClient負責和broker通訊,同時構造一個Sender並啟動一個非同步執行緒,這個執行緒會被命名為:kafka-producer-network-thread|${clientId},如果你在建立producer的時候指定client.id的值為myclient,那麼執行緒名稱就是kafka-producer-network-thread|myclient

傳送訊息(快取訊息)

KafkaProducer<String,String> producer = createProducer();

//指定topic,key,value
ProducerRecord<String,String> record = new ProducerRecord<>("test1","newkey1","newvalue1");

//非同步傳送,可以設定回撥函式
producer.send(record);
//同步傳送
//producer.send(record).get();
複製程式碼

傳送訊息有同步傳送以及非同步傳送兩種方式,我們一般不使用同步傳送,畢竟太過於耗時,使用非同步傳送的時候可以指定回撥函式,當訊息傳送完成的時候(成功或者失敗)會通過回撥通知生產者。

傳送訊息實際上是將訊息快取起來,核心程式碼如下:

RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, 
  serializedKey,serializedValue, headers, interceptCallback, remainingWaitMs);

複製程式碼

RecordAccumulator的核心資料結構是ConcurrentMap<TopicPartition, Deque<ProducerBatch>>,會將相同主題相同Partition的資料放到一個Deque(雙向佇列)中,這也是我們之前提到的同一個記錄批次裡面的訊息會傳送到同一個主題和分割槽的意思。append()方法的核心原始碼如下:

//從batchs(ConcurrentMap<TopicPartition, Deque<ProducerBatch>>)中
//根據主題分割槽獲取對應的佇列,如果沒有則new ArrayDeque<>返回
Deque<ProducerBatch> dq = getOrCreateDeque(tp);

//計算同一個記錄批次佔用空間大小,batchSize根據batch.size引數決定
int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(
	maxUsableMagic, compression, key, value, headers));

//為同一個topic,partition分配buffer,如果同一個記錄批次的記憶體不足,
//那麼會阻塞maxTimeToBlock(max.block.ms引數)這麼長時間
ByteBuffer buffer = free.allocate(size, maxTimeToBlock);
synchronized (dq) {
  //建立MemoryRecordBuilder,通過buffer初始化appendStream(DataOutputStream)屬性
  MemoryRecordsBuilder recordsBuilder = recordsBuilder(buffer, maxUsableMagic);
  ProducerBatch batch = new ProducerBatch(tp, recordsBuilder, time.milliseconds());

  //將key,value寫入到MemoryRecordsBuilder中的appendStream(DataOutputStream)中
  batch.tryAppend(timestamp, key, value, headers, callback, time.milliseconds());

  //將需要傳送的訊息放入到佇列中
  dq.addLast(batch);
}

複製程式碼

傳送訊息到Kafka

上面已經將訊息儲存RecordAccumulator中去了,現在看看怎麼傳送訊息。上面我們提到了建立KafkaProducer的時候會啟動一個非同步執行緒去從RecordAccumulator中取得訊息然後傳送到Kafka,傳送訊息的核心程式碼是Sender.java,它實現了Runnable介面並在後臺一直執行處理髮送請求並將訊息傳送到合適的節點,直到KafkaProducer被關閉

/**
* The background thread that handles the sending of produce requests to the Kafka cluster. This thread makes metadata
* requests to renew its view of the cluster and then sends produce requests to the appropriate nodes.
*/
public class Sender implements Runnable {

  public void run() {

    // 一直執行直到kafkaProducer.close()方法被呼叫
    while (running) {
       run(time.milliseconds());
    }
    
    //從日誌上看是開始處理KafkaProducer被關閉後的邏輯
    log.debug("Beginning shutdown of Kafka producer I/O thread, sending remaining records.");

    //當非強制關閉的時候,可能還仍然有請求並且accumulator中還仍然存在資料,此時我們需要將請求處理完成
    while (!forceClose && (this.accumulator.hasUndrained() || this.client.inFlightRequestCount() > 0)) {
       run(time.milliseconds());
    }
    if (forceClose) {
        //如果是強制關閉,且還有未傳送完畢的訊息,則取消傳送並丟擲一個異常new KafkaException("Producer is closed forcefully.")
        this.accumulator.abortIncompleteBatches();
    }
    ...
  }
}

複製程式碼

KafkaProducer的關閉方法有2個,close()以及close(long timeout,TimeUnit timUnit),其中timeout引數的意思是等待生產者完成任何待處理請求的最長時間,第一種方式的timeout為Long.MAX_VALUE毫秒,如果採用第二種方式關閉,當timeout=0的時候則表示強制關閉,直接關閉Sender(設定running=false)。

run(long)方法中我們先跳過對transactionManager的處理,檢視傳送訊息的主要流程如下:

//將記錄批次轉移到每個節點的生產請求列表中
long pollTimeout = sendProducerData(now);

//輪詢進行訊息傳送
client.poll(pollTimeout, now);

複製程式碼

首先檢視sendProducerData()方法,它的核心邏輯在sendProduceRequest()方法(處於Sender.java)中

for (ProducerBatch batch : batches) {
    TopicPartition tp = batch.topicPartition;

    //將ProducerBatch中MemoryRecordsBuilder轉換為MemoryRecords(傳送的資料就在這裡面)
    MemoryRecords records = batch.records();
    produceRecordsByPartition.put(tp, records);
}

ProduceRequest.Builder requestBuilder = ProduceRequest.Builder.forMagic(minUsedMagic, acks, timeout,
        produceRecordsByPartition, transactionalId);

//訊息傳送完成時的回撥
RequestCompletionHandler callback = new RequestCompletionHandler() {
    public void onComplete(ClientResponse response) {
        //處理響應訊息
        handleProduceResponse(response, recordsByPartition, time.milliseconds());
    }
};

//根據引數構造ClientRequest,此時需要傳送的訊息在requestBuilder中
ClientRequest clientRequest = client.newClientRequest(nodeId, requestBuilder, now, acks != 0,
        requestTimeoutMs, callback);

//將clientRequest轉換成Send物件(Send.java,包含了需要傳送資料的buffer),
//給KafkaChannel設定該物件,記住這裡還沒有傳送資料
client.send(clientRequest, now);
複製程式碼

上面的client.send()方法最終會定位到NetworkClient.doSend()方法,所有的請求(無論是producer傳送訊息的請求還是獲取metadata的請求)都是通過該方法設定對應的Send物件。所支援的請求在ApiKeys.java中都有定義,這裡面可以看到每個請求的request以及response對應的資料結構。

上面只是設定了傳送訊息所需要準備的內容,現在進入到傳送訊息的主流程,傳送訊息的核心程式碼在Selector.java的pollSelectionKeys()方法中,程式碼如下:

/* if channel is ready write to any sockets that have space in their buffer and for which we have data */
if (channel.ready() && key.isWritable()) {
  //底層實際呼叫的是java8 GatheringByteChannel的write方法
  channel.write();
}
複製程式碼

就這樣,我們的訊息就傳送到了broker中了,傳送流程分析完畢,這個是完美的情況,但是總會有傳送失敗的時候(訊息過大或者沒有可用的leader),那麼傳送失敗後重發又是在哪裡完成的呢?還記得上面的回撥函式嗎?沒錯,就是在回撥函式這裡設定的,先來看下回撥函式原始碼

private void handleProduceResponse(ClientResponse response, Map<TopicPartition, ProducerBatch> batches, long now) {
  RequestHeader requestHeader = response.requestHeader();

  if (response.wasDisconnected()) {
    //如果是網路斷開則構造Errors.NETWORK_EXCEPTION的響應
    for (ProducerBatch batch : batches.values())
        completeBatch(batch, new ProduceResponse.PartitionResponse(Errors.NETWORK_EXCEPTION), correlationId, now, 0L);

  } else if (response.versionMismatch() != null) {

   //如果是版本不匹配,則構造Errors.UNSUPPORTED_VERSION的響應
    for (ProducerBatch batch : batches.values())
        completeBatch(batch, new ProduceResponse.PartitionResponse(Errors.UNSUPPORTED_VERSION), correlationId, now, 0L);

  } else {
    
    if (response.hasResponse()) {
        //如果存在response就返回正常的response
           ...
        }
    } else {

        //如果acks=0,那麼則構造Errors.NONE的響應,因為這種情況只需要傳送不需要響應結果
        for (ProducerBatch batch : batches.values()) {
            completeBatch(batch, new ProduceResponse.PartitionResponse(Errors.NONE), correlationId, now, 0L);
        }
    }
  }
}
複製程式碼

而在completeBatch方法中我們主要關注失敗的邏輯處理,核心原始碼如下:

private void completeBatch(ProducerBatch batch, ProduceResponse.PartitionResponse response, long correlationId,
                           long now, long throttleUntilTimeMs) {
  Errors error = response.error;

  //如果傳送的訊息太大,需要重新進行分割傳送
  if (error == Errors.MESSAGE_TOO_LARGE && batch.recordCount > 1 &&
        (batch.magic() >= RecordBatch.MAGIC_VALUE_V2 || batch.isCompressed())) {

    this.accumulator.splitAndReenqueue(batch);
    this.accumulator.deallocate(batch);
    this.sensors.recordBatchSplit();

  } else if (error != Errors.NONE) {

    //發生了錯誤,如果此時可以retry(retry次數未達到限制以及產生異常是RetriableException)
    if (canRetry(batch, response)) {
        if (transactionManager == null) {
            //把需要重試的訊息放入佇列中,等待重試,實際就是呼叫deque.addFirst(batch)
            reenqueueBatch(batch, now);
        } 
    } 
}
複製程式碼

Producer傳送訊息的流程已經分析完畢,現在回過頭去看流程圖會更加清晰。

更多關於Kafka協議的涉及可以參考這個連結

分割槽演算法

List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
if (keyBytes == null) {
    //如果key為null,則使用Round Robin演算法
    int nextValue = nextValue(topic);
    List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
    if (availablePartitions.size() > 0) {
        int part = Utils.toPositive(nextValue) % availablePartitions.size();
        return availablePartitions.get(part).partition();
    } else {
        // no partitions are available, give a non-available partition
        return Utils.toPositive(nextValue) % numPartitions;
    }
} else {
    // 根據key進行雜湊
    return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
複製程式碼

Kafka中對於分割槽的演算法有兩種情況

  1. 如果鍵值為null,並且使用了預設的分割槽器,那麼記錄鍵隨機地傳送到主題內各個可用的分割槽上。分割槽器使用輪詢(Round Robin)演算法鍵訊息均衡地分佈到各個分割槽上。
  2. 如果鍵不為空,並且使用了預設的分割槽器,那麼Kafka會對鍵進行雜湊(使用Kafka自己的雜湊演算法,即使升級Java版本,雜湊值也不會發生變化),然後根據雜湊值把訊息對映到特定的分割槽上。同一個鍵總是被對映到同一個分割槽上(如果分割槽數量發生了變化則不能保證),對映的時候會使用主題所有的分割槽,而不僅僅是可用分割槽,所以如果寫入資料分割槽是不可用的,那麼就會發生錯誤,當然這種情況很少發生。

如果你想要實現自定義分割槽,那麼只需要實現Partitioner介面即可。

生產者的配置引數

分析了KafkaProducer的原始碼之後,我們會發現很多引數是貫穿在整個訊息傳送流程,下面列出了一些KafkaProducer中用到的配置引數。

  1. acks acks引數指定了必須要有多少個分割槽副本收到該訊息,producer才會認為訊息寫入是成功的。有以下三個選項

    • acks=0,生產者不需要等待伺服器的響應,也就是說如果其中出現了問題,導致伺服器沒有收到訊息,生產者就無從得知,訊息也就丟失了,當時由於不需要等待響應,所以可以以網路能夠支援的最大速度傳送訊息,從而達到很高的吞吐量。

    • acks=1, 只需要叢集的leader收到訊息,生產者就會收到一個來自伺服器的成功響應。如果訊息無法到達leader,生產者會收到一個錯誤響應,此時producer會重發訊息。不過如果一個沒有收到訊息的節點稱為leader,訊息還是會丟失。

    • acks=all,當所有參與複製的節點全部收到訊息的時候,生產者才會收到一個來自伺服器的成功響應,最安全不過延遲比較高。

  2. buffer.memory

設定生產者記憶體緩衝區的大小,如果應用程式傳送訊息的速度超過生產者傳送到伺服器的速度,那麼就會導致生產者空間不足,此時send()方法要麼被阻塞,要麼丟擲異常。取決於如何設定max.block.ms,表示在丟擲異常之前可以阻塞一段時間。

  1. retries

傳送訊息到伺服器收到的錯誤可能是可以臨時的錯誤(比如找不到leader),這種情況下根據該引數決定生產者重發訊息的次數。注意:此時要根據重試次數以及是否是RetriableException來決定是否重試。

  1. batch.size

當有多個訊息需要被髮送到同一個分割槽的時候,生產者會把他們放到同一個批次裡面(Deque),該引數指定了一個批次可以使用的記憶體大小,按照位元組數計算,當批次被填滿,批次裡的所有訊息會被髮送出去。不過生產者並不一定會等到批次被填滿才傳送,半滿甚至只包含一個訊息的批次也有可能被髮送。

  1. linger.ms

指定了生產者在傳送批次之前等待更多訊息加入批次的時間。KafkaProducer會在批次填滿或linger.ms達到上限時把批次傳送出去。把linger.ms設定成比0大的數,讓生產者在傳送批次之前等待一會兒,使更多的訊息加入到這個批次,雖然這樣會增加延遲,當時也會提升吞吐量。

  1. max.block.ms

指定了在呼叫send()方法或者partitionsFor()方法獲取後設資料時生產者的阻塞時間。當生產者的傳送緩衝區已滿,或者沒有可用的後設資料時,這些方法就會阻塞。在阻塞時間達到max.block.ms時,就會丟擲new TimeoutException("Failed to allocate memory within the configured max blocking time " + maxTimeToBlockMs + " ms.");

  1. client.id

任意字串,用來標識訊息來源,我們的後臺執行緒就會根據它來起名兒,執行緒名稱是kafka-producer-network-thread|{client.id}

  1. max.in.flight.requests.per.connection

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

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

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

  1. max.request.size

該引數用於控制生產者傳送的請求大小。broker對可接收的訊息最大值也有自己的限制(message.max.bytes),所以兩邊的配置最好可以匹配,避免生產者傳送的訊息被broker拒絕。

  1. receive.buffer.bytes和send.buffer.bytes

這兩個引數分別制定了TCP socket接收和傳送資料包的緩衝區大小(和broker通訊還是通過socket)。如果他們被設定為-1,就使用作業系統的預設值。如果生產者或消費者與broker處於不同的資料中心,那麼可以適當增大這些值,因為跨資料中心的網路一般都有比較高的延遲和比較低的頻寬。

相關文章