Image

今天放假有整個早上的時間,決定來整理 Discord 上面大大貼的香香文章:
Avoiding Double Payments in a Distributed Payments System

裡面提到實現最終一致性的三種方式:

  • read repair
  • write repair
  • asynchronous repair

Airbnb 他們在不同的地方都有應用到這三種方式,這篇主要是介紹使用了 write repair 的解決方案。也就是每次客戶端向服務器發出寫入請求時,都會嘗試修復不一致、破損的狀態。

為了讓客戶端能夠自動重試,就需要讓 API 是具有冪等性的:重複發出相同的 Requset,結果也會保持一致。而因為他們需要極低的延遲,不能拆服務出來跑,所以他們弄了一個叫做「奧菲斯(Orpheus)」的 library 來處理這件事

這邊整理了一些我(覺得)可能會遇到的相關內容,有興趣的朋友也可以閱讀原文,文章內有許多圖片來進行說明,值得一讀。


把資料庫操作和網路請求拆到不同階段

整個 API 請求會被重構成三個部分:

  • Pre-RPC:把 Request 的詳細資訊存到 DB
  • RPC:進行網路請求
  • Post-RPC:把 Repsonse 內容、是否成功、能不能重試塞到 DB 裡

因為資料庫已經提供了 ACID,並且只會產生兩種結果(成功或失敗)。因此 Pre-RPC 和 Post-PRC 對資料庫的操作都會包成 Transaction,確保一起成功或失敗。文章內示範了使用 Java 的 Lambda 來把多個操作包在一起(C# 應該用 EFCore 就可以了?)

延伸閱讀:淺談關聯式資料庫與ACID特性

除此之外,他們還嚴格地將資料庫操作和網路互動拆開,藉此降低風險

To maintain data integrity, we adhere to two simple ground rules: 為了保持數據的完整性,我們遵循兩個簡單的基本原則:

  1. No service interaction over networks in Pre and Post-RPC phases 預先和後續的RPC階段中,網絡上沒有服務互動

  2. No database interactions in the RPC phases 在 RPC 階段中沒有數據庫交互


把錯誤分類為可重試和不可重試

為了確認能不能夠重試,所有的錯誤都要被分類(當然也會遇到一些比較模糊的):

  • 伺服器或網路異常這類的,這些錯誤應該是暫時性的,基本上應該要可以重試
  • 但如果是退款失敗這種,就需要歸類到不可重試,並且標記起來

In general, we believe unexpected runtime exceptions due to network and infrastructure issues (5XX HTTP statuses) are retryable. We expect these errors to be transient, and we expect that a later retry of the same request may eventually be successful.

一般而言,我們認為由於網路和基礎設施問題而引起的意外運行時異常(5XX HTTP狀態)是可重試的。我們預期這些錯誤是暫時性的,並且我們期望稍後重新嘗試相同的請求可能最終會成功。

We categorize validation errors, such as invalid input and states (for example, you can’t refund a refund), as non-retryable (4XX HTTP statuses) — we expect all subsequent retries of the same request to fail in the same manner. We created a custom, generic exception class that handled these cases, defaulting to “non-retryable”, and for certain other cases, categorized as “retryable”.

我們將驗證錯誤分類為不可重試的錯誤,例如無效的輸入和狀態(例如,無法對退款進行退款),這些錯誤屬於非可重試的類型(4XX HTTP狀態碼)- 我們預期同一請求的所有後續重試都會以相同的方式失敗。我們創建了一個自定義的通用異常類,用於處理這些情況,默認為“不可重試”,對於某些其他情況,則歸類為“可重試”。


冪等鍵的選擇

為了保持冪等性,Client 端操作的時候需要建立一組 Key 並存到 DB,後續重試的時候也必須同樣使用這組 Key。這組 Key 會拿來當鎖,避免使用者或多個客戶端正在重試導致重複執行(這個鎖的持續時間通常會比 RPC 的 Timeout 時間還長)

選擇冪等鍵的時候,可以考慮 request-level 或是 entity-level:

  • 例如想要對同一組訂單允許多次付款,那麼每一次付款的 Key 的 Request 是不同的,這時候 可以使用 UUID 這類的格式
  • 但如果需要操作某個實體,例如某一組訂單,這時候就要根據這個實體來組成 Key,例如 “payment-1234-refund”(總覺得這段有點像是 DDD 訂 Entity 的 ID 的感覺?)

除此之外,他們還使用冪等鍵來對資料庫進行分片解決擴展性的問題:

The idempotency keys we use have high cardinality and even distribution, making them effective shard keys.


紀錄回應

前面提到的 Post-RPC 會把 Response 內容都存下來,後續重試的時候就可以更快回應(?),但資料表會長太快,所以可以考慮定期刪除太久的資料。


避免使用複製資料庫

如果客戶付了錢然後沒收到 Reponse,這時候主要資料庫已經存好資料了,但還沒同步給複本,然後客戶無情重試,剛好檢查了複本資料,結果就重複付款 (感覺就是選擇最終一致性而非即時性一定會遇到的問題,但這是不是代表不能學 CQRS 把 DB 拆成讀寫分離?)


文章內還提了蠻多部分,例如他們面對的問題(不能對最終一致性妥協,並且因為需要低延遲而不能獨立建個服務等等),並且附上了許多循序圖來說明流程,我個人已經收藏起來了。不過為了達成這些目標,系統的複雜性也會上升,看完他們的處理方式感覺也長知識了

那麼,今天的轉貼就先到這邊。明天見 >< 感謝大大分享的文章,也感謝 Heptabase + Readwise 拯救了我 QQ