阿里二面:Kafka中如何保證訊息的順序性?這周被問到兩次了

码农Academy發表於2024-03-20

引言

在現代分散式系統中,訊息順序消費扮演著至關重要的角色。特別是在涉及事務處理、日誌追蹤、狀態機更新等場景時,訊息的處理順序直接影響著系統的正確性和一致性。例如,金融交易系統中,賬戶間的轉賬操作必須嚴格按照發出請求的順序進行處理,否則可能導致資金不匹配;同樣,在構建實時流處理系統時,事件的時間戳順序可能關係到最終結果的準確性。

然而,在分散式環境中,保證訊息順序消費並非易事。訊息佇列中的訊息可能會因為網路延遲、系統故障、併發處理等多種因素導致亂序。此外,隨著系統規模的增長,如何在保證訊息順序的同時,有效提升訊息處理的吞吐量和響應時間,成為了一個頗具挑戰性的課題。

Apache Kafka作為一個高效能、分散式的訊息釋出訂閱系統,特別關注了訊息順序處理的需求。Kafka採用了分割槽(Partition)的設計,確保了單一分割槽內訊息的嚴格順序。每個分割槽內部的訊息是由一個生產者不斷追加的,因此消費者可以從分割槽的開始位置順序消費這些訊息。此外,Kafka允許使用者透過自定義分割槽策略,依據訊息鍵(Key)將具有順序要求的訊息路由到特定分割槽,從而在多分割槽環境下仍然能夠相對保證訊息順序消費。與此同時,Kafka也支援靈活的消費者組配置,允許透過控制消費者執行緒數和消費行為,以在保證順序的前提下儘可能提高系統處理效率。

Kafka中的訊息順序保證原理

在Apache Kafka中,訊息順序性的保障主要依託於其獨特的分割槽(Partition)機制以及訊息鍵(Key)的使用。

1. 分割槽(Partition)的作用與訊息順序性的內在關聯

Kafka的主題(Topic)可以被劃分為多個分割槽,每個分割槽都是一個獨立的順序日誌儲存。如下圖所示,每個分割槽內部的訊息按照其生成的先後順序排列,形成一個有序連結串列結構。

image.png

當生產者向主題傳送訊息時,可以選擇指定訊息的鍵(Key)。若未指定或Key為空,訊息將在各個分割槽間平均分佈;若指定了Key,Kafka會根據Key和分割槽數計算出一個雜湊值,確保具有相同Key的訊息會被髮送到同一個分割槽,從而確保這些訊息在分割槽內部是有序的。

2. 單分割槽內的訊息順序性保證

在單個Kafka分區中,訊息的順序性得到了嚴格的保證。新產生的訊息總是附加到分割槽日誌的末端,消費者按照訊息在分割槽中的物理順序進行消費。如下圖所示,每個分割槽內部的訊息具有明確的偏移量(Offset),消費者按照遞增的Offset順序消費訊息。

image.png

3. 利用鍵(Key)實現訊息到特定分割槽的路由策略

透過為訊息設定Key,Kafka可以確保具有相同Key的訊息被路由到同一個分割槽,這就為實現訊息順序消費提供了基礎。以下是一個簡單的鍵路由策略的虛擬碼表示:

public class KeyBasedPartitioner implements Partitioner {

    private AtomicInteger counter = new AtomicInteger(0); // 示例中使用一個原子整數作為輪詢計數器

    @Override
    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();

        // 假設key是String型別,可以根據業務需求轉換key型別並計算分割槽索引
        if (key instanceof String) {
            int partition = Math.abs(key.hashCode() % numPartitions); // 簡單的雜湊取模分割槽策略
            // 或者實現更復雜的邏輯,比如根據key的某些特性路由到固定分割槽
            return partition;
        } else {
            // 如果沒有key,或者key不是預期型別,可以採用預設的輪詢方式
            return counter.getAndIncrement() % numPartitions;
        }
    }

    @Override
    public void close() {}

    @Override
    public void configure(Map<String, ?> configs) {}
}

透過上述策略,我們可以根據業務需求將相關聯的訊息路由到特定分割槽,從而在該分割槽範圍內保證訊息的順序消費。而在全域性層面,需要業務邏輯本身支援訊息的區域性順序性,並透過合理設定分割槽數和消費者數量,兼顧訊息順序與處理效率之間的平衡。

Kafka原生保證訊息順序消費的實現

Apache Kafka中,原生實現訊息順序消費主要圍繞分割槽(Partition)和消費者組(Consumer Group)機制展開。以下是如何透過Kafka原生功能確保訊息順序消費的具體步驟和示例:

生產者側:首先,確保訊息按照需要的順序傳送到Kafka。若需要全域性順序,所有的訊息應被髮送到同一個分割槽。為此,可以透過設定訊息鍵(key)並將所有訊息對映到同一個確定的分割槽上。例如,可以自定義分割槽器,或者依賴Kafka預設的分割槽器,後者會基於訊息鍵的雜湊值均勻分佈到各個分割槽,但具有相同鍵的訊息會被路由到同一分割槽。

// 使用預設分割槽器,確保相同key的訊息進入同一分割槽
Properties props = new Properties();
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
// 不自定義分割槽器,則使用預設分割槽器,根據key的雜湊值決定分割槽
KafkaProducer<String, OrderMsg> producer = new KafkaProducer<>(props);

// 傳送訊息時設定key,確保相同key的訊息進入同一分割槽
producer.send(new ProducerRecord<>("toc-topic", "toc-key", orderMsg));

消費者側
消費者組:在消費者組層面,確保每個分割槽僅被組內一個消費者例項消費,這樣才能保證該分割槽內的訊息順序消費。可透過設定消費者組內消費者的併發度為分割槽數或小於分割槽數來達到這個目的。

// 設定消費者組並控制併發度等於分割槽數
props.put(ConsumerConfig.GROUP_ID_CONFIG, "toc-consumer-group");
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "1"); // 一次只消費一條,增強順序消費效果
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); // 從頭開始消費

KafkaConsumer<String, OrderMsg> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("toc-topic"));

while (true) {
    ConsumerRecords<String, OrderMsg> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, OrderMsg> record : records) {
        // 按照消費到的訊息順序處理
        processMessageInOrder(record.value());
    }
    // 控制消費速率並提交offset
    consumer.commitAsync();
}

只需保證具有相同鍵的訊息順序,生產者可以透過設定訊息鍵確保這些訊息被路由到同一分割槽。消費者只需在自己負責的分割槽上按照接收到的順序處理訊息即可。

透過以上方式,Kafka原生支援了訊息的區域性順序消費(單個分割槽內),以及在特定條件下(如透過訊息鍵路由)的全域性順序消費。然而,全域性順序消費可能犧牲系統的擴充套件性和並行處理能力,因此在實際應用中需要根據業務需求和效能指標做權衡和最佳化。

而單分割槽確實能保證訊息順序消費,但是在併發高的業務場景中,處理訊息的效率很地下,那麼我們如何在保證順序消費的前提下又要提高處理效率呢?

多分割槽下的順序消費策略

多分割槽順序消費

在多分割槽場景下,實現全域性順序消費的一種策略是透過定製分割槽策略,確保具有順序要求的訊息被路由到特定的分割槽。這種方式適用於那些需要根據業務標識(如訂單ID、使用者ID等)保持訊息順序的場景。

藉助自定義分割槽器,可以確保具有相同業務標識的訊息被髮送到同一分割槽,從而在單個分割槽內部保持訊息順序。

public class OrderIdPartitioner implements Partitioner {

    @Override
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        // 假設key是我們需要排序的訂單ID
        if (key instanceof String) {
            int numPartitions = cluster.partitionCountForTopic(topic);
            String orderId = (String) key;
            // 這裡只是簡單示例,實際專案中應根據業務邏輯制定合適雜湊演算法
            int partition = Math.abs(orderId.hashCode()) % numPartitions;
            return partition;
        } else {
            // 若key非字串型別,可以採用預設分割槽策略
			return DEFAULT_PARTITION;
        }
    }
}

然後我們註冊並使用自定義分割槽器,確保訊息按照業務標識路由到正確的分割槽。

@Configuration
public class KafkaProducerConfig {

    @Bean
    public KafkaTemplate<String, OrderMsg> kafkaTemplate() {
        Map<String, Object> configProps = new HashMap<>();
        // 其他配置...
        configProps.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, OrderIdPartitioner.class);

        DefaultKafkaProducerFactory<String, OrderMsg> producerFactory = new DefaultKafkaProducerFactory<>(configProps);
        return new KafkaTemplate<>(producerFactory);
    }
}

非同步處理與佇列緩衝

為了在多分割槽環境中既能保證訊息順序消費,又能提高處理效率,在多分割槽順序消費的基礎上可以引入記憶體佇列(如Java中的BlockingQueue)作為緩衝區,並結合多執行緒非同步處理,提高消費端消費訊息的能力。

image.png

消費者接收到訊息後,將訊息放入記憶體佇列中:

BlockingQueue<ConsumerRecord<String, OrderMsg>> messageQueue = new LinkedBlockingQueue<>();

@KafkaListener(topics = "your-topic")
public void consumeMessage(ConsumerRecord<String, OrderMsg> record) {
    try {
        messageQueue.put(record);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        // 錯誤處理...
    }
}

然後,使用執行緒池消費佇列中的訊息,確保訊息按照放入佇列的順序處理:

ExecutorService executorService = Executors.newFixedThreadPool(NUM_THREADS);

while (true) {
    ConsumerRecord<String, OrderMsg> record;
    try {
        record = messageQueue.take();
        executorService.submit(() -> processMessage(record));
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        // 錯誤處理...
    }
}

private void processMessage(ConsumerRecord<String, OrderMsg> record) {
    // 按照順序處理訊息
}

// 在應用關閉時,記得關閉執行緒池
executorService.shutdown();

這種方式,即使在多分割槽的情況下,系統依然能夠保證具有相同業務標識的訊息順序消費,同時透過非同步處理和佇列緩衝提升了整體的處理效率。然而,這也意味著需要處理好佇列溢位、執行緒同步等問題,以確保系統的穩定性和可靠性。

關於執行緒池的原理以及使用,請移步:

總結

Apache Kafka在訊息順序消費方面的設計體現了其高度的靈活性和可擴充套件性。透過巧妙利用分割槽機制,Kafka能夠在單個分割槽內部提供嚴格的順序保證,這為需要訊息順序處理的業務場景提供了堅實的基礎。透過自定義分割槽策略,尤其是利用訊息鍵(Key)實現訊息到特定分割槽的路由,Kafka能夠確保具有相同鍵值的訊息保持順序,這對於很多業務邏輯而言至關重要。

與此同時,Kafka支援消費者組概念,使得一組消費者可以訂閱同一個主題,每個分割槽在同一時刻僅由消費者組中的一個消費者例項消費,從而保證了分割槽內部訊息的順序消費。透過結合微批處理、批次提交等最佳化實踐,Kafka能夠進一步提高訊息處理效率,同時兼顧系統效能與訊息順序性。

然而,在實際應用中,尤其是在多分割槽場景下,完全保證全域性訊息順序可能會犧牲一定的系統擴充套件性和處理效能。因此,在設計和實施訊息順序消費方案時,需要綜合考慮以下幾個方面:

  1. 系統效能:透過合理的分割槽策略和消費者併發度設定,最佳化資源利用率,提升系統吞吐量。

  2. 訊息順序性:針對不同業務需求,靈活運用分割槽和鍵值策略,保證關鍵業務流程的訊息順序。

  3. 系統可用性:設計有效的錯誤處理與重試機制,確保在發生故障時仍能保持訊息的可靠傳遞,同時不影響正常訊息的順序消費。

Apache Kafka在訊息順序消費領域展現了強大的靈活性和適應性,允許我們在保障訊息順序性的同時,最佳化系統效能和可用性。在面對實際業務需求時,務必根據具體情況權衡利弊,制定最適合的解決方案,以期在保障業務流程正確執行的同時,實現系統的高效穩定執行。

本文已收錄於我的個人部落格:碼農Academy的部落格,專注分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中介軟體、架構設計、面試題、程式設計師攻略等

相關文章