高吞吐量訊息系統—kafka

我是碼客發表於2020-08-12

現在基本上大資料的場景中都會有kafka的身影,那麼為什麼這些場景下要用kafka而不用其他傳統的訊息佇列呢?例如rabbitmq。主要的原因是因為kafka天然的百萬級TPS,以及它對接其他大資料元件的流處理功能,比如可以更好的對接Apache storm。本文只是討論kafka作為訊息佇列的功能及一些用法。

 

醜話說在前頭

Kafka本身比較重,強依賴於zookeeper,所以使用Kafka必須要先搭建zookeeper(雖然topic offset已經不在zookeeper管理,但是其他重要的meta資訊都是儲存在zookeeper),本身的topic/partition/replicate/offset等概念需要學習成本,異常情況下存在重複消費資料的風險,需要使用者自行規避,例如將訊息設計為冪等訊息,或者使用者層維護一個index自行記錄有沒有消費過。同一個topic下的不同partition間不能保證訊息順序,只能保證同一個partition的資料是有序的。另外kafka消費者組的rebalance一直是一個使用者詬病的點,topic/parition/消費者數量變化都會引發rebalance,rebalance期間整個消費者組不能消費資料(即STW,stop the world)。所以我個人建議,如果不要求百萬級TPS的訊息佇列並且不強依賴kafka的某些特性,可以優先考慮傳統的訊息佇列,比如rabbitmq。

 

訊息佇列的優勢

1.削峰填谷

用於上下游資料處理時長差別很大的應用場景。比如購物網站,前端需要快速返回給使用者,後端需要處理一系列的動作(查庫存,扣費,發貨等等,很有可能需要依賴其他第三方系統),所以如果前端和後端如果沒有一個訊息佇列,前端的流量可能會壓垮後端。

2.鬆耦合設計

生產者只要將資料放進訊息佇列就完成了任務,至於消費者何時消費資料生產者都不需要關心。這樣帶來的一個好處是生產者如何發生異常或者變更都不會影響生產者。

 

kafka的優勢

1.百萬級TPS

Kafka輕鬆就能達到百萬級的TPS,也是為什麼大資料場景下kafka受歡迎的最主要的原因。具體的壓測方法參考:

https://engineering.linkedin.com/kafka/benchmarking-apache-kafka-2-million-writes-second-three-cheap-machines

Broker端的引數優化可以參考:

https://gist.github.com/jkreps/c7ddb4041ef62a900e6c#file-server-config-properties-L53

 

2.資料強一致性

Kafka收到訊息後會立馬落地,可以配置所有replicate都落地後再讓producer返回。CAP原則,kafka提供了充分的引數讓使用者選擇,資料一致性越強吞吐量越低,需要根據業務場景評估。

 

3.資料可以重複消費

不同於傳統的訊息佇列,佇列中的資料只能消費一次。kafka資料能重複消費,佇列中的資料消費後,每個消費者通過offset控制自己的消費,多個消費者可以同時消費同一個佇列。佇列的資料什麼時候清理是由broker儲存時間配置決定。該特性適合於需要重複載入資料或者多個消費者同時消費一份資料的場景。

 4.只要存活一個broker就能提供服務

對於n個broker組成的kafka叢集,意外當機n-1個broke都能保證對外提供服務。

 

整體介紹

kafka的topic主題是個邏輯意義的佇列,或者說是一類佇列,內部可以有一個或者多個partition,kafka只保證同一個partition內的訊息順序,也就是說partition是物理意義的訊息佇列,不同的partition是不同的訊息佇列。每個partition可以指定一個或者多個副本(replicate),一個leader replicate和n個follower replicate,只有leader replicate提供讀寫服務,其他的副本只會向leader replicate拉取資料做同步,如果leader replicate異常退出將會從剩下的followe replicate重新選舉一個leader。至於其他的副本為什麼不像mysql一樣提供只讀服務?主要原因是kafka是訊息佇列,一般是一寫一讀,mysql資料庫一般是一寫多讀,應用場景不一樣。

 

上圖是兩個生產者往一個topic內的不同partition中寫入資料。每個partition會維護一個offset,一般從0開始,每append一條訊息+1。offset資訊之前版本的kafka是儲存在zookeeper,由於頻繁讀寫offset觸發zookeeper效能瓶頸,所以較新版本的kafka將這些資訊維護在kafka內部的topic中。 kafka也會為每個消費者/消費者組儲存offset,記錄這個消費者/消費者組上一次的消費位置,以便於消費者/消費者組重啟後接著消費,消費者/消費者組也可以指定offset進行消費。

 

更多細節參考:https://kafka.apache.org/documentation/#introduction

 

使用注意事項

生產訊息

 Properties props = new Properties();
 props.put("bootstrap.servers", "localhost:9092");
 props.put("acks", "all");
 props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
 props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

 Producer<String, String> producer = new KafkaProducer<>(props);
 for (int i = 0; i < 100; i++)
     producer.send(new ProducerRecord<String, String>("my-topic", Integer.toString(i), Integer.toString(i)));

 producer.close();

 

這裡acks指定了all,即需要等待所有的ISR拉取到record之後再返回,是kafka吞吐量最低但是資料一致性最高的做法。ProducerRecord指定了topic,以及record的key和value,但是沒有指定partition,如果我們需要指定paritiion可以在topic的後面加上partition,參考下面的方法。

ProducerRecord有多種構造方式:

ProducerRecord(String topic, Integer partition, K key, V value)
Creates a record to be sent to a specified topic and partition
ProducerRecord(String topic, Integer partition, K key, V value, Iterable<Header> headers)
Creates a record to be sent to a specified topic and partition
ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value)
Creates a record with a specified timestamp to be sent to a specified topic and partition
ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value, Iterable<Header> headers)
Creates a record with a specified timestamp to be sent to a specified topic and partition
ProducerRecord(String topic, K key, V value)
Create a record to be sent to Kafka
ProducerRecord(String topic, V value)
Create a record with no key

 

如果ProducerRecord沒有指定record的時間戳,producer預設會新增當前時間的時間戳。kafka最終是否採用record中的時間取決於topic的配置,如果配置為CreateTime將會採用record中的timestamp,如果配置為LogAppendTime則採用kafka broker新增該record時的本機時間。

 

  

生產者批量傳送訊息

producer會為每個partition在本地維護一個buffer,作批量傳送資料用,producer呼叫close方法時會釋放buffer。buffer的大小由配置batch.size指定。生產者端指定batch.size 和linger.ms 搭配使用,提升客戶端和服務端效能。batch.size值預設為16k,即16k以內的record會打包傳送。linger.ms預設為0,即不延時傳送。可以適當調大batch.size的大小,會增加批量傳送的條數,副作用是會消耗一些本地記憶體,batch.size是每個partition的批量傳送大小。

例如指定batch.size=32k linger.ms=5,那麼在5ms內batch.size沒有滿也會等到5ms再傳送,所以linger.ms決定了訊息延時的上限。

生產者buffer總記憶體量大小由配置buffer.memory 決定,預設是32M。如果producer生產資料的速度大於傳送給server的速度就會滿,寫滿後producer send資料會阻塞,阻塞等待的最大時長由配置max.block.ms(預設1分鐘)決定,超過max.block.ms後會拋TimeoutException異常。

 

多執行緒生產者

kafka producer物件是執行緒安全的,可以多執行緒共享一個或者多個producer物件。

 

更多細節參考:https://kafka.apache.org/26/javadoc/index.html?org/apache/kafka/clients/producer/KafkaProducer.html 

 

消費者

消費者組:指定相同group.id的消費者屬於同一個消費者組。一個partition只會分配給同一個group中的其中一個消費者。例如下圖中A消費者組中的C1消費者消費P0和P3兩個partition,C2消費者消費P1和P2兩個partition。B消費者組中的C3,C4,C5,C6分別消費P0,P3,P1,P2 四個partition,如果B消費組有5個消費者,那麼會有一個消費者輪空,即沒有partition可以消費。使用消費者組一定要注意的一個地方是:當topic/partition/改消費者組內消費者數量任一數量發生變化時,都會觸發kafka rebalance,即重新進行負載均衡,在rebalance期間,改消費者組的消費者都不能進行消費。

kafka是如何知道消費者已經異常/退出從而發起rebalance?有兩種機制發現:

1.物理鏈路異常。通過配置session.timeout.ms心跳保活機制,消費者週期性向kafka傳送心跳,超過session.timeout.ms時間沒有收到心跳則認為消費者已經異常,從而剔除改消費者,重新rebalance,將改消費者負責的partition分給其他消費者。

2.邏輯異常。消費者和kafka server的心跳仍然存活,但是消費者由於內部邏輯異常,比如死鎖等,一直沒有poll資料。可以通過設定max.poll.interval.ms來限定兩次拉取資料間隔的最大值,超過這個時間kafka會判定消費者已經異常/不活躍。從而rebalance。

設定這兩個配置一定要慎重,畢竟kafka的rebalance還是有成本的,rebalance期間整個消費者組不能消費資料。

 

消費場景一:自動提交offset

     Properties props = new Properties();
     props.setProperty("bootstrap.servers", "localhost:9092");
     props.setProperty("group.id", "test");
     props.setProperty("enable.auto.commit", "true");
     props.setProperty("auto.commit.interval.ms", "1000");
     props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
     props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
     KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
     consumer.subscribe(Arrays.asList("foo", "bar"));
     while (true) {
         ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
         for (ConsumerRecord<String, String> record : records)
             System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
     }

bootstrap.servers kafka server地址,可以是一個或者多個,用於發現其他kafka broker,所以沒有必要填寫所有的kafka地址,為了高可用寫幾個就行。

enable.auto.commit/auto.commit.interval.ms 設定自動提交offset和自動提交的週期。

這裡需要特別指出的是,設定自動提交有可能會丟失消費資料,有可能poll回來,資料還沒有正式消費,但是offset 已經自動提交了,結果消費者異常退出。消費者程式重啟後讀取kafka儲存的offset,那麼之前崩潰沒有處理的資料將會漏掉,無法感知消費。所以一個可行的辦法是,將enable.auto.commit設定為false,while迴圈消費完後再呼叫commit。這樣做異常崩潰情況下會重複消費部分資料,需要使用者自行規避,可以將訊息設定為冪等,或者消費體中有序號欄位,使用者層能夠感知到這個訊息已經消費過,從而丟棄。即下面的場景二。

 

消費場景二:手動提交offset

     Properties props = new Properties();
     props.setProperty("bootstrap.servers", "localhost:9092");
     props.setProperty("group.id", "test");
     props.setProperty("enable.auto.commit", "false");
     props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
     props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
     KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
     consumer.subscribe(Arrays.asList("foo", "bar"));
     final int minBatchSize = 200;
     List<ConsumerRecord<String, String>> buffer = new ArrayList<>();
     while (true) {
         ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
         for (ConsumerRecord<String, String> record : records) {
             buffer.add(record);
         }
         if (buffer.size() >= minBatchSize) {
             insertIntoDb(buffer);
             consumer.commitSync();
             buffer.clear();
         }
     }

  

手動管理partition分配

為了避免topic/partition/消費者數量變化頻繁引起的rebalance,使用者層可以自行管理partition的分配,不用消費者組。

    String topic = "foo";
    TopicPartition partition0 = new TopicPartition(topic, 0);
    TopicPartition partition1 = new TopicPartition(topic, 1);
    consumer.assign(Arrays.asList(partition0, partition1));

然後可以像上面的示例使用while迴圈poll資料。

 

多執行緒consumer

kafka consumer物件不是執行緒安全的,換言之,不能多個執行緒用同一個consumer去poll資料。如果一定要這樣做,需要使用者自行實現多執行緒同步訪問consumer。建議還是一個執行緒一個獨立的consumer,多執行緒共享一個或者多個consumer物件還涉及到消費資料順序的問題。

 

更多細節參考:https://kafka.apache.org/26/javadoc/index.html?org/apache/kafka/clients/consumer/KafkaConsumer.html

 

高水平API VS 低水平API

kakfa提供high-level 和low-level api供使用者使用,可以根據不同的使用場景選擇不同的api。

高水平API(high-level api):API已經遮蔽底層topic/partition/offset管理細節,使用者呼叫API只需要指定kafka地址topic名稱就能生產和消費資料。比較簡單,使用者管理力度較弱。

低水平API(low-level api):API暴露底層topic/partition/offset,需要使用者自行管理,包括offset儲存,然後根據offset seek到特定的位置開始消費,尋找partition leader副本等。比較複雜,使用者管理力度較強。

 

總結

本文介紹了kafka的優缺點,以及圍繞生產和消費訊息兩種場景展開kafka的使用說明以及一些注意事項。下一篇將會介紹程式碼級別的demo應用。

相關文章