Kafka中消費者延遲處理訊息

banq發表於2024-05-25

Apache Kafka是一個事件流平臺,可大規模收集、處理、儲存和整合資料。有時,我們可能希望延遲處理來自 Kafka 的訊息。例如,客戶訂單處理系統旨在延遲 X 秒後處理訂單,並在此時間範圍內處理取消訂單。

在本文中,我們將探討使用Spring Kafka延遲處理 Kafka 訊息的消費者。儘管 Kafka 不提供對延遲訊息消費的開箱即用支援,但我們將研究另一種實現方案。

應用背景
Kafka 提供了多種錯誤重試方法。我們將使用此重試機制來延遲消費者對訊息的處理。因此,有必要了解Kafka 重試的工作原理。

讓我們考慮一個訂單處理應用程式,客戶可以在 UI 上下訂單。使用者可以在 10 秒內取消錯誤下達的訂單。這些訂單將傳送到 Kafka 主題web.orders,我們的應用程式會在那裡處理它們。

外部服務公開最新的訂單狀態(CREATED、ORDER_CONFIRMED、ORDER_PROCESSED、DELETED)。我們的應用程式需要接收訊息,等待 10 秒,並與外部服務核對訂單是否處於CONFIRMED狀態,即使用者在 10 秒內未取消訂單,以處理訂單。

為了測試,從web.orders.internal收到的內部訂單不應延遲。

讓我們新增一個簡單的訂單模型,其中orderGeneratedDateTime由生產者填充,orderProcessedTime由消費者在延遲一段時間後填充:

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Order {
    private UUID orderId;
    private LocalDateTime orderGeneratedDateTime;
    private LocalDateTime orderProcessedTime;
    private List<String> address;
    private double price;
}


 Kafka 監聽器和外部服務
接下來,我們將新增一個用於主題使用的監聽器和一個公開訂單狀態的服務。

讓我們新增一個KafkaListener,它讀取並處理來自主題web.orders和web.internal.orders 的訊息:

@RetryableTopic(attempts = <font>"1", include = KafkaBackoffException.class, dltStrategy = DltStrategy.NO_DLT)
@KafkaListener(topics = {
"web.orders", "web.internal.orders" }, groupId = "orders")
public void handleOrders(String order) throws JsonProcessingException {
    Order orderDetails = objectMapper.readValue(order, Order.class);
    OrderService.Status orderStatus = orderService.findStatusById(orderDetails.getOrderId());
    if (orderStatus.equals(OrderService.Status.ORDER_CONFIRMED)) {
        orderService.processOrder(orderDetails);
    }
}

包含KafkaBackoffException很重要,這樣偵聽器才允許重試。為簡單起見,我們假設外部OrderService始終將訂單狀態返回為CONFIRMED。此外,processOrder()方法將訂單處理時間設定為當前時間,並將訂單儲存到 HashMap中:

@Service
public class OrderService {
    HashMap<UUID, Order> orders = new HashMap<>();
    public Status findStatusById(UUID orderId) {
        return Status.ORDER_CONFIRMED;
    }
    public void processOrder(Order order) {
        order.setOrderProcessedTime(LocalDateTime.now());
        orders.put(order.getOrderId(), order);
    }
}

自定義延遲訊息監聽器
Spring-Kafka 推出了KafkaBackoffAwareMessageListenerAdapter,它擴充套件了AbstractAdaptableMessageListener並實現了AcknowledgingConsumerAwareMessageListener。此介面卡檢查 backoff dueTimestamp標頭,並透過呼叫KafkaConsumerBackoffManager來取消訊息或重試處理。

現在讓我們實現類似於KafkaBackoffAwareMessageListenerAdapter的DelayedMessageListenerAdapter。此介面卡應提供靈活性來配置每個主題的延遲以及預設延遲0秒:

public class DelayedMessageListenerAdapter<K, V> extends AbstractDelegatingMessageListenerAdapter<MessageListener<K, V>> 
  implements AcknowledgingConsumerAwareMessageListener<K, V> {
    <font>// Field declaration and constructor<i>
    public void setDelayForTopic(String topic, Duration delay) {
        Objects.requireNonNull(topic,
"Topic cannot be null");
        Objects.requireNonNull(delay,
"Delay cannot be null");
        this.logger.debug(() -> String.format(
"Setting delay %s for listener id %s", delay, this.listenerId));
        this.delaysPerTopic.put(topic, delay);
    }
    public void setDefaultDelay(Duration delay) {
        Objects.requireNonNull(delay,
"Delay cannot be null");
        this.logger.debug(() -> String.format(
"Setting delay %s for listener id %s", delay, this.listenerId));
        this.defaultDelay = delay;
    }
    @Override
    public void onMessage(ConsumerRecord<K, V> consumerRecord, Acknowledgment acknowledgment, Consumer<?, ?> consumer) throws KafkaBackoffException {
        this.kafkaConsumerBackoffManager.backOffIfNecessary(createContext(consumerRecord,
          consumerRecord.timestamp() + delaysPerTopic.getOrDefault(consumerRecord.topic(), this.defaultDelay)
          .toMillis(), consumer));
        invokeDelegateOnMessage(consumerRecord, acknowledgment, consumer);
    }
    private KafkaConsumerBackoffManager.Context createContext(ConsumerRecord<K, V> data, long nextExecutionTimestamp, Consumer<?, ?> consumer) {
        return this.kafkaConsumerBackoffManager.createContext(nextExecutionTimestamp, 
          this.listenerId, 
          new TopicPartition(data.topic(), data.partition()), consumer);
    }
}

對於每條傳入的訊息,此介面卡首先接收記錄並檢查主題的延遲設定。這將在配置中設定,如果未設定,則使用預設延遲。

KafkaConsumerBackoffManager#backOffIfNecessary方法的現有實現會檢查上下文記錄時間戳與當前時間戳之間的差異。如果差異為正,則表示無需消費,分割槽將暫停並引發 KafkaBackoffException 。否則,它會將記錄傳送到KafkaListener方法進行消費。

監聽器配置
ConcurrentKafkaListenerContainerFactory是 Spring Kafka 的預設實現,負責為KafkaListener構建容器。它允許我們配置併發KafkaListener例項的數量。每個容器都可以看作是一個邏輯執行緒池,其中每個執行緒負責監聽來自一個或多個 Kafka 主題的訊息。

DelayedMessageListenerAdapter需要透過宣告自定義ConcurrentKafkaListenerContainerFactory 來配置偵聽器。我們可以為特定主題(如web.orders)設定延遲,也可以為任何其他主題設定預設延遲0 :

@Bean
public ConcurrentKafkaListenerContainerFactory<Object, Object> kafkaListenerContainerFactory(ConsumerFactory<Object, Object> consumerFactory, 
  ListenerContainerRegistry registry, TaskScheduler scheduler) {
    ConcurrentKafkaListenerContainerFactory<Object, Object> factory = new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory);
    KafkaConsumerBackoffManager backOffManager = createBackOffManager(registry, scheduler);
    factory.getContainerProperties()
      .setAckMode(ContainerProperties.AckMode.RECORD);
    factory.setContainerCustomizer(container -> {
        DelayedMessageListenerAdapter<Object, Object> delayedAdapter = wrapWithDelayedMessageListenerAdapter(backOffManager, container);
        delayedAdapter.setDelayForTopic(<font>"web.orders", Duration.ofSeconds(10));
        delayedAdapter.setDefaultDelay(Duration.ZERO);
        container.setupMessageListener(delayedAdapter);
    });
    return factory;
}
@SuppressWarnings(
"unchecked")
private DelayedMessageListenerAdapter<Object, Object> wrapWithDelayedMessageListenerAdapter(KafkaConsumerBackoffManager backOffManager, 
  ConcurrentMessageListenerContainer<Object, Object> container) {
    return new DelayedMessageListenerAdapter<>((MessageListener<Object, Object>) container.getContainerProperties()
      .getMessageListener(), backOffManager, container.getListenerId());
}
private ContainerPartitionPausingBackOffManager createBackOffManager(ListenerContainerRegistry registry, TaskScheduler scheduler) {
    return new ContainerPartitionPausingBackOffManager(registry, 
      new ContainerPausingBackOffHandler(new ListenerContainerPauseService(registry, scheduler)));
}

值得注意的是,在RECORD級別設定確認模式對於確保消費者在處理過程中發生錯誤時重新傳遞訊息至關重要。

最後,我們需要定義一個TaskScheduler bean 來在延遲時間之後恢復暫停的分割槽,並且這個排程程式需要注入到 BackOffManager 中,它將被DelayedMessageListenerAdapter使用:

@Bean
public TaskScheduler taskScheduler() {
    return new ThreadPoolTaskScheduler();
}

測試
讓我們確保web.orders主題上的訂單在經過測試處理之前經歷 10 秒的延遲:

@Test
void givenKafkaBrokerExists_whenCreateOrderIsReceived_thenMessageShouldBeDelayed() throws Exception {
    <font>// Given<i>
    var orderId = UUID.randomUUID();
    Order order = Order.builder()
      .orderId(orderId)
      .price(1.0)
      .orderGeneratedDateTime(LocalDateTime.now())
      .address(List.of(
"41 Felix Avenue, Luton"))
      .build();
    String orderString = objectMapper.writeValueAsString(order);
    ProducerRecord<String, String> record = new ProducerRecord<>(
"web.orders", orderString);
    
   
// When<i>
    testKafkaProducer.send(record)
      .get();
    await().atMost(Duration.ofSeconds(1800))
      .until(() -> {
         
// then<i>
          Map<UUID, Order> orders = orderService.getOrders();
          return orders != null && orders.get(orderId) != null && Duration.between(orders.get(orderId)
              .getOrderGeneratedDateTime(), orders.get(orderId)
              .getOrderProcessedTime())
            .getSeconds() >= 10;
      });
}

接下來,我們將測試任何傳送到web.internal.orders的訂單是否遵循預設的0秒延遲:

@Test
void givenKafkaBrokerExists_whenCreateOrderIsReceivedForOtherTopics_thenMessageShouldNotBeDelayed() throws Exception {
    <font>// Given<i>
    var orderId = UUID.randomUUID();
    Order order = Order.builder()
      .orderId(orderId)
      .price(1.0)
      .orderGeneratedDateTime(LocalDateTime.now())
      .address(List.of(
"41 Felix Avenue, Luton"))
      .build();
    String orderString = objectMapper.writeValueAsString(order);
    ProducerRecord<String, String> record = new ProducerRecord<>(
"web.internal.orders", orderString);
    
   
// When<i>
    testKafkaProducer.send(record)
      .get();
    await().atMost(Duration.ofSeconds(1800))
      .until(() -> {
         
// Then<i>
          Map<UUID, Order> orders = orderService.getOrders();
          System.out.println(
"Time...." + Duration.between(orders.get(orderId)
              .getOrderGeneratedDateTime(), orders.get(orderId)
              .getOrderProcessedTime())
            .getSeconds());
          return orders != null && orders.get(orderId) != null && Duration.between(orders.get(orderId)
              .getOrderGeneratedDateTime(), orders.get(orderId)
              .getOrderProcessedTime())
            .getSeconds() <= 1;
      });
}

在本教程中,我們探討了 Kafka 消費者如何按固定間隔延遲處理訊息。

我們可以透過利用嵌入的訊息持續時間作為訊息的一部分來修改實現以動態設定處理延遲。

相關文章