使用Kafka Streams和Spring Boot微服務中的分散式事務 - Piotr

banq發表於2022-01-25

在本文中,您將學習如何在 Spring Boot 中使用 Kafka Streams。我們將依賴 Spring Kafka 專案。為了很好地解釋它是如何工作的,我們將實現一個 saga 模式。saga 模式是一種跨微服務管理分散式事務的方法。該過程的關鍵階段是釋出觸發本地事務的事件。微服務通過訊息代理交換此類事件。事實證明,Kafka Streams 可以幫助我們。讓我們看看如何!

原始碼:

如果您想自己嘗試一下,可以隨時檢視我的原始碼。為此,您需要克隆我的 GitHub 儲存庫。之後,您應該按照我的指示進行操作。

除了 Spring Kafka,您還可以使用 Spring Cloud Stream for Kafka。您可以在本文中閱讀更多相關資訊。Spring Cloud Stream 提供了一些有用的功能,例如 DLQ 支援、預設的 JSON 序列化或互動式查詢。

 

架構

我們將建立一個簡單的系統,由三個微服務組成。訂單服務將訂單傳送到名為訂單的Kafka主題。其他兩個微服務stock-service和payment-service都會監聽傳入的事件。在接收到這些事件後,它們會驗證是否有可能執行該訂單。例如,如果客戶賬戶上沒有足夠的資金,訂單將被拒絕。否則,支付服務接受訂單,並向支付-訂單主題傳送一個響應。庫存服務也是如此,除了驗證庫存產品的數量並向庫存-訂單主題傳送一個響應。

然後,訂單服務通過訂單的ID將來自庫存-訂單和付款-訂單主題的兩個流連線起來。如果兩個訂單都被接受,它就確認了一個分散式交易。另一方面,如果一個訂單被接受,而第二個訂單被拒絕,它就會執行回滾。在這種情況下,它只是生成一個新的訂單事件並將其傳送到訂單主題。我們可以把訂單主題看作是訂單狀態變化的一個流,或者就像一個有最後狀態的表。下面的圖片直觀地展示了我們的場景。

使用Kafka Streams和Spring Boot微服務中的分散式事務 - Piotr

 

Kafka Streams with Spring Boot

讓我們從訂單服務開始實施。令人驚訝的是,沒有針對Kafka的Spring Boot啟動器(除非我們使用Spring Cloud Stream)。因此,我們需要包含spring-kafka依賴項。為了處理流,我們還需要直接包含kafka-streams模組。由於訂單服務暴露了一些REST端點,所以需要新增Spring Boot Web啟動器。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.kafka</groupId>
  <artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
  <groupId>org.apache.kafka</groupId>
  <artifactId>kafka-streams</artifactId>
</dependency>

訂單服務是我們方案中最重要的微服務。它充當訂單閘道器和傳奇模式的協調者。它需要我們架構中使用的所有三個主題。為了在應用程式啟動時自動建立主題,我們需要定義以下bean類。

@Bean
public NewTopic orders() {
   return TopicBuilder.name("orders")
         .partitions(3)
         .compact()
         .build();
}

@Bean
public NewTopic paymentTopic() {
   return TopicBuilder.name("payment-orders")
         .partitions(3)
         .compact()
         .build();
}

@Bean
public NewTopic stockTopic() {
   return TopicBuilder.name("stock-orders")
         .partitions(3)
         .compact()
         .build();
}

然後,讓我們來定義我們的第一個Kafka流。

要做到這一點,我們需要使用StreamsBuilder Bean。

訂單服務接收來自支付服務(在payment-events主題)和股票服務(在stock-events主題)的事件。每一個事件都包含先前由訂單服務設定的ID。如果我們通過訂單的ID將這兩個流連線成一個流,我們將能夠確定我們的交易狀態。這個演算法非常簡單。如果支付服務和股票服務都接受了訂單,交易的最終狀態就是確認。如果兩個服務都拒絕了訂單,最終的狀態是REJECTED。最後一個選項是ROLLBACK--當一個服務接受了訂單,而一個服務拒絕了它。

下面是OrderManageService Bean中的描述方法:

@Service
public class OrderManageService {

   public Order confirm(Order orderPayment, Order orderStock) {
      Order o = new Order(orderPayment.getId(),
             orderPayment.getCustomerId(),
             orderPayment.getProductId(),
             orderPayment.getProductCount(),
             orderPayment.getPrice());
      if (orderPayment.getStatus().equals("ACCEPT") &&
                orderStock.getStatus().equals("ACCEPT")) {
         o.setStatus("CONFIRMED");
      } else if (orderPayment.getStatus().equals("REJECT") &&
                orderStock.getStatus().equals("REJECT")) {
         o.setStatus("REJECTED");
      } else if (orderPayment.getStatus().equals("REJECT") ||
                orderStock.getStatus().equals("REJECT")) {
         String source = orderPayment.getStatus().equals("REJECT")
                    ? "PAYMENT" : "STOCK";
         o.setStatus("ROLLBACK");
         o.setSource(source);
      }
      return o;
   }

}

最後,我們的流的實現。我們需要定義KStream bean。我們正在使用KStream的join方法連線兩個流。連線的視窗是10秒。結果是,我們正在設定訂單的狀態,並向訂單主題傳送一個新的訂單。我們使用與傳送新訂單相同的主題。

@Autowired
OrderManageService orderManageService;

@Bean
public KStream<Long, Order> stream(StreamsBuilder builder) {
   JsonSerde<Order> orderSerde = new JsonSerde<>(Order.class);
   KStream<Long, Order> stream = builder
         .stream("payment-orders", Consumed.with(Serdes.Long(), orderSerde));

   stream.join(
         builder.stream("stock-orders"),
         orderManageService::confirm,
         JoinWindows.of(Duration.ofSeconds(10)),
         StreamJoined.with(Serdes.Long(), orderSerde, orderSerde))
      .peek((k, o) -> LOG.info("Output: {}", o))
      .to("orders");

   return stream;
}

使用Kafka Streams和Spring Boot微服務中的分散式事務 - Piotr

 

SpringBoot配置

在Spring Boot中,應用程式的名稱預設為Kafka Streams的消費者組的名稱。因此,我們應該在application.yml中設定。當然,我們還需要設定Kafka bootstrap伺服器的地址。最後,我們要配置事件序列化的預設鍵和值。它同時適用於標準和流處理。

spring.application.name: orders
spring.kafka:
  bootstrap-servers: 127.0.0.1:56820
  producer:
    key-serializer: org.apache.kafka.common.serialization.LongSerializer
    value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
  streams:
    properties:
      default.key.serde: org.apache.kafka.common.serialization.Serdes$LongSerde
      default.value.serde: org.springframework.kafka.support.serializer.JsonSerde
      spring.json.trusted.packages: "*"

從Kafka主題傳送和接收事件

在上一節中,我們討論瞭如何建立一個新的Kafka流,作為連線其他兩個流的結果。

現在,讓我們看看如何處理傳入的訊息。我們可以以支付服務為例來考慮這個問題。

它監聽傳入的訂單。如果它收到一個新的訂單,它將在客戶的賬戶上執行預訂,並向payment-orders主題傳送一個帶有預訂狀態的響應。如果它收到來自訂單服務的交易確認,它將提交交易或回滾。為了啟用Kafka監聽器,我們應該用@EnableKafka來註解主類。此外,監聽方法必須用@KafkaListener來註解。下面的方法監聽訂單主題上的事件,並在支付消費者組中執行。

@SpringBootApplication
@EnableKafka
public class PaymentApp {

    private static final Logger LOG = LoggerFactory.getLogger(PaymentApp.class);

    public static void main(String[] args) {
        SpringApplication.run(PaymentApp.class, args);
    }

    @Autowired
    OrderManageService orderManageService;

    @KafkaListener(id = "orders", topics = "orders", groupId = "payment")
    public void onEvent(Order o) {
        LOG.info("Received: {}" , o);
        if (o.getStatus().equals("NEW"))
            orderManageService.reserve(o);
        else
            orderManageService.confirm(o);
    }
}

這裡是前面程式碼片段中使用的OrderManageService的實現。如果它收到新狀態的訂單,它將執行預訂。在預訂過程中,它將訂單的價格從 amountAvailable 欄位中減去,並在 amountReserved 欄位中新增相同的值。然後,它設定訂單的狀態,並使用KafkaTemplate向payment-orders主題傳送響應。在確認階段,它不傳送任何響應事件。它可以執行回滾,這意味著--將訂單的價格從reserved欄位中減去,並將其新增到amountAvailable欄位中。否則,它只是 "提交 "交易事務,從保留金額欄位中減去價格。

@Service
public class OrderManageService {

   private static final String SOURCE = "payment";
   private static final Logger LOG = LoggerFactory.getLogger(OrderManageService.class);
   private CustomerRepository repository;
   private KafkaTemplate<Long, Order> template;

   public OrderManageService(CustomerRepository repository, KafkaTemplate<Long, Order> template) {
      this.repository = repository;
      this.template = template;
   }

   public void reserve(Order order) {
      Customer customer = repository.findById(order.getCustomerId()).orElseThrow();
      LOG.info("Found: {}", customer);
      if (order.getPrice() < customer.getAmountAvailable()) {
         order.setStatus("ACCEPT");
         customer.setAmountReserved(customer.getAmountReserved() + order.getPrice());
         customer.setAmountAvailable(customer.getAmountAvailable() - order.getPrice());
      } else {
         order.setStatus("REJECT");
      }
      order.setSource(SOURCE);
      repository.save(customer);
      template.send("payment-orders", order.getId(), order);
      LOG.info("Sent: {}", order);
   }

   public void confirm(Order order) {
      Customer customer = repository.findById(order.getCustomerId()).orElseThrow();
      LOG.info("Found: {}", customer);
      if (order.getStatus().equals("CONFIRMED")) {
         customer.setAmountReserved(customer.getAmountReserved() - order.getPrice());
         repository.save(customer);
      } else if (order.getStatus().equals("ROLLBACK") && !order.getSource().equals(SOURCE)) {
         customer.setAmountReserved(customer.getAmountReserved() - order.getPrice());
         customer.setAmountAvailable(customer.getAmountAvailable() + order.getPrice());
         repository.save(customer);
      }

   }
}

 

庫存服務stock-service

一個類似的邏輯在庫存服務方面被實現。然而,它使用欄位productCount而不是訂單的價格,並對所需數量的訂單產品進行預訂。下面是庫存服務中OrderManageService類的實現。

@Service
public class OrderManageService {

   private static final String SOURCE = "stock";
   private static final Logger LOG = LoggerFactory.getLogger(OrderManageService.class);
   private ProductRepository repository;
   private KafkaTemplate<Long, Order> template;

   public OrderManageService(ProductRepository repository, KafkaTemplate<Long, Order> template) {
      this.repository = repository;
      this.template = template;
   }

   public void reserve(Order order) {
      Product product = repository.findById(order.getProductId()).orElseThrow();
      LOG.info("Found: {}", product);
      if (order.getStatus().equals("NEW")) {
         if (order.getProductCount() < product.getAvailableItems()) {
            product.setReservedItems(product.getReservedItems() + order.getProductCount());
            product.setAvailableItems(product.getAvailableItems() - order.getProductCount());
            order.setStatus("ACCEPT");
            repository.save(product);
         } else {
            order.setStatus("REJECT");
         }
         template.send("stock-orders", order.getId(), order);
         LOG.info("Sent: {}", order);
      }
   }

   public void confirm(Order order) {
      Product product = repository.findById(order.getProductId()).orElseThrow();
      LOG.info("Found: {}", product);
      if (order.getStatus().equals("CONFIRMED")) {
         product.setReservedItems(product.getReservedItems() - order.getProductCount());
         repository.save(product);
      } else if (order.getStatus().equals("ROLLBACK") && !order.getSource().equals(SOURCE)) {
         product.setReservedItems(product.getReservedItems() - order.getProductCount());
         product.setAvailableItems(product.getAvailableItems() + order.getProductCount());
         repository.save(product);
      }
   }

}

 

用Spring Boot查詢Kafka流

現在,讓我們考慮以下場景:

首先,訂單服務收到一個新的訂單(通過REST API)並將其傳送到Kafka主題。然後,這個訂單被其他兩個微服務接收。一旦他們發回一個積極的響應(或消極的),訂單服務就會把它們作為流來處理,並改變訂單的狀態。具有新狀態的訂單將被排放到與之前相同的主題。那麼,我們在哪裡儲存訂單的當前狀態的資料呢?在Kafka主題中。我們將再次在我們的Spring Boot應用程式中使用Kafka Streams。但是這一次,我們利用了KTable的優勢。讓我們來看看我們的場景的視覺化情況。

使用Kafka Streams和Spring Boot微服務中的分散式事務 - Piotr

好了,讓我們在訂單服務中定義另一個Kafka Streams bean。我們將獲得相同的訂單主題作為一個流。我們將把它轉換為Kafka表,並在一個持久的儲存中實現它。由於這一點,我們將能夠輕鬆地從我們的REST控制器中查詢儲存。

@Bean
public KTable<Long, Order> table(StreamsBuilder builder) {
   KeyValueBytesStoreSupplier store =
         Stores.persistentKeyValueStore("orders");
   JsonSerde<Order> orderSerde = new JsonSerde<>(Order.class);
   KStream<Long, Order> stream = builder
         .stream("orders", Consumed.with(Serdes.Long(), orderSerde));
   return stream.toTable(Materialized.<Long, Order>as(store)
         .withKeySerde(Serdes.Long())
         .withValueSerde(orderSerde));
}

如果我們在同一臺機器上執行多個訂單服務例項,覆蓋狀態儲存的預設位置也很重要。要做到這一點,我們應該為每一個例項定義以下獨特的屬性。

spring.kafka.streams.state-dir: /tmp/kafka-streams/1

不幸的是,Spring Boot中沒有內建支援Kafka流的互動式查詢。然而,我們可以使用自動配置的StreamsBuilderFactoryBean將KafkaStreams例項注入控制器。然後我們可以在 "物化 materialized"名稱下查詢狀態儲存。這當然是非常微不足道的例子。我們只是從KTable獲取所有的訂單。

@GetMapping
public List<Order> all() {
   List<Order> orders = new ArrayList<>();
      ReadOnlyKeyValueStore<Long, Order> store = kafkaStreamsFactory
         .getKafkaStreams()
         .store(StoreQueryParameters.fromNameAndType(
                "orders",
                QueryableStoreTypes.keyValueStore()));
   KeyValueIterator<Long, Order> it = store.all();
   it.forEachRemaining(kv -> orders.add(kv.value));
   return orders;
}

在同一個OrderController中,也有一個方法用於傳送一個新訂單到Kafka主題。

@PostMapping
public Order create(@RequestBody Order order) {
   order.setId(id.incrementAndGet());
   template.send("orders", order.getId(), order);
   LOG.info("Sent: {}", order);
   return order;
}
 

測試場景

在我們執行我們的示例微服務之前,我們需要啟動 Kafka 的本地例項。通常,我為此使用 Redpanda。它是一個相容 Kafka API 的流媒體平臺。與 Kafka 相比,在本地執行它相對容易。您需要做的就是在rpk本地安裝CLI(這裡是macOS的說明)。之後,您可以使用以下命令建立單節點例項:

$ rpk container start

執行後,它將列印您的節點的地址。對我來說,是 127.0.0.1:56820。您應該將該地址作為spring.kafka.bootstrap-servers屬性的值。您還可以使用以下命令顯示已建立主題的列表:

$ rpk topic list --brokers 127.0.0.1:56820

然後,讓我們執行我們的微服務。從 開始,order-service因為它正在建立所有必需的主題並構建 Kafka Streams 例項。您可以使用 REST 端點傳送單個訂單:

$ curl -X 'POST' \
  'http://localhost:8080/orders' \
  -H 'Content-Type: application/json' \
  -d '{
  "customerId": 10,
  "productId": 10,
  "productCount": 5,
  "price": 100,
  "status": "NEW"
}'

以下 bean 負責生成 10000 個隨機訂單:

@Service
public class OrderGeneratorService {

   private static Random RAND = new Random();
   private AtomicLong id = new AtomicLong();
   private Executor executor;
   private KafkaTemplate<Long, Order> template;

   public OrderGeneratorService(Executor executor, KafkaTemplate<Long, Order> template) {
      this.executor = executor;
      this.template = template;
   }

   @Async
   public void generate() {
      for (int i = 0; i < 10000; i++) {
         int x = RAND.nextInt(5) + 1;
         Order o = new Order(id.incrementAndGet(), RAND.nextLong(100) + 1, RAND.nextLong(100) + 1, "NEW");
         o.setPrice(100 * x);
         o.setProductCount(x);
         template.send("orders", o.getId(), o);
      }
   }
}

可以通過呼叫端點來啟動該過程POST /orders/generate。

無論是決定傳送單個訂單還是生成多個隨機訂單,您都可以使用以下端點輕鬆查詢訂單狀態:

$ curl http://localhost:8080/orders

這是由應用程式和 Kafka Streams 生成的主題結構,用於執行連線操作並將訂單儲存KTable為狀態儲存。

使用Kafka Streams和Spring Boot微服務中的分散式事務 - Piotr

相關文章