透過 Payment Service 與 DB Isolation Level 成為莫逆之交


最近這陣子在 Codementor,我們花了很多時間痛改 Payment Service。 過程中會要考慮到各種可能的 race condition,和外部 API 的一致性等等。 在這篇文章裡面,我試著把一些團隊一起遇到的問題和解法簡單的歸納出來。

情境介紹

在 Codementor, 我們有各種不同的 “要被付錢的產品”。包括 1-1 的線上即時教學、小型或大型的 freelance job 等等。 每個產品各自有自己的 life cycle, 並且要各自在不同的階段收取 user 費用。 同時對於 user 來說,他可以自由選擇用信用卡、Paypal、ACH 等方式付錢。 身為一個 marketplace,在產品的各種時候會要容許各種的 special case。 好比說如果有 user 有很特殊的需求,我們可能甚至要可以讓他直接匯錢給我們, 然後我們再想辦法把這樣的東西 model 進我們現有的邏輯裡面。

技術上,我們的 application layer 是 Ruby on Rails, DB 則是 Postgres。

在付費的情境裡,大多數 “真的 charge user” 這個步驟都是透過外部的服務,像是 Stripe, Paypal 等。 而我們要做到的,就是

  • 設法讓這些外部服務的狀態,不管在成功或是失敗的狀況下,都可以精準地和內部的 application logic 同步
  • 如果有 race condition,也要設法在關鍵的時候可以擋下來。

接下來要提到的各種狀況,都是為了要達成上面兩個目標。☝️

Isolation Level

在開始之前,要先簡單的介紹一下什麼是 Isolation。根據 wiki 上的說法:

In database systems, isolation determines how transaction integrity is visible to other users and systems.

翻成白話文大致上是,如果同時間在 DB 裡有一個以上的 transactions 在進行,那他們彼此之間可以 access 到對方的”暫態”的程度,就是 isolation。

而為什麼上面會用”程度”來形容這件事情呢?因為實際上,transaction 之間能不能 access 到對方的”暫態”,在大多數的時候並不是一個 0 或 1 的選擇。 而會是 “如果怎麼樣的話就可以 access 到, 而如果怎樣的話就不行” 這樣的作法。 會要在 0 和 1 兩個極端中間加入不同的程度,主要的原因是希望在 data 的一致性(consistency) 和 database 的效能之間取得一個平衡。

舉例來說,如果今天我們完全限制不同的 transaction 之間完全不能 access 到對方的 data。最直接的方式就是同一時間 database 只允許有一個 transaction 發生。這樣的話我們可以確保 data 是完全一致的,但顯然的 database 的效能會變得很糟,因為所有其他的 transaction 都因此要排隊。

而上面提到的不同 “程度”,就是 isolation level。由 ISO SQL 定義出來的 Isolation level 有下面四種: (註:下面只是很簡略的介紹,實際上它們都有很精密的定義,各個 db 也都各自有不同的 implementation)

  • Serializable:這是最高級的 isolation level。基本上可以想像使用這樣 isolation level 的 transaction,概念上可以視為同時間只有它一個 transaction 存在。
  • Repeatable Reads:對於該 transaction 內讀過的 data, 在 transaction 的過程中不允許有別的 transaction 改動到這些 data。
  • Read Committed:Transaction 的 data 如果 commit 了,就能被別人 access 到。換句話說,在這樣個 isolation level 下,不會有 “讀到別人還沒 commit 的 data” 這回事(dirty read)
  • Read Uncommitted:允許上面的 dirty read

事實上,任何一個 transaction 可以指定一個對應的 isolation level。而當我們沒有特別指定的時候,database 會用預設的 level 去執行。以 Postgres 來說,default 的 isolation level 是 Read Committed

Hello Payment Service

在簡單地介紹完 isolation level 之後,接下來會透過我們實作 Payment Service 當中遇到的情境來討論 DB 的設計和 isolation level 的使用。

在所有的狀況下,我們的設計是以下面的目標出發的:

  • 如果有一件事情失敗了,那我們希望 data 可以讓我們可以知道當初是死在哪邊。
  • 如果有一件事情成功了,那我們希望 data 有確實的記錄下來。

情境1:單純的 external request

先從最單純的 external request 開始。

情境是:假設我們想要發一個外部的 http request (好比說 charge user),並且在成功後,把結果記錄下來。

直覺的作法是:

  1. 發送 external request。
  2. 把結果記錄在 data base 裡面。

這樣的作法在一切順利的時候,是沒有問題的。 但是如果說在第二步寫入 DB 的時候,因為各種邏輯上的原因失敗了,那第一步的結果在 DB 裡面就無法追蹤到。

在 Payment 的情境下,如果第一步是 charge user 的信用卡並且成功了,但第二步沒有記錄成功的話, 那會變成我們 charge 了 user, 但 DB 裡面並不知道這件事情的存在。這樣在後續要處理問題的時候會是相當不利的。

而如果把 1, 2 的順序交換也不是辦法,因為我們不確定 external request 是不是能成功。 如果我們先把 data 寫進去,結果 external request 卻失敗那就糗惹。

以我們的情境,解決的方法是:

  1. 在 database 裡面多加一個欄位叫 charge_state,用來記錄這筆交易的狀態。一開始設成 pending
  2. 開始一個 transaction, 並且在裡面:
    • charge_state 改成 charged
    • 發送 request
    • 把結果記下來

在邏輯上,會設計成把衝突的檢查放在第一步和 2.a, 也就是說,在 2.c 這個步驟是幾乎不用邏輯上的驗証的。

這樣一來,

  • 如果第一步的邏輯驗証就失敗了,那自然不會發生後續的 2.a-c,也不會真的 charge user。
  • 如果 charge user 失敗,那步驟 2 的 transaction 就會 roll back,於是 charge_state 就又會回到 pending,於是我們在 DB 就留下了一個 “charge 失敗” 的線索可以追溯

情境2:多筆紀錄共同維持另一筆紀錄的一致性

有時候我們會由多筆紀錄共同來維持另一筆紀錄的一致性。如下圖:

multi-record-consistency

情境:我們有多筆的 credit 購買紀錄,並且每個 user 有一筆 “credit 總和” 的紀錄。任一個 user 的 “購買紀錄” 的值加起來,要等於他的 “credit 總和” 的值。假設現在我們要新增一筆 “credit 購買紀錄”:

在這個情境下,直覺的作法會是:

  1. 送 external request 去 charge user
  2. 建立 “credit 購買紀錄”
  3. 加總並修改 “credit 總和紀錄”

但是在這樣的作法之下,會遇到的問題有:

  1. external request 可能會失敗,或者是建立 “credit 購買紀錄” 的邏輯可能會有衝突。解法如情境1所述。
  2. 在修改”總和紀錄” 的時候,可能會有 race condition。好比說,假設目前的總和是100。如果同時間有兩個 request 要加總,一個是 30,一個是 50。這力候前者會認為他應該要把總和改成 100 + 30 = 130,後者會認為他應該要改成 100 + 50 = 150。但不管最後誰先成功,結果都是錯誤的,因為正確的結果應該要是 100 + 30 + 50 = 180

在這樣的狀況,我們的解法如下圖:

multi-record-consistency-design

  • 首先,如 情境1 所述,我們先建立一筆 pending 的 “購買紀錄”,再用一個 Read Committed 的 transaction 把 external request 跟寫入結果的行為包起來。這個時候,”總和紀錄” 還沒有被更新。
  • 再者,我們在 “購買紀錄” 上面加入一個 merge 的概念,意思是 “這筆購買紀錄有沒有被加總”
  • 接著,我們建立一個 Repeatable Read 的 transaction, 在其中:
    • 先讀取 “總和紀錄”
    • 修改 “總和紀錄” 到正確的值
    • 把 “購買記錄” 上面的 merged 改成 true

這樣一來,假設有 race condition 發生的時候,因為上面的 c 步驟有指定 Repeatable Read 的 isolation level,比較慢的那個 request 就會失敗。 這時候,我們的 DB 會留下一個 merged = false 的 “購買紀錄”,可以讓我們做後續的處理。

情境3:多筆紀錄共同被另一筆紀錄限制

第三種情境和第二種有點類似,但不同的點是,兩類紀錄的一致性是某種限制。如下圖:

charge-refund-db

舉例來說,我們有一筆購買紀錄(Charge), 可以分次退款。並且我們要把每次的退款行為(ChargeRefund)都紀錄下來。Charge 上面會記錄一開始的金額和退款後的總金額。而ChargeRefund的總合 不能超過 本來 Charge 的值。

好比說我們有一筆 Charge 本來是 100 元,然後我們第一次退了 20,第二次退了40,於是最後的 Charge 上面會記下 amount = 100, refunded_amount = 60,並且我們會有兩筆 ChargeRefund, 分別是 20 和 40 元。

這種情境下,直覺的作法會是:

  1. 送 external request 進行退款
  2. 新增 ChargeRefund
  3. 修改 Charge

但這樣會遇到的問題有:

  1. external request 有可能會失敗(解法如情境1)
  2. Race condition. 假設有兩個 request 同時想要 refund 一個本來是 100 元的 Charge。第一個 request 想要 refund 50 元,第二個想要 refund 60 元。兩個 request 一開始看到 Charge 的時候,都認為自己可以進行 refund。但最後的結果卻會是退了 110 元。

面對樣的問題,我們的作法如下:

charge-refund-design

  1. 首先用 Serializable 的 transaction,建立一個 pendingChargeRefund
    • 先 query Charge 下面對應到的 ChargeRefund,並且確認金額沒有超過
    • 建立 ChargeRefund
  2. 開始一個 Read Committed 的 transaction, 在裡面送 external request,改 ChargeRefund 的狀態
  3. 開始一個 Repeatable Read 的 transaction:
    • 先 query Charge
    • 修改 Charge 上面的 refunded_amount

在第一步用 Serializable 的原因是,我們這步面對 race condition 的時候,在 concurrent 的 request 之間,沒有一個共同都會修改到的 record。這時候 Repeatable Read 是不夠的。

這樣的設計下,有兩個地方可能有 race condition,

  • 如果在第一步的時候有 race condition, 那 Serializable 可以幫我們擋住,可以幫我們避免 refund 超過本來 Charge 的金額的狀況。這時候慢的那個 request 會直接失敗,不會留下任何的紀錄,但因為這步失敗的話沒有別的 side effect (沒有建立 record, 沒有送 external request),所以還可以接受。
  • 如果在第三步的時候有 race condition, 那 Repeatable Read 會幫我們擋住,這邊我們會在 ChargeRefund 加上一個 merged 的狀態(同情境2),可以幫助我們做後續的處理。

設計的方法

上面大概是簡單地把我們遇到的問題歸納出來。 而實際上要把一個功能做出來的時候,要怎麼切入呢?

1. 先了解要做的功能對於錯誤的容忍程度

每個功能在不同產品的情境下,對於各種錯誤的容忍度是不同的。

舉例來說,大部份的 “付費” 功能,對於”重覆 charge user” 都是不能忍受的。 並且如果說付費的過程有任何環節出了錯,我們會希望有最多的資訊,可以還原當時的情況。 但如果是留言的功能,可能讓某些使用者偶而重覆留兩次,可能不是什麼太不能忍受的事。 這部份會和 business logic 有高度的相關。 我認為,了解這個部份非常的重要。因為在越嚴謹的實作下,意味著複雜度也跟著提高。 所以如果一個功能根本對錯誤的容忍度很高,或者錯誤發生的情境(好比說 race condition)的機率很低, 那這時候如果沒有想清楚,就很容易發生 over design 的狀況,反而讓整個系統更難維謢。

所以在做一個功能之前,我們會先問自己:

這個功能的整個過程,對各種可能遇到的錯誤的容忍度為何?或者換句話說,在過程裡面的每個步驟,我們最不希望哪個環節出錯?

2. 從最直覺的作法出發

在了解各種限制和錯誤的容忍度之後,我們通常會先想一個最直覺的作法。 然後一步一步去檢視,如果這步發生問題,那現在的作法

  • 得到的結果是我們可以接受的嗎?
  • 可以讓 data 留在一個可以 trace 的狀態嗎?

如果不行的話,再一步一步調整,最後得到一個可以滿足我們情境上需求的解法。

結尾

耶!打完收工!

我覺得基本上扯到跟時間相關的東西(像 race condition),就會把事情變得蠻複雜的。 但在考慮這些細節的同時,也對整個系統的運作越來越了解。 其實在很多時候,我相信我們還是沒有得到一個 “完美” 的解法, 但就像上面提到的,大多數的時候我們需要的是一個 “可以滿足現有需求” 的,而不是 “完美” 的解法。

有一些 reference 我覺得很不錯,也附在下面跟大家分享。

references: