以事務方式傳送 Kafka 訊息

banq發表於2022-07-21

在自 2016 年以來,我們在 Mirakl 開始使用 Kafka 作為訊息服務,以支援我們在微服務環境中的非同步驅動架構
起初,Kafka 僅用於非關鍵服務,如電子郵件、審計或日誌記錄。這是一種安全的方法,因為我們對這項技術還沒有完全的信心,尤其是我們對關聯式資料庫的交易方面,這是處理支付等更關鍵的服務時必須具備的。
本文將介紹 Kafka 中的訊息傳遞語義、它們在發生故障時的限制,以及我們如何在 Mirakl 使用發件箱模式克服這一限制。

Kafka 中的訊息傳遞
您可能已經知道 Kafka 支援三種訊息語義:

1、至少一次
生產者向代理傳送訊息並期望得到確認以確保訊息已成功新增到主題中。如果由於網路延遲或任何其他原因沒有收到確認,則生產者會重試傳送訊息,假設它尚未新增到主題中。
這可能導致重複訊息,考慮到所有消費者都必須是冪等的,這不一定是一個問題。

以事務方式傳送 Kafka 訊息


2、最多一次
這也被稱為“盡力而為”策略。生產者在不等待確認的情況下向代理傳送訊息,如果代理無法訪問或由於某種原因導致訊息丟失(這種情況很少發生),生產者將不會嘗試重新傳送訊息。換句話說,訊息被傳遞一次(最好的情況)或根本不傳遞。
當我們對進度而不是結果更感興趣時,或者在某些罕見的情況下,當不傳送訊息比傳送兩次訊息(物聯網感測器、跟蹤……)更多或同樣可以容忍時,這很有用。

以事務方式傳送 Kafka 訊息


3、恰好一次
每條訊息都精確傳遞一次,它為流處理應用程式等讀取-處理-寫入任務提供端到端的精確一次保證。為了支援這種保證,Kafka 依賴於兩個特性:

  • 冪等傳遞:允許生產者只傳送一次訊息(屬於同一生產者的重複訊息被代理忽略)
  • 事務交付:允許生產者以原子方式將資料傳送到多個分割槽,這意味著要麼所有事件都成功交付,要麼沒有一個。

以事務方式傳送 Kafka 訊息

您應該注意,此語義僅在 Kafka Streams 內部處理範圍內得到保證,其中包括使用事件、更新狀態儲存和生成事件。嘗試更新 Kafka 外部的狀態,例如更新資料庫中的行或進行 API 呼叫,將導致較弱的保證。

您可以在此處找到有關完全一次語義的更多詳細資訊。

使用現有解決方案時的限制
所有現有的語義在某些情況下可能有用,但在處理關鍵用例(如支付)時並不理想。
例如,考慮一個管理採購訂單的“訂單服務”微服務和另一個管理賣方餘額和付款的“支付服務”微服務。當客戶從賣家那裡購買書籍時,我們需要在幕後執行以下操作:

  • order-service : 在資料庫中建立一個訂單
  • order-service:向 Kafka 傳送事件以更新賣家的餘額
  • payment-service : 消費事件並更新賣家餘額

以事務方式傳送 Kafka 訊息


挑戰在於前兩個請求,在資料庫中插入訂單並向 Kafka 傳送事件。如果兩個呼叫中的任何一個失敗,我們需要回滾所有內容,因此要麼成功執行所有內容,要麼什麼都不執行。

讓我們看看當我們使用上面提到的語義時會發生什麼:

至少一次
使用這種策略有兩個缺點:

  • 資料重複:如果第一條訊息的確認丟失, order-service可能會向 Kafka 傳送兩條訊息,這幾乎不是問題,因為它可以透過使用冪等消費者來解決。
  • 資料不一致:即使資料庫事務回滾,order-service也可能會傳送 Kafka 訊息來更新賣家的餘額。或者反過來(如果您使用的是提交後策略),訂單服務可能會在傳送 Kafka 事件之前提交資料庫事務並崩潰。


最多一次
這是我們要使用的最後一個語義,因為我們不能丟失訊息。考慮以下場景:

  • order-service : 建立一個訂單
  • order-service : 傳送一個事件來更新seller1的餘額而不等待確認
  • Kafka:由於網路錯誤導致訊息丟失
  • payment-service : 不會更新seller1的餘額

您可以猜到這對我們的賣家來說不是一個幸福的結局。

恰好一次
正如我上面提到的,只有當您嘗試更新的資料位於 Kafka 中時,此策略才有效。這不是我們的情況,因為我們也與資料庫進行通訊。

我們如何在 Mirakl 解決這個問題
嘗試一次更新兩個系統在設計和實現方面都極具挑戰性,尤其是對於我們知道 Kafka 不支援分散式事務的案例。在 Mirakl,我們決定一次只更新一個系統,首先我們更新資料庫,然後使用非同步過程將這些更新從資料庫驅動到 Kafka。
這就是通常所說的發件箱模式。它可以被描述為一種幫助服務寫入自己的本地資料庫並以安全一致的方式與其他微服務交換資料的方式。

實現發件箱模式
如果我們回到我們的圖書購買示例,我們希望訂單服務在其本地資料庫中建立一個訂單,並以一致的方式與支付服務交換該資訊。
透過使用 outbox 模式,order-service會將 book1 訂單插入到 order 表中,並且作為同一事務的一部分,它還將在 outbox 表中插入表示要傳送的事件的記錄。
如您所料,我們需要一個非同步程式來驅動從發件箱表到 Kafka 的更新,該程式將從發件箱表中讀取事件並將它們傳送到 Kafka。

  • order-service : 在資料庫中插入訂單
  • order-service:在資料庫中插入 Kafka 有效負載
  • outbox-process:從發件箱表中讀取事件並使用至少一次策略將其傳送到 Kafka
  • payment-service : 消費事件並更新seller1的餘額


發件箱非同步流程
需要觸發發件箱非同步過程,以便實際傳播我們儲存在發件箱表中的事件。這需要儘快完成,以避免在order-service中的訂單建立和payment-service中的賣家餘額更新之間出現任何額外的延遲。有兩種選擇:

1、使用 Kafka 消費者觸發事件
這裡的想法是每次我們向發件箱表新增記錄時,在“觸發發件箱程式”主題中傳送一個事件(使用最多一次語義),觸發事件將被“發件箱程式”消費這將從發件箱表中獲取/刪除資料並將其傳送到 Kafka。

以事務方式傳送 Kafka 訊息


2、使用 Debezium 捕獲變更資料
如果您的基礎架構允許,您可以簡單地使用 Debezium,這是一種為變更資料捕獲提供低延遲資料流平臺的工具。您可以將其配置為監控發件箱表中的所有更改並將其流式傳輸到 Kafka。可以在此處找到有關其工作原理的更多詳細資訊。

以事務方式傳送 Kafka 訊息



處理資料庫膨脹
無論您選擇哪種解決方案,您都需要記住需要維護髮件箱表,您不能只是永遠插入行並期望整個過程正常工作。
為了防止發件箱表變大,您需要在確保成功傳送到Kafka之後刪除每一行。問題是,刪除行會導致膨脹,從而導致效能問題(查詢緩慢、Kafka 滯後)。幸運的是,可以透過監控膨脹並在每次膨脹超過一定限度時執行真空分析來輕鬆解決此問題。

這如何使它具有事務性
在深入研究使我們的解決方案具有事務性之前,我們需要建立一個共同的理解基礎,它就像一組公理,可以幫助我們構建和理解更復雜的表示式:

  • 如果成功傳送訊息到 Kafka,訊息最終會被消費(當然你需要確保訊息被所有副本複製,並且保留時間足以讓訊息在刪除前被消費)。
  • 如果您成功將訊息儲存到發件箱表,該訊息將被髮送到 Kafka。
  • 從上面的兩個表示式中,我們可以安全地假設:如果一條訊息被儲存到發件箱表中,它將被消費。

話雖如此,瞭解我們的解決方案如何是事務性的可以很簡單:
1、原子性
一切都被視為一個單一的單元,它要麼完全成功,要麼完全失敗。在我們的例子中,我們在同一事務中在 order 表中插入一行,在 outbox 表中插入一行,如果我們考慮到 outbox 表中的事件將被髮送並最終被支付服務消費,這就足夠了。如果事務回滾,則不會在發件箱表中插入任何資料,因此不會向 Kafka 傳送任何事件。

2、一致性
我們的資料整體是一致的,雖然我們建立了一個訂單沒有更新賣家餘額,但是我們在發件箱表中新增了負責更新賣家餘額的事件,所以一旦事件被消費,賣家的餘額最終會是更新。

3、隔離
在我們的例子中,我們的資料庫保證的隔離級別就足夠了,因為我們不會在 order-service 中從 Kafka 讀取資料。換句話說,如果兩個事務試圖購買最後剩下的書,那麼只有一個會成功,因為隔離是由資料庫保證的。
4、耐用性
Kafka 將跨複製分割槽的多個副本中的記錄儲存到基於磁碟的檔案系統中。這意味著如果系統崩潰,我們的資料不會丟失。

概括
在微服務時代,系統故障是不可避免的。作為開發人員,我們必須使我們的設計適應這種惡劣的環境,並始終牢記系統故障。在本文中,我描述了可靠訊息傳遞的問題,使用 Kafka 傳遞語義的侷限性,以及如何使用發件箱模式來解決這個問題,發件箱模式需要以下元件功能齊全:

  • 至少包含兩列的發件箱表(id UUID,有效負載:jsonb)
  • 將事件儲存到發件箱表的生產者
  • 從發件箱表中刪除事件並使用至少一次策略將它們傳送到 Kafka 的過程
  • 監控和修復膨脹問題的過程
  • 消費事件的冪等消費者

相關文章