Apache Kafka和Spring Boot的容錯和可靠訊息傳遞 – Arnold Galovics

banq發表於2020-06-09

在過去的幾年中,Kafka已經開始大幅增加其市場份額。除了微服務和訊息傳遞之外,還有一種已經開始流行的架構模式:事件溯源。
Kafka提供了架構模式所需的屬性,因此非常適合事件採購。事件源中的關鍵概念之一是儲存不可變的事件序列(將其視為稽核日誌)以捕獲系統狀態。這樣就可以在任何給定時間透過重播事件直到特定點來重新建立系統狀態。當然,作為每種模式,它都有缺點。它引入了許多必須解決的難題,而在編寫簡單資料庫的標準應用程式時,您可能會忘記這些問題。
希望實現元件之間基於容錯和可靠的Kafka訊息傳遞的通訊,不可靠情況主要有兩個:
  • 正在處理訊息時崩潰的服務
  • 由於外部系統(例如資料庫)不可用,無法處理該訊息

這兩種情況可以使用Kafka 自動提交功能附帶的最多一次交付保證實現,但是可能會造成重複訊息,這對於重視事件訊息序列的事件溯源是不方便的。
這裡介紹遠離自動提交模式的一般原理,並保證至少一次傳遞訊息的手工方式:
這個想法很簡單。開始消耗訊息時,不要立即提交讀取的偏移量,而要等到處理完成後再手動提交偏移量。採用這種方法可以確保僅在透過應用程式邏輯處理訊息後才認為訊息已處理。這解決了發生崩潰時丟失應用程式中執行中訊息的問題。
但是,要考慮的一件事。當您崩潰後重新啟動應用程式時。它必須能夠重新處理該訊息。換句話說,訊息處理應該是冪等的。想象一下,當使用者向系統註冊時,以及將新行插入資料庫後,應用程式發生故障失敗,因此不會提交偏移量。然後重新啟動應用,並選擇相同的訊息,然後將具有相同資料的行再次成功插入資料庫。
從錯誤中恢復的解決方案:
  1. 一種方法當使用者在處理過程中崩潰時重新傳遞訊息;
  2. 另一種方法是在發生可恢復的錯誤(例如,資料庫在短時間內不可用)的情況下,實現一種重試訊息處理的方法。

在本文中,我想展示使用Spring Boot解決這兩個問題的示例解決方案。
本文原始碼在GitHub上

首先,我們需要建立基礎設施,即Kafka和Zookeeper。我在Windows的Docker上使用以下docker-compose.yml:

version: '3'
services:
  zookeeper:
    image: wurstmeister/zookeeper
    ports:
      - "2181:2181"
    hostname: zookeeper
  kafka:
    image: wurstmeister/kafka
    command: [start-kafka.sh]
    ports:
      - "9092:9092"
    hostname: kafka
    environment:
      KAFKA_CREATE_TOPICS: "normal-topic:1:1"
      KAFKA_LISTENERS: PLAINTEXT://:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - "zookeeper"

現在,我們將使用一個主題,具有單個複製和單個分割槽的normal-topic。
執行docker-compose up完之後,我們就可以使用基礎架構了。
現在,讓我們在Spring Boot應用程式中建立使用者。它會非常簡單,相信我。我們需要一門新課,我叫它NormalTopicConsumer。現在唯一要做的是登出已讀取的訊息。這裡還有一件事,我正在使用Lombok,因為我懶得寫記錄器。

@Component
@Slf4j
public class NormalTopicConsumer {
    @KafkaListener(id = "normal-topic-consumer", groupId = "normal-topic-group", topics = "normal-topic")
    public void consume(ConsumerRecord<?, ?> consumerRecord) {
        String json = consumerRecord.value().toString();
        log.info("Consuming normal message {}", json);
    }
}

application.properties:

spring.kafka.consumer.bootstrap-servers=localhost:9092


第一步,我們需要找出容器ID是什麼,您可以使用docker ps它。然後exec放入容器:

$ docker exec -it a0a7 bash

然後進入生產者模式併傳送示例JSON訊息:

$ sh /opt/kafka/bin/kafka-console-producer.sh --broker-list localhost:9092 --topic normal-topic>{"data":"test"}

在應用程式方面,我們應該看到以下日誌:

[-consumer-0-C-1] c.a.b.n.NormalTopicConsumer : Consuming normal message {"data":"test"}

太好了,讓我們繼續。

實施手動提交
對於手動提交模式,實現非常簡單。需要更改一些配置以禁用自動提交模式並啟用手動配置。然後對程式碼進行一些調整。
application.properties:

spring.kafka.consumer.bootstrap-servers=localhost:9092
spring.kafka.consumer.enable-auto-commit=false
spring.kafka.listener.ack-mode=MANUAL_IMMEDIATE


此處提供不同型別的確認模式。其中有2個相關。MANUAL和MANUAL_IMMEDIATE。差異在提到的頁面上有所描述。這篇文章我將繼續使用MANUAL_IMMEDIATE。
程式碼更改非常簡單。我們唯一需要做的就是擴充套件使用者方法的引數列表,並在其中新增一個Acknowledgment引數。Spring將自動填充它。該物件提供一個確認方法,該方法手動提交讀取的偏移量。該MANUAL_IMMEDIATEACK模式設定的方式,只要消費者acknowledge方法被呼叫時,它會立即告訴經紀人,消費者已成功處理訊息。

@Component
@Slf4j
public class NormalTopicConsumer {
    @KafkaListener(id = "normal-topic-consumer", groupId = "normal-topic-group", topics = "normal-topic")
    public void consume(ConsumerRecord<?, ?> consumerRecord, Acknowledgment ack) {
        String json = consumerRecord.value().toString();
        log.info("Consuming normal message {}", json);
        ack.acknowledge();
    }
}

如果您以除錯模式啟動應用程式,並且在acknowledge呼叫之前放置了一個斷點。同時,您透過控制檯生產者傳送訊息。當執行在斷點處暫停並且您終止了應用程式時,偏移量尚未提交。啟動該應用程式將導致重新傳送相同的訊息並再次對其進行重新處理。這就是我們想要實現的行為。

實施死信佇列DLQ
有一個處理失敗訊息的行業標準,稱為死信佇列(DLQ)。這實際上是新主題和使用者正在做的事情,但是我們不僅將失敗的訊息放到主題上以在將來進行檢查,還增強了它作為重試的方式。
DLQ使用者可以重新傳送訊息,這很好,但是我們應該能夠擺脫無限重試的麻煩。因此,我們可以引入retryCount的概念,該概念告訴DLQ使用者重試特定訊息多少次。如果達到閾值(例如5),它將停止重試該訊息。達到閾值後,應該以自動方式或手動方式仔細檢查郵件。您可以簡單地記錄該訊息並使用Kibana,Splunk等來檢測那些重試但失敗的訊息。您可以使用專用儲存來放置這些儲存,例如S3,但這只是體系結構的問題,什麼適合您的情況。
有了這個功能,系統將不會無限地重試,這真棒。但是,如果您再考慮重試機制,重試時普通消費者會收到一條訊息。它失敗了,因為它無法訪問MySQL資料庫。如果應用程式立即重試,但是資料庫沒有恢復聯機時,就顯得太快。對於更智慧的重試邏輯,可以在重新傳送訊息時引入延遲。它可以是恆定的延遲(例如5秒),也可以是指數延遲,例如從1秒開始,然後連續增加。
帶有重試計數的可重試訊息:

{
    "retryCount": 0,
    "message": {
        "data": "..."
    }
}

如果您使用的Kafka代理支援重試計數,則另一種可能性是將重試計數儲存在訊息標頭中。在這種情況下,它是完全透明的。
當需要在DLQ使用者中重新傳送訊息時,它必須確定必須將訊息重新傳送到哪個主題。同樣,這裡有多種可能性,但讓我提及其中兩種。
  1. 一種方法是將原始主題儲存在訊息中,就像重試計數一樣,或者利用Kafka標頭。
  2. 另一種方法是將原始主題編碼為DLQ主題。想象一下,DLQ使用者可以基於正規表示式收聽許多主題。該規則可能非常簡單,如果主題名稱以-dlq結尾,則必須繼續監聽。如果使用這種方法,則將正常主題稱為normal-topic,並且在訊息處理失敗的情況下,我們會將訊息傳送到normal-topic-dlq,DLQ使用者可以輕鬆推斷出它需要重新傳送到哪個主題。

在這裡我將使用Kafka標頭,因為當您不以這種詳細程度汙染實​​際的訊息內容(有效負載payload)時,我認為它是一種更乾淨的實現。
在中docker-compose.yml,我將新增具有複製因子1和分割槽1 的新dlq-topic。

KAFKA_CREATE_TOPICS: "normal-topic:1:1,dlq-topic:1:1"

DLQ的目的是要有一個傳送失敗訊息的地方。NormalTopicConsumer類需要被改變了一點點:

@Component
@Slf4j
public class NormalTopicConsumer {
    public static final String ORIGINAL_TOPIC_HEADER_KEY = "originalTopic";
    public static final String DLQ_TOPIC = "dlq-topic";
 
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
 
    @KafkaListener(id = "normal-topic-consumer", groupId = "normal-topic-group", topics = "normal-topic")
    public void consume(ConsumerRecord<?, ?> consumerRecord, Acknowledgment ack) {
        String json = consumerRecord.value().toString();
        try {
            log.info("Consuming normal message {}", json);
            // Simulating an error case
            // throw new RuntimeException();
        } catch (Exception e) {
            log.info("Message consumption failed for message {}", json);
            String originalTopic = consumerRecord.topic();
            ProducerRecord<String, String> record = new ProducerRecord<>(DLQ_TOPIC, json);
            record.headers().add(ORIGINAL_TOPIC_HEADER_KEY, originalTopic.getBytes(UTF_8));
            kafkaTemplate.send(record);
        } finally {
            ack.acknowledge();
        }
    }
}

我在這裡做了幾件事。一種是,我已將處理邏輯放入try子句中,以便捕獲由於邏輯而掉的任何錯誤,我們可以採取必要的步驟進行恢復。顯然,目前這只是一個日誌記錄,並且不會很快失敗,所以這就是為什麼我在那兒丟擲新的記錄RuntimeException。只需取消註釋,處理邏輯就會失敗。
另一件事是catch子句。我正在建立一個ProducerRecord來儲存有效負載並儲存應該將其傳送到哪個主題(dlq-topic)。最後但並非最不重要的一點是,在originalTopic鍵下將當前處理的主題名稱新增為訊息的標題。由於Kafka客戶端API僅接受位元組作為標頭,因此必須首先轉換String值。我知道這並不漂亮,但我們不能忍受。
而且我還新增了一個finally子句以始終提交偏移量,即使它失敗了。如果處理成功完成,則因此提交。如果處理失敗,則在將其傳送到DLQ之後,我們仍然需要提交它,否則,使用者將收到來自代理的相同訊息,並且將繼續失敗。那不是我們想要的。
現在,另一邊是DLQ。在正常情況下,僅將失敗的訊息傳送到DLQ就足夠了,但是由於我想將其用作重試方式,因此我們需要在其中新增一些邏輯。我要為此使用其他服務。同樣的交易,生成的專案就像正常主題消費者服務一樣。我稱它為dlq-topic-consumer。
配置與normal-topic-consumer相同:

application.properties:

spring.kafka.consumer.bootstrap-servers=localhost:9092
spring.kafka.consumer.enable-auto-commit=false
spring.kafka.listener.ack-mode=MANUAL_IMMEDIATE


一個用於消費邏輯的Spring bean DlqTopicConsumer:

@Component
@Slf4j
public class DlqTopicConsumer {
    public static final String ORIGINAL_TOPIC_HEADER_KEY = "originalTopic";
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
 
    @KafkaListener(id = "dlq-topic-consumer", groupId = "dlq-topic-group", topics = "dlq-topic")
    public void consume(ConsumerRecord<?, ?> consumerRecord, Acknowledgment ack) {
        String json = consumerRecord.value().toString();
        try {
            Header originalTopicHeader = consumerRecord.headers().lastHeader(ORIGINAL_TOPIC_HEADER_KEY);
            if (originalTopicHeader != null) {
                String originalTopic = new String(originalTopicHeader.value(), UTF_8);
                log.info("Consuming DLQ message {} from originalTopic {}", json, originalTopic);
            } else {
                log.error("Unable to read DLQ message because it's missing the originalTopic header");
            }
        } catch (Exception e) {
            log.error("Unable to process DLQ message {}", json);
        } finally {
            ack.acknowledge();
        }
    }
}
 

它只做一件事情。如果郵件具有originalTopic可用的標頭,則僅將郵件與原始主題一起登出。到目前為止,這已經足夠了,但是當我們遇到重試邏輯時,我們將構建一個更復雜的解決方案。
normal-topic-consumer對正常事件訊息進行提取,處理失敗訊息,並且它將向dlq-topic傳送失敗的訊息並提交偏移量。將訊息傳送到DLQ主題後,將啟動dlq-topic-consumer服務。它將拾取訊息並進行記錄。太棒了

實施重試邏輯
它的工作方式是這樣的。DLQ使用者將訊息重新傳送到原始主題時,將在訊息上設定retryCount標頭。如果傳入的DLQ訊息中還沒有retryCount標頭,它將把它設定為零。如果已經存在,它將讀出來,將其遞增,然後將新值設定為外發訊息。另一方面,如果可用,普通主題使用者將把retryCount標頭複製到DLQ訊息中。
當DLQ使用者要重新傳送訊息時,它將根據閾值檢查重試計數。在這裡,我將使用5作為閾值,但是您可以使用適合您的任何值。
NormalTopicConsumer:

@Component
@Slf4j
public class NormalTopicConsumer {
    public static final String RETRY_COUNT_HEADER_KEY = "retryCount";
    public static final String ORIGINAL_TOPIC_HEADER_KEY = "originalTopic";
    public static final String DLQ_TOPIC = "dlq-topic";
 
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
 
    @KafkaListener(id = "normal-topic-consumer", groupId = "normal-topic-group", topics = "normal-topic")
    public void consume(ConsumerRecord<?, ?> consumerRecord, Acknowledgment ack) {
        String json = consumerRecord.value().toString();
        try {
            log.info("Consuming normal message {}", json);
            throw new RuntimeException();
        } catch (Exception e) {
            log.info("Message consumption failed for message {}", json);
            String originalTopic = consumerRecord.topic();
            ProducerRecord<String, String> record = new ProducerRecord<>(DLQ_TOPIC, json);
            record.headers().add(ORIGINAL_TOPIC_HEADER_KEY, originalTopic.getBytes(UTF_8));
 
            Header retryCount = consumerRecord.headers().lastHeader(RETRY_COUNT_HEADER_KEY);
            if (retryCount != null) {
                record.headers().add(retryCount);
            }
            kafkaTemplate.send(record);
        } finally {
            ack.acknowledge();
        }
    }
}

DlqTopicConsumer:

@Component
@Slf4j
public class DlqTopicConsumer {
    public static final String RETRY_COUNT_HEADER_KEY = "retryCount";
    public static final String ORIGINAL_TOPIC_HEADER_KEY = "originalTopic";
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
 
    @KafkaListener(id = "dlq-topic-consumer", groupId = "dlq-topic-group", topics = "dlq-topic")
    public void consume(ConsumerRecord<?, ?> consumerRecord, Acknowledgment ack) {
        String json = consumerRecord.value().toString();
        try {
            log.info("Consuming DLQ message {}", json);
            Header originalTopicHeader = consumerRecord.headers().lastHeader(ORIGINAL_TOPIC_HEADER_KEY);
            if (originalTopicHeader != null) {
                String originalTopic = new String(originalTopicHeader.value(), UTF_8);
                Header retryCountHeader = consumerRecord.headers().lastHeader(RETRY_COUNT_HEADER_KEY);
                int retryCount = 0;
                if (retryCountHeader != null) {
                    retryCount = Integer.parseInt(new String(retryCountHeader.value(), UTF_8));
                }
                if (retryCount < 5) {
                    retryCount += 1;
                    log.info("Resending attempt {}", retryCount);
                    ProducerRecord<String, String> record = new ProducerRecord<>(originalTopic, json);
                    byte[] retryCountHeaderInByte = Integer.valueOf(retryCount).toString().getBytes(UTF_8);
                    record.headers().add(RETRY_COUNT_HEADER_KEY, retryCountHeaderInByte);
                    kafkaTemplate.send(record);
                    });
                } else {
                    log.error("Retry limit exceeded for message {}", json);
                }
            } else {
                log.error("Unable to resend DLQ message because it's missing the originalTopic header");
            }
        } catch (Exception e) {
            log.error("Unable to process DLQ message {}", json);
        } finally {
            ack.acknowledge();
        }
    }
}


剩下的事情就是使重試邏輯更加智慧,在重發中引入一些延遲。這可以透過使用AsyncTaskExecutor來實現,我們將在另一個執行緒中傳送訊息,但會在開始時將其休眠一段時間,這裡我使用了5秒的延遲。最終程式碼如下所示:

@Component
@Slf4j
public class DlqTopicConsumer {
    public static final String RETRY_COUNT_HEADER_KEY = "retryCount";
    public static final String ORIGINAL_TOPIC_HEADER_KEY = "originalTopic";
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
 
    @Autowired
    private AsyncTaskExecutor asyncTaskExecutor;
 
    @KafkaListener(id = "dlq-topic-consumer", groupId = "dlq-topic-group", topics = "dlq-topic")
    public void consume(ConsumerRecord<?, ?> consumerRecord, Acknowledgment ack) {
        String json = consumerRecord.value().toString();
        try {
            log.info("Consuming DLQ message {}", json);
            Header originalTopicHeader = consumerRecord.headers().lastHeader(ORIGINAL_TOPIC_HEADER_KEY);
            if (originalTopicHeader != null) {
                String originalTopic = new String(originalTopicHeader.value(), UTF_8);
                Header retryCountHeader = consumerRecord.headers().lastHeader(RETRY_COUNT_HEADER_KEY);
                int retryCount = 0;
                if (retryCountHeader != null) {
                    retryCount = Integer.parseInt(new String(retryCountHeader.value(), UTF_8));
                }
                if (retryCount < 5) {
                    retryCount += 1;
                    log.info("Resending attempt {}", retryCount);
                    ProducerRecord<String, String> record = new ProducerRecord<>(originalTopic, json);
                    byte[] retryCountHeaderInByte = Integer.valueOf(retryCount).toString().getBytes(UTF_8);
                    record.headers().add(RETRY_COUNT_HEADER_KEY, retryCountHeaderInByte);
                    asyncTaskExecutor.execute(() -> {
                        try {
                            log.info("Waiting for 5 seconds until resend");
                            Thread.sleep(5000);
                            kafkaTemplate.send(record);
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    });
                } else {
                    log.error("Retry limit exceeded for message {}", json);
                }
            } else {
                log.error("Unable to resend DLQ message because it's missing the originalTopic header");
            }
        } catch (Exception e) {
            log.error("Unable to process DLQ message {}", json);
        } finally {
            ack.acknowledge();
        }
    }
}
 

更新: Jakub在評論中完美地指出,如果DLQ服務在非同步執行緒上崩潰時,等待訊息重新傳送,它將丟失訊息。最快的解決方案是不使用非同步重發模型,而是在原始執行緒中進行等待。但是,這會影響DLQ服務的整體吞吐量。

@Component
@Slf4j
public class DlqTopicConsumer {
    public static final String RETRY_COUNT_HEADER_KEY = "retryCount";
    public static final String ORIGINAL_TOPIC_HEADER_KEY = "originalTopic";
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
 
    @KafkaListener(id = "dlq-topic-consumer", groupId = "dlq-topic-group", topics = "dlq-topic")
    public void consume(ConsumerRecord<?, ?> consumerRecord, Acknowledgment ack) {
        String json = consumerRecord.value().toString();
        try {
            log.info("Consuming DLQ message {}", json);
            Header originalTopicHeader = consumerRecord.headers().lastHeader(ORIGINAL_TOPIC_HEADER_KEY);
            if (originalTopicHeader != null) {
                String originalTopic = new String(originalTopicHeader.value(), UTF_8);
                Header retryCountHeader = consumerRecord.headers().lastHeader(RETRY_COUNT_HEADER_KEY);
                int retryCount = 0;
                if (retryCountHeader != null) {
                    retryCount = Integer.parseInt(new String(retryCountHeader.value(), UTF_8));
                }
                if (retryCount < 5) {
                    retryCount += 1;
                    log.info("Resending attempt {}", retryCount);
                    ProducerRecord<String, String> record = new ProducerRecord<>(originalTopic, json);
                    byte[] retryCountHeaderInByte = Integer.valueOf(retryCount).toString().getBytes(UTF_8);
                    record.headers().add(RETRY_COUNT_HEADER_KEY, retryCountHeaderInByte);
                    log.info("Waiting for 5 seconds until resend");
                    Thread.sleep(5000);
                    kafkaTemplate.send(record);
                } else {
                    log.error("Retry limit exceeded for message {}", json);
                }
            } else {
                log.error("Unable to resend DLQ message because it's missing the originalTopic header");
            }
        } catch (Exception e) {
            log.error("Unable to process DLQ message {}", json);
        } finally {
            ack.acknowledge();
        }
    }
}

另一種選擇是為延遲的訊息保留一個永續性儲存。萬一服務崩潰,它可以接收重新傳送的訊息。(mailbox郵箱模式)
 

相關文章