使用Spring Boot和Kafka Streams實現基於SAGA模式的分散式事務原始碼教程 - Piotr

banq發表於2022-02-08

本案例原始碼是如何使用Spring Boot 和Kafka Streams實現基於SAGA 模式的分散式事務

有三個微服務

  • 訂單服務--它向Kafka主題傳送訂單事件,並協調分散式事務的過程
  • 支付服務--它根據訂單價格在客戶賬戶上執行本地事務
  • 庫存服務--它根據訂單中的產品數量在商店上執行本地事務

這是我們的架構圖:

使用Spring Boot和Kafka Streams實現基於SAGA模式的分散式事務原始碼教程 - Piotr

為了完全理解本示例中發生的情況,您還應該熟悉 Kafka Streams 執行緒模型。值得閱讀以下文章,它以簡潔的方式解釋了它。首先,每個流分割槽是一個完全有序的資料記錄序列,並對映到一個 Kafka 主題分割槽。這意味著,即使我們同時有多個訂單與相同的產品相關,它們也會被順序處理,因為它們具有相同的訊息鍵(productId在這種情況下)。

此外,預設情況下,只有一個流執行緒處理所有分割槽。您可以在下面的日誌中看到這一點。但是,有一些流任務充當最低階別的並行單元。因此,流任務可以獨立並行處理,無需人工干預。

  

這是完全基於Kafka流的。我們不會使用任何SQL資料庫:

當訂單服務傳送一個新的訂單時,它的id是訊息鍵。通過Kafka流,我們可以改變流中的一個訊息鍵。它的結果是建立新的主題和重新分割槽。有了新的訊息鍵,我們可以只對特定的customerId或productId進行計算。這種計算的結果可以儲存在永續性儲存中。

例如,當你在呼叫count()或aggregate()等有狀態的操作時,Kafka會自動建立和管理這樣的狀態儲存。我們將聚合與特定客戶或產品相關的訂單。

 

現在,讓我們詳細考慮一下支付服務的場景。在傳入的訂單流中,支付服務呼叫selectKey()操作。它將鍵從訂單的id改為訂單的customerId。然後它通過新的鍵對所有的訂單進行分組,並呼叫aggregate()操作。在aggregate()方法中,它根據訂單的價格和狀態(無論是新訂單還是確認訂單)計算出可用金額和保留金額。如果客戶賬戶上有足夠的資金,它將傳送ACCEPT訂單到payment-order主題。否則,它將傳送REJECT訂單。然後,訂單服務流程通過訂單的ID加入來自payment-order和inventory-order的流進行響應。作為結果,它傳送一個確認或回滾訂單。

使用Spring Boot和Kafka Streams實現基於SAGA模式的分散式事務原始碼教程 - Piotr

 

用Kafka流進行聚合

讓我們從支付服務開始。KStream的實現在這裡並不複雜。

@Bean
public KStream<Long, Order> stream(StreamsBuilder builder) {
   JsonSerde<Order> orderSerde = new JsonSerde<>(Order.class);
   JsonSerde<Reservation> rsvSerde = new JsonSerde<>(Reservation.class);
   KStream<Long, Order> stream = builder
      .stream("orders", Consumed.with(Serdes.Long(), orderSerde))
      .peek((k, order) -> LOG.info("New: {}", order));

   KeyValueBytesStoreSupplier customerOrderStoreSupplier =
      Stores.persistentKeyValueStore("customer-orders");

   stream.selectKey((k, v) -> v.getCustomerId()) // (1)
      .groupByKey(Grouped.with(Serdes.Long(), orderSerde)) // (2)
      .aggregate(
         () -> new Reservation(random.nextInt(1000)),
         aggregatorService,
         Materialized.<Long, Reservation>as(customerOrderStoreSupplier)
            .withKeySerde(Serdes.Long())
            .withValueSerde(rsvSerde)) // (3)
      .toStream()
      .peek((k, trx) -> LOG.info("Commit: {}", trx));

   return stream;
}

第一步(1),我們呼叫selectKey()方法,獲得訂單物件的customerId值作為一個新的鍵。然後我們呼叫groupByKey()方法(2)來接收KGroupedStream作為結果。當我們擁有KGroupedStream時,我們可以呼叫其中的一個計算方法。在這種情況下,我們需要使用aggregate(),因為我們有一個比簡單計數更高階的計算方法(3)。

最後兩步只是為了列印計算後的值。

然而,上面可見的程式碼片段中最重要的一步是在aggregate()方法中呼叫的類。aggregate()方法需要三個輸入引數。其中第一個參數列示我們的計算物件的起始值。該物件代表客戶賬戶的當前狀態。它有兩個欄位: amountAvailable 和 amountReserved。為了說明問題,我們使用該物件而不是在客戶賬戶上儲存可用和保留金額的實體。每個客戶由Kafka KTable中的customerId(鍵)和Reservation物件(值)表示。只是為了測試的目的,我們要生成一個介於0和1000之間的隨機數,作為 amountAvailable 的起始值。

public class Reservation {
   private int amountAvailable;
   private int amountReserved;

   public Reservation() {
   
   }

   public Reservation(int amountAvailable) {
      this.amountAvailable = amountAvailable;
   }

   // GETTERS AND SETTERS ...

}

好吧,讓我們來看看我們的聚合方法。它需要實現Kafka Aggregate介面及其方法apply()。它可以處理三種型別的訂單。其中一種是訂單的確認(1)。它確認的是分散式交易,所以我們只需要通過從amountReserved欄位中減去訂單的價格來取消一個預訂。另一方面,在回滾的情況下,我們需要用訂單的價格來增加可用金額的值,並相應減少保留金額的值(2)。最後,如果我們收到一個新的訂單,如果客戶賬戶上有足夠的資金,我們需要執行保留,否則,拒絕一個訂單。

Aggregator<Long, Order, Reservation> aggregatorService = (id, order, rsv) -> {
   switch (order.getStatus()) {
      case "CONFIRMED" -> // (1)
         rsv.setAmountReserved(rsv.getAmountReserved() 
               - order.getPrice());
      case "ROLLBACK" -> { // (2)
         if (!order.getSource().equals("PAYMENT")) {
            rsv.setAmountAvailable(rsv.getAmountAvailable() 
                  + order.getPrice());
            rsv.setAmountReserved(rsv.getAmountReserved() 
                  - order.getPrice());
         }
      }
      case "NEW" -> { // (3)
         if (order.getPrice() <= rsv.getAmountAvailable()) {
            rsv.setAmountAvailable(rsv.getAmountAvailable() 
                  - order.getPrice());
            rsv.setAmountReserved(rsv.getAmountReserved() 
                  + order.getPrice());
            order.setStatus("ACCEPT");
         } else {
            order.setStatus("REJECT");
         }
         template.send("payment-orders", order.getId(), order);
      }
   }
   LOG.info("{}", rsv);
   return rsv;
};

 

使用Kafka流表的狀態儲存

庫存服務的實現與支付服務非常相似。不同的是,我們計算的是庫存產品的數量而不是客戶賬戶上的可用資金。

Aggregator<Long, Order, Reservation> aggrSrv = (id, order, rsv) -> {
   switch (order.getStatus()) {
      case "CONFIRMED" -> rsv.setItemsReserved(rsv.getItemsReserved() 
            - order.getProductCount());
      case "ROLLBACK" -> {
         if (!order.getSource().equals("STOCK")) {
            rsv.setItemsAvailable(rsv.getItemsAvailable() 
                  + order.getProductCount());
            rsv.setItemsReserved(rsv.getItemsReserved() 
                  - order.getProductCount());
         }
      }
      case "NEW" -> {
         if (order.getProductCount() <= rsv.getItemsAvailable()) {
            rsv.setItemsAvailable(rsv.getItemsAvailable() 
                  - order.getProductCount());
            rsv.setItemsReserved(rsv.getItemsReserved() 
                  + order.getProductCount());
            order.setStatus("ACCEPT");
         } else {
            order.setStatus("REJECT");
         }
         // (1)
         template.send("stock-orders", order.getId(), order)
            .addCallback(r -> LOG.info("Sent: {}", 
               result != null ? result.getProducerRecord().value() : null),
               ex -> {});
      }
   }
   LOG.info("{}", rsv); // (2)
   return rsv;
};

聚合方法的實現也與支付服務非常相似。然而,這一次,讓我們專注於另一件事。一旦我們處理了一個新的訂單,我們就需要向stock-orders主題傳送一個響應。我們使用KafkaTemplate來做這個。在支付服務的情況下,我們也會傳送一個響應,但要傳送到支付-訂單主題。KafkaTemplate的傳送方法並沒有阻塞執行緒。它返回ListenableFuture物件。我們可以給傳送方法新增一個回撥,使用它和傳送訊息後的結果(1)。最後,讓我們記錄一下預訂物件的當前狀態(2)。

 

之後,我們也在記錄保留物件的值(1)如下。為了做到這一點,我們需要將KTable轉換為KStream,然後呼叫peek方法。這個日誌是在Kafka Streams提交源主題中的偏移量後才列印的。

@Bean
public KStream<Long, Order> stream(StreamsBuilder builder) {
   JsonSerde<Order> orderSerde = new JsonSerde<>(Order.class);
   JsonSerde<Reservation> rsvSerde = new JsonSerde<>(Reservation.class);
   KStream<Long, Order> stream = builder
      .stream("orders", Consumed.with(Serdes.Long(), orderSerde))
      .peek((k, order) -> LOG.info("New: {}", order));

   KeyValueBytesStoreSupplier stockOrderStoreSupplier =
      Stores.persistentKeyValueStore("stock-orders");

   stream.selectKey((k, v) -> v.getProductId())
      .groupByKey(Grouped.with(Serdes.Long(), orderSerde))
      .aggregate(() -> new Reservation(random.nextInt(100)), aggrSrv,
         Materialized.<Long, Reservation>as(stockOrderStoreSupplier)
            .withKeySerde(Serdes.Long())
            .withValueSerde(rsvSerde))
      .toStream()
      .peek((k, trx) -> LOG.info("Commit: {}", trx)); // (1)

   return stream;
}

 

執行程式碼

如果你傳送測試訂單會發生什麼?讓我們看看日誌。你可以看到處理訊息和偏移提交之間的時間差。在你的應用程式正在執行或被優雅地停止之前,你不會有任何問題。但如果你,比如說,用kill -9命令殺死這個程式?重新啟動後,我們的應用程式將再次收到相同的訊息。由於我們使用KafkaTemplate來向stock-orders主題傳送響應,我們需要儘快提交偏移。

我們可以做什麼來避免這樣的問題呢?我們可以覆蓋commit.interval.ms Kafka Streams屬性的預設值(30000)。如果你把它設定為0,在處理完成後立即提交。

spring.kafka:  
  streams:
    properties:
      commit.interval.ms: 0

另一方面,我們也可以將processing.guarantee屬性設定為exact_once。它還將commit.interval.ms的預設值改為100ms,並啟用生產者的idempotence。你可以在Kafka文件中閱讀更多關於它的內容。

spring.kafka:  
  streams:
    properties:
      processing.guarantee: exactly_once

 

相關文章