Kafka 學習筆記

低吟不作語發表於2022-02-07

為什麼使用訊息佇列?

以使用者下單購買商品的行為舉例,在使用微服務架構時,我們需要呼叫多個服務。傳統的呼叫方式是同步呼叫,這會存在一定的效能問題

使用訊息佇列可以實現非同步的通訊方式,相比於同步的通訊⽅式,非同步的⽅式可以讓上游快速成功,極大提高系統的吞吐量。在分散式系統中,通過下游多個服務的分散式事務的保障,也能保障業務執行之後的最終⼀致性


Kafka 概述

1. 介紹

Kafka 是⼀個分散式的、⽀持分割槽的(partition)、多副本的 (replica),基於 zookeeper 協調的分散式訊息系統,它最大的特性就是可以實時處理大量資料以滿足各類需求場景:

  • 日誌收集:使用 Kafka 收集各種服務的日誌,並通過 kafka 以統一介面服務的方式開放給各種 consumer,例如 hadoop、Hbase、Solr 等
  • 訊息系統:解耦和生產者和消費者、快取訊息等
  • 使用者活動跟蹤:Kafka 經常被用來記錄 web 使用者或者 app 使用者的各種活動,如瀏覽網頁、搜尋、點選等活動,這些活動資訊被各個伺服器釋出到 kafka 的 topic 中,然後訂閱者通過訂閱這些 topic 來做實時的監控分析,或者裝載到 hadoop、資料倉儲中做離線分析和挖掘
  • 運營指標:Kafka 也經常用來記錄運營監控資料,包括收集各種分散式應用的資料,生產各種操作的集中反饋,比如報警和報告

2. 相關術語

名稱 解釋
Broker 訊息中介軟體處理節點,⼀個 Kafka 節點就是⼀個 broker,⼀個或者多個 Broker 可以組成⼀個 Kafka 叢集
Topic Kafka 根據 topic 對訊息進行歸類,釋出到 Kafka 叢集的每條訊息都需要指定⼀個 topic
Producer 訊息生產者,向 Broker 傳送訊息的客戶端
Consumer 訊息消費者,從 Broker 讀取訊息的客戶端
ConsumerGroup 每個 Consumer 屬於⼀個特定的 Consumer Group,⼀條訊息可以被多個不同的 Consumer Group 消費,但是⼀個 Consumer Group 中只能有⼀個 Consumer 能夠消費該訊息
Partition 物理上的概念,⼀個 topic 可以分為多個 partition,每個 partition 內部訊息是有序的

3. 安裝

安裝 Kafka 之前需要先安裝 JDK 和 Zookeeper,在官網下載 Kafka 安裝包:http://kafka.apache.org/downloads,直接解壓即可

需要修改配置檔案,進⼊到 config 目錄內,修改 server.properties

# broker.id 屬性在 kafka 叢集中必須唯一
broker.id= 0
# kafka 部署的機器 ip 和提供服務的埠號
listeners=PLAINTEXT://192.168.65.60:9092
# kafka 的訊息儲存檔案
log.dir=/usr/local/data/kafka-logs
# kafka 連線 zookeeper 的地址
zookeeper.connect= 192.168.65.60:2181

server.properties 核心配置詳解:

Property Default Description
broker.id 0 每個 broker 都可以用⼀個唯⼀的非負整數 id 進行標識,作為 broker 的 名字
log.dirs /tmp/kafka-logs kafka 存放資料的路徑,這個路徑並不是唯⼀的,可以是多個,路徑之間只需要使⽤逗號分隔即可;每當建立新 partition 時,都會選擇在包含最少 partitions 的路徑下進行
listeners PLAINTEXT://192.168.65.60:9092 server 接受客戶端連線的端⼝,ip 配置 kafka 本機 ip 即可
zookeeper.connect localhost:2181 zooKeeper 連線字串的格式為:hostname:port,此處 hostname 和 port 分別是 ZooKeeper 叢集中某個節點的 host 和 port;zookeeper 如果是叢集,連線⽅式為 hostname1:port1,hostname2:port2,hostname3:port3
log.retention.hours 168 每個日誌檔案刪除之前儲存的時間,預設資料儲存時間對所有 topic 都⼀樣
num.partitions 1 建立 topic 的預設分割槽數
default.replication.factor 1 ⾃動建立 topic 的預設副本數量,建議設定為⼤於等於 2
min.insync.replicas 1 當 producer 設定 acks 為 -1 時,min.insync.replicas 指定 replicas 的最小數目(必須確認每⼀個 repica 的寫資料都是成功的),如果這個數目沒有達到,producer 傳送訊息會產生異常
delete.topic.enable false 是否允許刪除主題

進入到 bin 目錄下,使用命令來啟動

./kafka-server-start.sh -daemon../config/server.properties

驗證是否啟動成功:進入到 zk 中的節點看 id 是 0 的 broker 有沒有存在(上線)

ls /brokers/ids/

實現訊息的生產和消費

1. 主題 Topic

topic 可以實現訊息的分類,不同消費者訂閱不同的 topic

執行以下命令建立名為 test 的 topic,這個 topic 只有一個 partition,並且備份因子也設定為 1

./kafka-topics.sh --create --zookeeper 172.16.253.35:2181 --replication-factor 1 --partitions 1 --topic test

檢視當前 kafka 內有哪些 topic

./kafka-topics.sh --list --zookeeper 172.16.253.35:2181

2. 傳送訊息

把訊息傳送給 broker 中的某個 topic,開啟⼀個 kafka 傳送訊息的客戶端,然後開始⽤客戶端向 kafka 伺服器傳送訊息

kafka 自帶了一個 producer 命令客戶端,可以從本地檔案中讀取內容,或者我們也可以以命令列中直接輸入內容,並將這些內容以訊息的形式傳送到 kafka 叢集中。在預設情況下,每一個行會被當做成一個獨立的訊息

./kafka-console-producer.sh --broker-list 172.16.253.38:9092 --topic test

3. 消費訊息

對於 consumer,kafka 同樣也攜帶了一個命令列客戶端,會將獲取到內容在命令中進行輸出,預設是消費最新的訊息。使用 kafka 的消費者客戶端,從指定 kafka 伺服器的指定 topic 中消費訊息

方式一:從最後一條訊息的 偏移量+1 開始消費

./kafka-console-consumer.sh --bootstrap-server 172.16.253.38:9092 --topic test

方式二:從頭開始消費

./kafka-console-consumer.sh --bootstrap-server 172.16.253.38:9092 --from-beginning --topic test

訊息的傳送方會把訊息傳送到 broker 中,broker 會儲存訊息,訊息是按照傳送的順序進行儲存。因此消費者在消費訊息時可以指明主題中訊息的偏移量。預設情況下,是從最後一個訊息的下一個偏移量開始消費

4. 單播訊息

一個消費組裡只有一個消費者能消費到某一個 topic 中的訊息,可以建立多個消費者,這些消費者在同一個消費組中

./kafka-console-consumer.sh --bootstrap-server 10.31.167.10:9092 --consumer-property group.id=testGroup --topic test

5. 多播訊息

在一些業務場景中需要讓一條訊息被多個消費者消費,那麼就可以使用多播模式。kafka 實現多播,只需要讓不同的消費者處於不同的消費組即可

./kafka-console-consumer.sh --bootstrap-server 10.31.167.10:9092 --consumer-property group.id=testGroup1 --topic test

./kafka-console-consumer.sh --bootstrap-server 10.31.167.10:9092 --consumer-property group.id=testGroup2 --topic test

6. 檢視消費組及資訊

# 檢視當前主題下有哪些消費組
./kafka-consumer-groups.sh --bootstrap-server 10.31.167.10:9092 --list
# 檢視消費組中的具體資訊:比如當前偏移量、最後一條訊息的偏移量、堆積的訊息數量
./kafka-consumer-groups.sh --bootstrap-server 172.16.253.38:9092 --describe --group testGroup

  • Currennt-offset:當前消費組的已消費偏移量
  • Log-end-offset:主題對應分割槽訊息的結束偏移量(HW)
  • Lag:當前消費組未消費的訊息數

7. 其他細節

  • 生產者將訊息傳送給 broker,broker 會將訊息儲存在本地的日誌檔案中

    /usr/local/kafka/data/kafka-logs/主題-分割槽/00000000.log
    
  • 訊息的儲存是有序的,通過 offset 偏移量來描述訊息的有序性

  • 消費者消費訊息時也是通過 offset 來描述當前要消費的那條訊息的位置


主題與分割槽

主題 Topic 在 kafka 中是⼀個邏輯概念,kafka 通過 topic 將訊息進行分類。不同的 topic 會被訂閱該 topic 的消費者消費。但是有⼀個問題,如果說這個 topic 的訊息非常多,訊息是會被儲存到 log 日誌檔案中的,這會出現檔案過大的問題,因此,kafka 提出了 Partition 分割槽的概念

通過 partition 將⼀個 topic 中的訊息分割槽來儲存,這樣的好處有多個:

  • 分割槽儲存,可以解決儲存檔案過大的問題
  • 提供了讀寫的吞吐量:讀和寫可以同時在多個分割槽進⾏

為⼀個主題建立多個分割槽

./kafka-topics.sh --create --zookeeper localhost:2181 --partitions 2 --topic test1

通以下命令檢視 topic 的分割槽資訊

./kafka-topics.sh --describe --zookeeper localhost:2181 --topic test1

分割槽的作用:

  • 可以分散式儲存
  • 可以並行寫

瞭解了 Partition,再補充一個 Kafka 細節:在訊息日誌檔案中,kafka 內部建立了 __consumer_offsets 主題包含了 50 個分割槽。這個主題用來存放消費者某個主題的偏移量,每個消費者會把消費的主題的偏移量自主上報給 kafka 中的預設主題:consumer_offsets。因此 kafka 為了提升這個主題的併發性,預設設定了 50 個分割槽

  • 提交到哪個分割槽:通過 hash 函式:hash(consumerGroupId) % __consumer_offsets 主題的分割槽數
  • 提交到該主題中的內容是:key 是 consumerGroupId + topic + 分割槽號,value 就是當前 offset 的值
  • 檔案中儲存的訊息,預設儲存七天,七天到後訊息會被刪除

Kafka 叢集

1. 搭建

建立三個 server.properties 檔案

# 0 1 2
broker.id=2
# 9092 9093 9094
listeners=PLAINTEXT://192.168.65.60:9094
# kafka-logs kafka-logs-1 kafka-logs-2
log.dir=/usr/local/data/kafka-logs-2

通過命令啟動三臺 broker

./kafka-server-start.sh -daemon../config/server0.properties
./kafka-server-start.sh -daemon../config/server1.properties
./kafka-server-start.sh -daemon../config/server2.properties

搭建完後通過檢視 zk 中的 /brokers/ids 看是否啟動成功

2. 副本

下面的命令,在建立主題時,除了指明瞭主題的分割槽數以外,還指明瞭副本數,分別是:一個主題,兩個分割槽、三個副本

./kafka-topics.sh --create --zookeeper 172.16.253.35:2181 --replication-factor 3 --partitions 2 --topic my-replicated-topic

通過檢視主題資訊,其中的關鍵資料:

  • replicas:當前副本所存在的 broker 節點

  • leader:副本里的概念

    • 每個 partition 都有一個 broker 作為 leader
    • 訊息傳送方要把訊息發給哪個 broker,就看副本的 leader 是在哪個 broker 上面,副本里的 leader 專門用來接收訊息
    • 接收到訊息,其他 follower 通過 poll 的方式來同步資料
  • follower:leader 處理所有針對這個 partition 的讀寫請求,而 follower 被動複製 leader,不提供讀寫(主要是為了保證多副本資料與消費的一致性),如果 leader 所在的 broker 掛掉,那麼就會進行新 leader 的選舉

  • isr:可以同步的 broker 節點和已同步的 broker 節點,存放在 isr 集合中

3. broker、主題、分割槽、副本

Kafka 叢集中由多個 broker 組成,⼀個 broker 存放⼀個 topic 的不同 partition 以及它們的副本

4. 叢集訊息的傳送

./kafka-console-producer.sh --broker-list 172.16.253.38:9092,172.16.253.38:9093,172.16.253.38:9094 --topic my-replicated-topic

5. 叢集訊息的消費

./kafka-console-consumer.sh --bootstrap-server 172.16.253.38:9092,172.16.253.38:9093,172.16.253.38:9094 --from-beginning --topic my-replicated-topic

6. 分割槽消費組消費者的細節

  • ⼀個 partition 只能被⼀個消費組中的⼀個消費者消費,目的是為了保證消費的順序性,但是多個 partion 的多個消費者的消費順序性是得不到保證的
  • 一個消費者可以消費多個 partition,如果消費者掛了,那麼會觸發rebalance機制,由其他消費者來消費該分割槽
  • 消費組中消費者的數量不能比一個 topic 中的 partition 數量多,否則多出來的消費者消費不到訊息

Java 中使用 Kafka

1. 生產者

1.1 引入依賴
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>2.4.1</version>
</dependency>
1.2 生產者傳送訊息
/**
 * 訊息的傳送方
 */
public class MyProducer {

    private final static String TOPIC_NAME = "my-replicated-topic";

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 1.設定引數
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "10.31.167.10:9092,10.31.167.10:9093,10.31.167.10:9094");
        // 把傳送的 key 從字串序列化為位元組陣列
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        // 把傳送訊息 value 從字串序列化為位元組陣列
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

        // 2.建立⽣產訊息的客戶端,傳⼊引數
        Producer<String, String> producer = new KafkaProducer<String, String>(props);
        // 3.建立訊息
        // key: 作⽤是決定了往哪個分割槽上發
        // value: 具體要傳送的訊息內容
        ProducerRecord<String, String> producerRecord = new ProducerRecord<>(TOPIC_NAME, "mykeyvalue", "hellokafka");
        // 4.傳送訊息,得到訊息傳送的後設資料並輸出
        RecordMetadata metadata = producer.send(producerRecord).get();
        System.out.println("同步⽅式傳送訊息結果:" + "topic-" + metadata.topic() + "|partition-" + metadata.partition() + "|offset-" + metadata.offset());
    }
}
1.3 傳送訊息到指定分割槽
ProducerRecord<String, String> producerRecord = new ProducerRecord<String, String>(TOPIC_NAME, 0 , order.getOrderId().toString(), JSON.toJSONString(order));

如果未指定分割槽,則會通過業務 Key 的 hash 運算,得出要傳送的分割槽,公式為:hash(key)%partitionNum

1.4 同步傳送訊息

⽣產者同步發訊息,在收到 kafka 的 ack 告知傳送成功之前將⼀直處於阻塞狀態

// 等待訊息傳送成功的同步阻塞方法
RecordMetadata metadata = producer.send(producerRecord).get();
System.out.println("同步方式傳送訊息結果:" + "topic-" +metadata.topic() + "|partition-"+ metadata.partition() + "|offset-" +metadata.offset());
1.5 非同步傳送訊息

非同步傳送,⽣產者傳送完訊息後就可以執⾏之後的業務,broker 在收到訊息後非同步呼叫生產者提供的 callback 回撥方法

// 指定傳送分割槽
ProducerRecord<String, String> producerRecord = new ProducerRecord<String, String>(TOPIC_NAME, 0 , order.getOrderId().toString(),JSON.toJSONString(order));
// 非同步回撥方式傳送訊息
producer.send(producerRecord, new Callback() {
    public void onCompletion(RecordMetadata metadata, Exception exception) {
        if (exception != null) {
            System.err.println("傳送訊息失敗:" +
                               exception.getStackTrace());
        }
        if (metadata != null) {
            System.out.println("非同步方式傳送訊息結果:" + "topic-" +metadata.topic() + "|partition-"+ metadata.partition() + "|offset-" + metadata.offset());
        }
    }
});
1.6 生產者中的 ack 的配置

在同步傳送的前提下,生產者在獲得叢集返回的 ack 之前會⼀直阻塞,那麼叢集什麼時候返回 ack 呢?此時 ack 有三個配置:

  • acks = 0:表示 producer 不需要等待任何 broker 確認收到訊息的回覆,就可以繼續傳送下一條訊息,效能最高,但最容易丟訊息
  • acks = 1:至少要等待leader已經成功將資料寫入本地log,但是不需要等待所有follower是否成功寫入。就可以繼續傳送下一條訊息。這種情況下,如果follower沒有成功備份資料,而此時leader又掛掉,則訊息會丟失
  • acks = -1 或 all:需要等待 min.insync.replicas(預設為 1 ,推薦配置大於等於2)這個引數配置的副本個數都成功寫入日誌,這種策略會保證只要有一個備份存活就不會丟失資料。這是最強的資料保證,一般是金融級別,或跟錢打交道的場景才會使用這種配置
props.put(ProducerConfig.ACKS_CONFIG, "1");
// 傳送失敗,預設會重試三次,每次間隔 100ms
props.put(ProducerConfig.RETRIES_CONFIG, 3);
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 100)
1.7 訊息傳送的緩衝區

  • kafka 預設會建立⼀個訊息緩衝區,用來存放要傳送的訊息,緩衝區是 32m

    props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432);
    
  • kafka 本地執行緒會在緩衝區中⼀次拉 16k 的資料,傳送到 broker

    props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384);
    
  • 如果執行緒拉不到 16k 的資料,間隔 10ms 也會將已拉到的資料發到 broker

    props.put(ProducerConfig.LINGER_MS_CONFIG, 10);
    

2. 消費者

2.1 消費訊息
public class MySimpleConsumer {
    
    private final static String TOPIC_NAME = "my-replicated-topic";
    private final static String CONSUMER_GROUP_NAME = "testGroup";
    
    public static void main(String[] args) {
        
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "172.16.253.38:9092,172.16.253.38:9093,172.16.253.38:9094");
 		// 消費分組名
        props.put(ConsumerConfig.GROUP_ID_CONFIG, CONSUMER_GROUP_NAME);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        
 		// 1.建立⼀個消費者的客戶端
        KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);
 		// 2.消費者訂閱主題列表
        consumer.subscribe(Arrays.asList(TOPIC_NAME));
 		while (true) {
            /*
             * 3. poll() API 是拉取訊息的⻓輪詢
             */
 			ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
 			for (ConsumerRecord<String, String> record : records) {
 				// 4.列印訊息
                System.out.printf("收到訊息:partition = %d,offset = %d, key = %s, value = %s%n", record.partition(), record.offset(), record.key(), record.value());
            }
        }
    }
}
2.2 自動提交和手動提交 offset

無論是自動提交還是手動提交,都需要把所屬的 消費組 + 消費的某個主題 + 消費的某個分割槽 + 消費的偏移量 提交到叢集的 _consumer_offsets 主題裡面

  • 自動提交:消費者 poll 訊息下來以後自動提交 offset

    // 是否自動提交 offset,預設就是 true
    props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
    // 自動提交 offset 的間隔時間
    props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
    

    注意:如果消費者還沒消費完 poll 下來的訊息就自動提交了偏移量,此時消費者掛了,於是下⼀個消費者會從已提交的 offset 的下⼀個位置開始消費訊息,之前未被消費的訊息就丟失掉了

  • 手動提交:需要把自動提交的配置改成 false

    props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
    

    手動提交又分成了兩種:

    • 手動同步提交

      在消費完訊息後呼叫同步提交的方法,當叢集返回 ack 前⼀直阻塞,返回 ack 後表示提交成功,執行之後的邏輯

      while (true) {
          /*
           * poll() API 是拉取訊息的⻓輪詢
           */
       	ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
          for (ConsumerRecord<String, String> record : records) {
              System.out.printf("收到訊息:partition = %d,offset = %d, key = %s, value = %s%n", record.partition(),record.offset(), record.key(), record.value());
          }
       	// 所有的訊息已消費完
       	if (records.count() > 0) { // 有訊息
       		// ⼿動同步提交 offset, 當前執行緒會阻塞直到 offset 提交成功
       		// ⼀般使⽤同步提交, 因為提交之後⼀般也沒有什麼邏輯程式碼了
              consumer.commitSync(); // ====阻塞=== 提交成功
          }
      }
      
    • 手動非同步提交

      在訊息消費完後提交,不需要等到叢集 ack,直接執行之後的邏輯,可以設定⼀個回撥方法,供叢集呼叫

      while (true) {
          /*
           * poll() API 是拉取訊息的⻓輪詢
           */
          ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
          for (ConsumerRecord<String, String> record : records) {
              System.out.printf("收到訊息:partition = %d,offset = %d, key = %s, value = %s%n", record.partition(), record.offset(), record.key(), record.value());
          }
       	// 所有的訊息已消費完
       	if (records.count() > 0) {
       		// 手動非同步提交 offset,當前執行緒提交 offset 不會阻塞,可以繼續處理後⾯的程式邏輯
       		consumer.commitAsync(new OffsetCommitCallback() {
       			@Override
       			public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
                      if (exception != null) {
       					System.err.println("Commit failed for " + offsets);
       					System.err.println("Commit failed exception: " + exception.getStackTrace());
                      }
                  }
              });
          }
      }
      
2.3 長輪詢 poll 訊息

消費者建立與 broker 之間的長連線,開始 poll 訊息,預設⼀次 poll 五百條訊息

// ⼀次 poll 最⼤拉取訊息的條數,可以根據消費速度的快慢來設定
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500)

可以根據消費速度的快慢來設定,如果兩次 poll 的時間超出了 30s 的時間間隔,kafka 會認為其消費能力過弱,將其踢出消費組,將分割槽分配給其他消費者

程式碼中設定了長輪詢的時間是 1000 毫秒

while (true) {
    /*
     * poll() API 是拉取訊息的⻓輪詢
     */
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
    for (ConsumerRecord<String, String> record : records) {
        System.out.printf("收到訊息:partition = %d,offset = %d, key = %s, value = %s%n", record.partition(), record.offset(), record.key(), record.value());
    }
}
  • 如果⼀次 poll 到 500 條,就直接執行 for 迴圈
  • 如果這⼀次沒有 poll 到 500 條,且時間在1秒內,那麼長輪詢繼續 poll,要麼到 500 條,要麼到 1s
  • 如果多次 poll 都沒達到 500 條,且 1 秒時間到了,那麼直接執行 for 迴圈
2.4 健康狀態檢查

消費者每隔 1s 向 Kafka 叢集傳送心跳,叢集發現如果有超過 10s 沒有續約的消費者,將被踢出消費組,觸發該消費組的 rebalance 機制,將該分割槽交給消費組裡的其他消費者進行消費

// consumer 給 broker 傳送⼼跳的間隔時間
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 1000);
// kafka 如果超過 10 秒沒有收到消費者的⼼跳,則會把消費者踢出消費組,進行rebalance,把分割槽分配給其他消費者
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 10 * 1000)
2.5 指定分割槽消費
consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
2.6 訊息回溯消費

也即從頭開始消費訊息

consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
consumer.seekToBeginning(Arrays.asList(new TopicPartition(TOPIC_NAME,
0)));
2.7 指定偏移量消費
consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
consumer.seek(new TopicPartition(TOPIC_NAME, 0), 10);
2.8 從指定時間點消費

根據時間,去所有的 partition 中確定該時間對應的 offset,然後去所有的 partition 中找到該 offset 之後的訊息開始消費

List<PartitionInfo> topicPartitions = consumer.partitionsFor(TOPIC_NAME);
// 從一小時前開始消費
long fetchDataTime = new Date().getTime() - 1000 * 60 * 60;
Map<TopicPartition, Long> map = new HashMap<>();
for (PartitionInfo par : topicPartitions) {
    map.put(new TopicPartition(TOPIC_NAME, par.partition()), fetchDataTime);
}
Map<TopicPartition, OffsetAndTimestamp> parMap = consumer.offsetsForTimes(map);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : parMap.entrySet()) {
    TopicPartition key = entry.getKey();
    OffsetAndTimestamp value = entry.getValue();
    if (key == null || value == null) {
    	continue;
    }
    // 根據消費⾥的 timestamp 確定 offset
    Long offset = value.offset();
    System.out.println("partition-" + key.partition() + "|offset-" + offset);
    if (value != null) {
        consumer.assign(Arrays.asList(key));
        consumer.seek(key, offset);
    }
}
2.9 新消費組的消費 offset 規則

新消費組中的消費者在啟動以後,預設會從當前分割槽的最後⼀條訊息的 offset+1 開始消費(消費新訊息),可以通過以下的設定,讓新的消費者第⼀次從頭開始消費,之後開始消費新訊息(最後消費的位置的偏移量 +1)

  • Latest:預設的,消費新訊息
  • earliest:第⼀次從頭開始消費,之後開始消費新訊息(最後消費的位置的偏移量 +1)
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");

SpringBoot 中使用 Kafka

1. 引入依賴

<dependency>
	<groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

2. 編寫配置檔案

server:
	port: 8080
spring:
	kafka:
		bootstrap-servers: 172.16.253.38:9092,172.16.253.38:9093,172.16.253.38:9094
 		producer: # ⽣產者
 			retries: 3 # 設定⼤於0的值,則客戶端會將傳送失敗的記錄重新傳送
 			batch-size: 16384
 			buffer-memory: 33554432
 			acks: 1
 			# 指定訊息key和訊息體的編解碼⽅式
 			key-serializer: org.apache.kafka.common.serialization.StringSerializer
 			value-serializer: org.apache.kafka.common.serialization.StringSerializer
        consumer:
        	group-id: default-group
        	enable-auto-commit: false
        	auto-offset-reset: earliest
        	key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
        	value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
        	max-poll-records: 500
 		listener:
 			# 當每⼀條記錄被消費者監聽器(ListenerConsumer)處理之後提交
 			# RECORD
 			# 當每⼀批 poll() 的資料被消費者監聽器(ListenerConsumer)處理之後提交
 			# BATCH
 			# 當每⼀批poll()的資料被消費者監聽器(ListenerConsumer)處理之後,距離上次提交時間⼤於TIME時提交
 			# TIME
 			# 當每⼀批poll()的資料被消費者監聽器(ListenerConsumer)處理之後,被處理record數量⼤於等於COUNT時提交
 			# COUNT
 			# TIME | COUNT 有⼀個條件滿⾜時提交
 			# COUNT_TIME
 			# 當每⼀批poll()的資料被消費者監聽器(ListenerConsumer)處理之後, ⼿動調⽤Acknowledgment.acknowledge()後提交
 			# MANUAL
 			# 手動調⽤Acknowledgment.acknowledge()後⽴即提交,⼀般使⽤這種
 			# MANUAL_IMMEDIATE
 			ack-mode: MANUAL_IMMEDIATE
 redis:
 	host: 172.16.253.21

3. 編寫生產者

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/msg")
public class MyKafkaController {

    private final static String TOPIC_NAME = "my-replicated-topic";

    @Autowired
 	private KafkaTemplate<String,String> kafkaTemplate;
    
    @RequestMapping("/send")
 	public String sendMessage(){
        kafkaTemplate.send(TOPIC_NAME,0,"key","this is a message!");
        return "send success!";
    }
}

4. 編寫消費者

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;

@Component
public class MyConsumer {
    
    @KafkaListener(topics = "my-replicated-topic",groupId = "MyGroup1")
    public void listenGroup(ConsumerRecord<String, String> record, Acknowledgment ack) {
        String value = record.value();
        System.out.println(value);
        System.out.println(record);
        // 手動提交offset
        ack.acknowledge();
    }
}

配置消費主題、分割槽和偏移量

@KafkaListener(groupId = "testGroup", topicPartitions = {
	@TopicPartition(topic = "topic1", partitions = {"0", "1"}),
	@TopicPartition(topic = "topic2", partitions = "0",
                    partitionOffsets = 	@PartitionOffset(partition = "1", initialOffset = "100"))},concurrency = "3") // concurrency 就是同組下的消費者個數,就是併發消費數,建議⼩於等於分割槽總數
public void listenGroupPro(ConsumerRecord<String, String> record, Acknowledgment ack) {
    String value = record.value();
    System.out.println(value);
    System.out.println(record);
 	// 手動提交offset
    ack.acknowledge();
}

Kafka 叢集 Controller、Rebalance、HW

1. Controller

Kafka 叢集中的 broker 在 zookeeper 中建立臨時序號節點,序號最小的節點(最先建立的節點)將作為叢集的 controller,負責管理整個叢集中的所有分割槽和副本的狀態:

  • 當某個分割槽的 leader 副本出現故障時,由控制器負責為該分割槽選舉新的 leader 副本,選舉的規則是從 isr 集合中最左邊獲取
  • 當叢集中有 broker 新增或減少,controller 會同步資訊給其他 broker
  • 當叢集中有分割槽新增或減少,controller 會同步資訊給其他 broker

2. Rebalance

如果消費者沒有指明分割槽消費,那麼當消費組裡消費者和分割槽的關係發生變化,就會觸發 rebalance 機制,重新調整消費者該消費哪個分割槽

在觸發 rebalance 機制之前,消費者消費哪個分割槽有三種分配策略:

  • range:通過公式來計算某個消費者消費哪個分割槽,公式為:前面的消費者是 (分割槽總數/消費者數量)+1,之後的消費者是 分割槽總數/消費者數量
  • 輪詢:大家輪著來
  • sticky:粘合策略,如果需要 rebalance,會在之前已分配的基礎上調整,不會改變之前的分配情況。如果這個策略沒有開,那麼就要全部重新分配,所以建議開啟

3. HW 和 LEO

LEO 是某個副本最後訊息的訊息位置(log-end-offset),HW 是已完成同步的位置。訊息在寫入 broker 時,且每個 broker 完成這條訊息的同步後,HW 才會變化。在這之前,消費者是消費不到這條訊息的,在同步完成之後,HW 更新之後,消費者才能消費到這條訊息,這樣的目的是防止訊息的丟失


Kafka 線上問題優化

1. 防止訊息丟失

生產者:

  1. 使用同步傳送
  2. 把 ack 設成 1 或者 all,並且設定同步的分割槽數 >= 2

消費者:

  1. 把自動提交改成手動提交

2. 防止重複消費

如果生產者傳送完訊息後,卻因為網路抖動,沒有收到 ack,但實際上 broker 已經收到了。此時生產者會進行重試,於是 broker 就會收到多條相同的訊息,而造成消費者的重複消費

解決方案:

  • 生產者關閉重試,但會造成丟訊息,不建議
  • 消費者解決非冪等性消費問題,所謂非冪等性,就是使用者對於同一操作發起的一次請求或者多次請求的結果是一致的,可以用唯一主鍵或分散式鎖來實現

3. 保證訊息的順序消費

生產者:使用同步傳送,ack 設定成非 0 的值

消費者:主題只能設定⼀個分割槽,消費組中只能有⼀個消費者

4. 解決訊息積壓

所謂訊息積壓,就是訊息的消費者的消費速度遠趕不上生產者的生產訊息的速度,導致 kafka 中有大量的資料沒有被消費。隨著沒有被消費的資料堆積越多,消費者定址的效能會越來越差,最後導致整個 kafka 對外提供的服務的效能很差,從而造成其他服務也訪問速度變慢,造成服務雪崩

解決方案:

  • 在這個消費者中,使用多執行緒,充分利用機器的效能消費訊息
  • 通過業務的架構設計,提升業務層面消費的效能
  • 建立多個消費組,多個消費者,部署到其他機器上,⼀起消費,提高消費者的消費速度
  • 建立⼀個消費者,該消費者在 kafka 另建⼀個主題,配上多個分割槽,多個分割槽再配上多個消費者。該消費者將 poll 下來的訊息,不進行消費,直接轉發到新建的主題上。此時,新的主題的多個分割槽的多個消費者就開始⼀起消費了

5. 實現延時佇列

假設一個應用場景:訂單建立後,超過 30 分鐘沒有支付,則需要取消訂單,這種場景可以通過延時佇列來實現,實現方案如下:

  • 在 Kafka 建立相應的主題,比如該主題的超時時間定為 30 分鐘
  • 消費者消費該主題的訊息(輪詢)
  • 消費者消費訊息時,判斷訊息的建立時間和當前時間是否超過 30 分鐘(前提是訂單沒有完成支付)
    • 超過:資料庫修改訂單狀態為已取消
    • 沒有超過:記錄當前訊息的 offset,並不再繼續消費之後的訊息。等待 1 分鐘後,再次從 Kafka 拉取該 offset 及之後的訊息,繼續判斷,以此反覆

相關文章