你知道微服務架構中的「發件箱模式」嗎

2023-01-07 12:00:34

前言

微服務架構如今非常的流行,這個架構下可能經常會遇到「雙寫」的場景。雙寫是指您的應用程式需要在兩個不同的系統中更改資料的情況,比如它需要將資料儲存在資料庫中並向訊息佇列傳送事件。您需要保證這兩個操作都會成功。如果兩個操作之一失敗,您的系統可能會變得不一致。那針對這樣的情況有什麼好的方法或者設計保證呢?本文就和大家分享一個「發件箱模式」, 可以很好的避免此類問題。

歡迎關注個人公眾號『JAVA旭陽』交流溝通

下訂單的例子

假設我們有一個 OrderService 類,它在建立新訂單時被呼叫,此時它應該將訂單實體儲存在資料庫中並向交付微服務傳送一個事件,以便交付部門可以開始計劃交付。

你的程式碼可能是下面這樣子的:

@Service
public record OrderService(
    IDeliveryMessageQueueService deliveryMessageQueueService,
    IOrderRepository orderRepository,
    TransactionTemplate transactionTemplate) implements IOrderService {

    @Override
    public void create(int id, String description) {
        String message = buildMessage(id, description);

        transactionTemplate.executeWithoutResult(transactionStatus -> {
            // 儲存訂單
            orderRepository.save(id, description);
        });

        // 傳送訊息
        deliveryMessageQueueService.send(message);
    }

    private String buildMessage(int id, String description) {
        // ...
    }
}

可以看到我們在事務中將訂單儲存在資料庫中,然後我們使用訊息佇列將事件傳送到交付服務。這是雙寫的一個場景。

這麼寫,會遇到什麼問題呢?

首先,如果我們儲存了訂單但是傳送訊息失敗了怎麼辦?送貨服務永遠不會收到訊息。

那你可能想到把儲存訂單和發訊息放到同一個事務中不就可以了嗎,就是是將 deliveryMessageQueueService#send 移動到與 orderRepository#save 相同的事務中,如下圖:

transactionTemplate.executeWithoutResult(transactionStatus -> {
            // 儲存訂單
            orderRepository.save(id, description);
            // 傳送訊息
        	deliveryMessageQueueService.send(message);
        });

實際上,在資料庫事務內部建立 TCP 連線是一種糟糕的做法,我們不應該這樣做。

有沒有更好的方法呢?

我們可以訂單表所在的同一資料庫中有一個表「發件箱」(在最簡單的情況下,它可以有一個列「訊息」和當前時間戳)。儲存訂單時,在同一個事務中,我們在「發件箱」表中儲存了一條訊息。訊息一傳送,我們就可以將其從發件箱表中刪除,程式碼如下:

@Service
public record OrderService(
    IDeliveryMessageQueueService deliveryMessageQueueService,
    IOrderRepository orderRepository,
    IOutboxRepository outboxRepository,
    TransactionTemplate transactionTemplate) implements IOrderService {

    @Override
    public void create(int id, String description) {
        UUID outboxId = UUID.randomUUID();
        String message = buildMessage(id, description);

        transactionTemplate.executeWithoutResult(transactionStatus -> {
            // 儲存訂單
            orderRepository.save(id, description);
            // 儲存到發件箱
            outboxRepository.save(new OutboxEntity(outboxId, message));
        });

        deliveryMessageQueueService.send(message);
        
        // 刪除
        outboxRepository.delete(outboxId);
    }

    private String buildMessage(int id, String description) {
        // ...
    }
}

可以看到,我們在一次事務中將訂單和發件箱實體儲存在我們的資料庫中。然後我們傳送一條訊息,如果成功,我們刪除這條訊息。

如果 deliveryMessageQueueService#send 失敗會怎樣?(例如,您的應用程式被終止或訊息佇列或資料庫不可用)。在這種情況下,outboxRepository#delete 將不會執行,我們必須重試傳送訊息。

它可以使用將在後臺執行的計劃任務來完成,該任務將嘗試傳送在表發件箱中顯示超過 X 秒(例如 10 秒)的訊息,如下面的程式碼。

@Service
public record OutboxRetryTask(IOutboxRepository outboxRepository,
                              IDeliveryMessageQueueService deliveryMessageQueueService) {

    @Scheduled(fixedDelayString = "10000")
    public void retry() {
        List<OutboxEntity> outboxEntities = outboxRepository.findAllBefore(Instant.now().minusSeconds(60));
        for (OutboxEntity outbox : outboxEntities) {
            deliveryMessageQueueService.send(outbox.message());
            outboxRepository.delete(outbox.id());
        }
    }
}

在這裡你可以看到,我們每 10 秒執行一個任務,並行送之前沒有傳送過的訊息。如果訊息成功傳送到訊息佇列,但發件箱實體沒有從資料庫中刪除(例如因為資料庫問題),那麼下次該後臺任務將嘗試再次將此訊息傳送到訊息佇列。但這也意味著我們訊息的消費者必須做好冪等處理,因為可能會多次接收相同的訊息。

發件箱模式

通過上面的例子,我們可以抽象出「發件箱模式」。

  • 在資料庫裡面額外增加一個outbox表用於儲存需要傳送的event
  • 把直接傳送event的步驟換成先把event儲存到資料庫outbox表
  • 程式啟動一個 job 不斷去抓取 outbox 表裡面的記錄,通過推播執行緒完成不同業務的推播
  • 最後刪除傳送成功的記錄
  • 提醒訊息消費端要做好冪等處理

總結

發件箱模式雖然聽上去可能很簡單,但是在平時開發中可能會忽略掉。如果還不能理解,我們可以將它類比到生活的場景,寄信人只需要寫好信件,放入收件箱,之後就不用管了。送信的人會來收件箱取走信件,根據信件裡需要送到的地址,將信件送至目的地。這樣做的好處就是,寄信人寫好信之後,就不需要等待收信人有空的時候才能寄信,只需要往發件箱裡丟就好了。

歡迎關注個人公眾號『JAVA旭陽』交流溝通