Java中的幾種Kafka客戶端比較介紹

banq發表於2022-01-23

在這篇部落格中,我介紹了在Java中定義Kafka消費者的各種方法。Spring、Micronaut、Vert.x和Akka Streams在引擎蓋下使用kafka-clients庫,並提供完整的功能集來消費Kafka訊息。

Kafka 是一個著名的事件流平臺。我們在很多專案中使用它。沒什麼不尋常的——工具很棒。各種框架和庫提供與 Kafka 的整合。在這篇文章中,我想介紹其中一些 Java 語言,看看我們如何建立一個客戶例項,用來讀取Kafka訊息:

 

連線到 Kafka 的第一種方法是使用kafka-clients 庫中的KafkaConsumer類。其他庫或框架整合通常使用該庫。在本節中,我將重點介紹直接使用它。雖然它非常簡單,但我們需要付出一些努力來提高效率。

首先,我們希望我們的消費者持續工作。因此,我們將在單獨的執行緒中執行它,我們需要自己管理它。此外,我們需要將輪詢放入不定式迴圈中。

另一件事是關閉消費者,這可能很棘手。我們可以關閉執行緒並使用超時來關閉套接字和網路連線。然而,採用這種方法,我們錯過了兩個要點:

  1. 顯式關閉消費者會立即觸發重新平衡,因為組協調員沒有發現消費者因丟失心跳而離開。
  2. 該操作也會完成待處理的偏移量提交。因此,在再次執行消費者之後,我們不會兩次消費某些訊息。

接下來,如果我們想並行消費訊息,我們需要提供一個自定義的解決方案來在同一個消費者組中執行特定數量的消費者。然而,每個消費者都需要兩個執行緒——一個用於輪詢,另一個用於心跳。

在訊息的批量消費方面,我們在輪詢一個佇列後得到一個記錄集合(可能為空)。因此,我們不必提供任何特定的配置或機制。

 

當我們想流式傳輸接收到的資料時,我們可以使用 JDK 的 Stream API。但是如果我們想並行使用它們,情況就會變得複雜。更復雜的程式碼變得更容易出錯。

預設情況下,消費者會自動提交偏移量。但是,我們可以更改這一點並手動完成工作。API 為我們提供了幾種同步或非同步呼叫操作的方法。此外,我們可以提交從佇列上最後一次輪詢收到的所有訊息的所有偏移量,或者提供特定的主題分割槽值。

使用普通的 Kafka 消費者,我們處理ConsumerRecord包含訊息本身及其後設資料的例項。它本身並不是一個缺點。但是,如果我們想解析它,我們需要提供我們的機制。

所以,總的來說,我會謹慎使用這種方法,而是考慮其他可用的可能性。那麼,讓我們看看如何在一些框架或工具包中使用 Kafka。

 

Spring Boot 

當您在專案中使用 Spring Boot 時,您可以使用 Spring for Kafka整合。它提供了一種方便的監聽器機制來實現對 Kafka 訊息的消費。

我們可以通過兩種方式消費訊息:

  • 使用訊息偵聽器的容器,
  • 或通過提供帶有@KafkaListener註釋的類。

當我們想使用訊息偵聽器方法時,我們需要提供兩種型別的容器之一來執行我們的偵聽器:

  • KafkaMessageListenerContainer— 在單個執行緒上為容器配置中提供的所有主題提供訊息消費,
  • ConcurrentMessageListenerContainerKafkaMessageeListenerContainer— 允許在多執行緒環境中使用訊息,為每個執行緒提供一個訊息。

容器具有豐富的 API,允許我們設定各種配置引數(如執行緒、批處理、確認、錯誤處理程式等)。重要的是要設定一個監聽器類——一個訊息驅動的 POJO。MessageListener它是orBatchMessageListener介面的一個例項。兩者都是基本的,允許我們使用型別化的 ConsumerRecord 例項。Spring 還提供了其他更復雜的介面

然而,在 Spring 中使用 Kafka 訊息最直接的方法是使用@KafkaListener註解實現一個 bean。處理接收到的訊息的方法的簽名可能會有所不同。您將使用的輸入引數取決於您的需要,並且有很多可能性(有關詳細資訊,請檢視註釋 javadocs)。在啟動時,Spring 會查詢註解使用情況(帶有註解的類必須是 Spring 元件)並建立執行在偵聽器中定義的邏輯的 Kafka 消費者。

@Component
public class KafkaListenerConsumer {
    @KafkaListener(topics = "${spring.kafka.consumer.topic}", groupId = "${spring.kafka.consumer.group-id}")
    public void processMessage(List<Message<String>> content) {
        // processing logic comes here
    }
}

預設情況下,@KafkaListener在單個執行緒中執行——我們不會並行使用來自主題分割槽的訊息。但是,我們可以通過兩種方式改變這種行為。

第一個是定義concurrency註解的引數,我們可以在其中設定給定偵聽器正在使用的執行緒數。

@Component
public class KafkaListenerConsumer {
    @KafkaListener(
            concurrency = "2",
            topics = "${spring.kafka.consumer.topic}", groupId = "${spring.kafka.consumer.group-id}")
    public void processMessage(List<Message<String>> content) {
        // processing logic comes here
    }
}

第二個選項是為containerFactory引數提供一個值。它是生產用於執行偵聽器邏輯的容器的容器工廠 bean 的名稱。當工廠不是單執行緒時(併發設定為大於 1 的值),框架將容器執行緒分配給分割槽。

 @Bean
    ConcurrentKafkaListenerContainerFactory<String, String> multiThreadedListenerContainerFactory() {
        ConcurrentKafkaListenerContainerFactory<String, String> factory =
                new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory());
        factory.setConcurrency(3);
        return factory;
    }

在這兩種情況下,如果我們的執行緒數多於分割槽數,則有些執行緒仍處於空閒狀態。

這還沒有結束——我們甚至可以使用topicPartitions引數為特定分割槽指定偵聽器方法。有了這樣的解決方案,Spring 會自動在單獨的執行緒中執行每一個。

@Component
public class PartitionedKafkaListenerConsumer {

    @KafkaListener(
            clientIdPrefix = "part0",
            topics = "${spring.kafka.consumer.topic}",
            groupId = "${spring.kafka.consumer.group-id}",
            topicPartitions = {
                    @TopicPartition(topic = "${spring.kafka.consumer.topic}", partitions = {"0"})
            })
    public void partition0(ConsumerRecord<String, String> content) {
        // processing logic comes here
    }

    @KafkaListener(
            clientIdPrefix = "part1",
            topics = "${spring.kafka.consumer.topic}", groupId = "${spring.kafka.consumer.group-id}",
            topicPartitions = {
                    @TopicPartition(topic = "${spring.kafka.consumer.topic}", partitions = {"1"})
            })
    public void partition1(ConsumerRecord<String, String> content) {
        // processing logic comes here
    }
}

Spring for Kafka 也提供了批量消費訊息的功能。當然,我們有不止一種選擇。

第一個是容器工廠中批處理的配置開關。啟用後,我們可以提供一個接受訊息列表的偵聽器。重要的是——我們需要使用批處理容器作為KafkaListener 註解中的containerFactory引數值。

一個選項使用帶有字首的訊息偵聽器介面。Batch他們接受消費者記錄列表而不是單個記錄。

當涉及到手動提交訊息偏移量時,我們有同樣豐富的選擇。首先,我們有原始的 Kafka 設定,即 `enable.auto.commit`。當它為真時,Kafka 根據其配置提交所有訊息。否則,將根據配置中設定的確認模式的值來選擇負責提交的實體。對於 ack 設定為MANUALor MANUAL_IMMEDIATE,由開發人員提交偏移量。對於所有其他值,由容器來執行它。此外,我們可以指定提交操作的同步性。

當我們使用手動提交時,我們可以Acknowledgment在框架的一些訊息監聽器中使用該類。該介面提供了呼叫已處理訊息的提交操作或丟棄上次輪詢的剩餘記錄的方法。

Spring for Kafka 讓我印象深刻的是設定工作 Kafka 消費者的方法的數量。我們可以通過多種方式做到這一點,這很好,因為框架的彈性。但是,當我們迷失在各種可用選項中時,它也可能是有害的。

 

Micronaut

與 Spring 一樣,Micronaut 框架與 Kafka 進行了專門的整合,並且也適用於訊息驅動的 POJO。消費者的配置甚至類似。

我們從@KafkaListener類級別的註釋開始。這是我們定義一組消費者的地方。這樣的組是基於為具有給定 groupId 的特定組提供預設值或值的配置檔案內容配置的。我們可以使用註釋引數覆蓋這些值。

偵聽器類的每個公共或包私有方法,用@Topic(提供強制性主題名稱/模式)註釋,成為在後臺執行的 Kafka 消費者。我們也可以將註釋放在類級別,但所有公共或私有包方法都成為 Kafka 消費者。所以我們需要小心這個。

@KafkaListener(groupId = "micronaut-group", clientId = "${kafka.consumers.micronaut-group.client-id}")
public class MicronautListener {
    @Topic("${kafka.consumers.micronaut-group.topic}")
    void receive(@KafkaKey String key, String value) {
        // processing logic comes here
    }
}

要設定併發訊息處理,我們可以將執行緒數定義為@KafkaListener註釋引數。如果我們提供的執行緒數少於分割槽數,一些消費者將處理來自兩個或更多分割槽的訊息。另一方面,如果我們設定更多它們,一些會保持閒置,什麼也不做。這與 Spring 整合中的行為相同。

@KafkaListener(groupId = "micronaut-group", clientId = "${kafka.consumers.micronaut-group.client-id}", threads = 5)
public class MultithreadedMicronautListener {
    @Topic("${kafka.consumers.micronaut-group.topic}")
    void receive(@KafkaKey String key, String value, int partition) {
        switch (partition) {
            case 0:
                // processing logic comes here
                break;
            case 1:
                // processing logic comes here
                break;
            case 2:
                // processing logic comes here
                break;
            default:
                log.error("Message (key {}, value {}) from unexpected partition ({}) received.", key, value, partition);
        }
    }
}

同樣,我們可以使用註釋上的batch引數啟用批處理。

然後我們可以在消費者方法中消費一個記錄列表(或領域類)。

@KafkaListener(groupId = "micronaut-group", clientId = "${kafka.consumers.micronaut-group.client-id}", batch = true)
public class BatchedMicronautListener {
    @Topic("${kafka.consumers.micronaut-group.topic}")
    void receive(List<ConsumerRecord<String, String>> records) {
        log.info("Batch received: {}", records.size());
        records.forEach(rec -> 
          // processing logic comes here
        );
    }
}

偏移提交的 Micronaut 管理提供了一些選項。我們通過 `OffsetStrategy` 列舉定義使用哪一個。您可以在框架文件中找到對它的出色描述。這是處理記錄後使用手動提交的示例:

Micronaut 中 Kafka 類的配置比 Spring 中的配置更加簡潔。在維護程式碼方面,更改或更新的地方更少了。但是,與 Spring 不同,我們不能以程式設計方式定義消費者,而無需使用註釋。

 

Akka Stream

下一個客戶端是帶有Alpakka 聯結器[url=https://doc.akka.io/docs/akka/current/stream/index.html]的 Akka Streams[/url]庫。

此設定提供了一種使用來自 Kafka 的訊息的反應方式。它在底層使用了 Akka Actors 框架。

在這裡,我們將 Kafka 消費者作為事件源的例項。在提供的資料型別、提供的後設資料和分割槽資訊以及處理偏移提交的方式方面有所不同。

讓我們從Consumer.plainSource. ConsumerRecord它在單個執行緒中為整個主題發出訊息,保留給定分割槽的訊息消費順序。根據 Kafka 消費者配置,流可以自動提交已處理的記錄。

Consumer
  .plainSource(consumerSettings, Subscriptions.topics(topicName))
  .map(consumerRecord -> {
      // processing logic comes here
      return consumerRecord;
  })
  .runWith(Sink.ignore(), materializer)
  .toCompletableFuture()
  .handle(AppSupport.doneHandler())
  .join();

我們也可以選擇手動提交訊息。如果是這樣,我們需要使用提供消費者記錄和有關當前偏移量資訊的可提交源之一。在處理完一條訊息後,我們可以利用額外的資料來呼叫一個Committer例項來進行手動提交。

Consumer
  .committableSource(consumerSettings, Subscriptions.topics(topicName))
  .map(committableMessage -> {
      // processing logic comes here
      return committableMessage;
  })
  .mapAsync(maxParallelism, msg -> CompletableFuture.completedFuture(msg.committableOffset()))
  .runWith(Committer.sink(CommitterSettings.create(committerSettings)), materializer)
  .toCompletableFuture()
  .handle(AppSupport.doneHandler())
  .join();

關於並行處理事件,該庫也提供了出色的工具。最簡單的解決方案是使用普通分割槽源,它發出記錄源以及主題分割槽資訊。當我們使用來自子源的訊息時,該操作在每個分割槽的單獨執行緒上執行。但是,我們可以使用分割槽資訊以自定義方式分配執行緒分配(我們需要使用flatMapMerge和groupBy運算子)。

Consumer
  .plainPartitionedSource(consumerSettings, Subscriptions.topics(topicName))
  .mapAsync(maxPartitions, pair -> {
      Source<ConsumerRecord<String, String>, NotUsed> source = pair.second();
      return source
              .map(record -> {
                  // processing logic comes here
                  return record;
              })
              .runWith(Sink.ignore(), materializer);
  })
  .runWith(Sink.ignore(), materializer)
  .toCompletableFuture()
  .handle(AppSupport.doneHandler())
  .join();

並行使用資料時最重要的是結果的順序。我們有兩個選擇。第一個是使用mapAsync運算子。它使用引數中指定大小的執行緒池。該階段確保下游發出訊息的順序,但不保證處理順序。另一方面——假設發出訊息的順序對我們來說並不重要。在這種情況下,我們可以使用mapAsyncUnordered操作符——它會在處理完成後向下遊傳遞訊息,而不管接收順序如何。

批處理也可用。我們可以通過使用類似groupedor的批處理操作符batch來實現它。在這種情況下,我們需要使用一個CommittableOffsetBatch例項並使用批處理中最後處理的訊息的偏移量對其進行更新。然後,我們需要在流程的下一步中呼叫 commit。

Akka Streams 對 Kafka 的支援令人驚歎。它將訊息作為資料流來消費,這是最適合 Kafka 消費者的方式。由於訊息源的粒度,我們可以輕鬆地為我們的案例選擇最合適的一個。通過利用 Streams 的強大 API,我們可以非常快速地獲得批處理或背壓等功能。使用 Akka Streams 時對我來說最大的缺點是操作符的豐富性。您可能需要一些時間來熟悉它。但是,當您檢視聯結器原始碼時,您會發現許多如何將 Streams 與 Kafka 一起使用的示例。

 

Vertx

介紹的最後一種實現消費來自 Kafka 的訊息的方法是使用Vert.x 工具包。該方法類似於 Akka Streams 的方法——它適用於 Verticle,一種輕量級 Actor 的形式。verticles 使用事件匯流排在彼此之間傳遞訊息。它們可以在事件迴圈和工作執行緒上執行。

核心庫提供基本功能(如在 Akka Streams 中),我們需要使用外部元件來連線 Kafka,即vertx-kafka-client。雖然一般假設與 Akka Streams 中的假設非常相似,但使用程式碼看起來不同。

Vert.x 應用程式使用事件迴圈和工作執行緒。前者將事件傳遞到目標頂點,並且可以執行快速、非阻塞的程式碼。後者的目的是完成繁重的工作,例如 I/O 或昂貴的計算。因此,我們應該考慮使用工作執行緒來消費 Kafka 訊息,這樣迴圈不會被阻塞,應用程式執行順暢。示例程式碼包含作為工作人員執行的 Kafka verticles。

對,那麼我們如何建立 Kafka 訊息的消費者呢?Vert.x 客戶端為此提供了一個類 — KafkaConsumer. 它提供了幾種工廠方法,用於根據提供的配置建立例項。

有了消費者,我們需要在啟動頂點之前訂閱一些 Kafka 主題。我們可以從 subscribe 方法的幾個變體中進行選擇。呼叫其中之一使頂點能夠從單個或多個主題中讀取資料。下一步是註冊處理函式,使用接收到的訊息。所有這一切,我們都是通過在消費者上使用流暢的 API 來完成的。

這是為一個或多個主題建立普通消費者的方式,沒有分割槽拆分到不同的執行緒。正如您在示例專案中看到的那樣,我已經封裝了 verticle 的邏輯並將其部署為 worker verticle。使用此解決方案,所有訊息都將由同一個工作執行緒使用。

class KafkaVerticle extends AbstractVerticle {

    // initialization

    @Override
    public void start() {
        KafkaConsumer.create(vertx, kafkaConfig)
                .subscribe(topic)
                .handler(record -> 
                    // processing logic comes here
                )
                .endHandler(v -> log.info("End of data. Topic: {}", this.topic))
                .exceptionHandler(e -> log.error("Single Kafka consumer error", e));
    }
}

根據 Kafka 配置,消費者可能會自動提交偏移量。但是手動觸發動作呢?Vert.x 消費者提供了完成這項工作的提交方法。我們可以將它們稱為單個訊息或特定主題和分割槽的一組偏移量。

在為給定主題的所有分割槽建立消費者時,我們需要手動設定Vertx。

首先,我們需要知道我們想要處理哪些主題的多少個分割槽。我們可以使用KafkaAdminClient來描述我們感興趣的話題。接下來,我們需要為每個主題-分割槽對建立一個包含 Kafka 消費者的專用執行緒。線上程內部,我們將消費者分配給所需的主題分割槽資料並指定處理程式,就像在“普通”消費者執行緒中一樣。

根據Kafka的配置,消費者可能會自動提交offsets。但是手動觸發動作呢?Vert.x消費者提供了做這個工作的提交方法。我們可以為單個訊息或者為特定主題和分割槽的一堆偏移量呼叫它們。

當涉及到為某一主題的所有分割槽建立消費者時。

我們需要手動設定Vertx。首先,我們需要知道我們想處理哪些主題的多少個分割槽。我們可以呼叫KafkaAdminClient來描述我們感興趣的主題。接下來,我們需要為每個主題分割槽對建立一個專用Vertx執行緒,包含Kafka消費者。在頂點內,我們將消費者分配給所需的主題分割槽資料,並像 "普通 "消費者頂點那樣指定處理程式。

// create required number of vertices
IntStream.range(0, numberOfPartitions)
    .forEach(partition -> {
        vertx.deployVerticle(
                () -> KafkaPartitionedVerticle.create(topic, partition, kafkaConfig),
                deploymentOptions,
                async -> log.info("Partitioned Kafka consumer deployed. DeploymentId: {}", async.result())
        );
    });


// inside KafkaPartitionedVerticle
class KafkaPartitionedVerticle extends AbstractVerticle {

    // initialization

    @Override
    public void start() {
        KafkaConsumer.create(vertx, kafkaConfig)
                .assign(new TopicPartition(topic, partition), AsyncResult::result)
                .handler(record -> 
                    // processing logic comes here
                )
                .endHandler(v -> log.info("End of data. Topic: {}, partition: {}", this.topic, this.partition))
                .exceptionHandler(e -> log.error("Partitioned Kafka consumer error", e));
    }
}

正如我上面提到的,每個Kafka消費者都使用一個專門的處理器來處理收到的訊息。根據我們的需要,我們可以一條一條地消費記錄,也可以分批消費。在前一種情況下,我們使用handler方法定義一個函式,而對於後者,我們使用 batchHandler方法。Kafka元件分別以KafkaConsumerRecord或KafkaConsumerRecords的形式提供記錄。在這兩種情況下,在引擎蓋下,我們可以找到一個好的舊ConsumerRecord例項。

 

總結

哪一個更優越呢?我想說沒有,除了一種情況。

如果你有一個專案考慮直接使用kafka-clients庫,我會建議你不要這樣做。這個庫是一種連線到Kafka的驅動。在專案中使用它,我們需要接受的是,在某種程度上,我們將重新發明已經在其他工具中實現的輪子。

如果你的專案中的框架不限制你,我建議使用Akka Streams。

否則,尋找已經存在的整合。如果你的服務是在Spring框架內開發的,那麼應用Akka Streams是沒有意義的。

或者在Vert.x應用程式中使用Micronaut?

相關文章