微服務架構下,餘額表誰能讀寫?從交易系統談 Data Ownership

分散式系統架構設計雜記

最近剛換到了新工作不久,新公司與前東家類似,有交易系統的業務存在:幫 user 建立訂單、加扣款、改狀態,核心邏輯圍繞著餘額在轉。

明明到了新的地方,加入了不同的團隊,但卻發現了一個幾乎一樣的 pattern:A、B、C 三個服務都在直接操作餘額表和訂單表,table 都沒有明確的歸屬,誰想操作就去操作。在前公司時每次看到這種架構都覺得哪裡不太對,在新公司看到一樣的 pattern 就更難忽視了。

本篇想從這個 pattern 出發,聊聊微服務架構下資料表的歸屬問題:同一個 table 到底該不該允許多個服務直接讀寫?以及如果只能透過 API 存取其他服務的資料表,如何解決跨服務一致性的問題。

兩種架構長什麼樣:共用資料表 vs 只能透過 API 存取其他服務的資料表

在進入故事之前,我們來把故事場景說明清楚。假設系統中已經有這三個服務:

  • user-service:管理用戶帳號資訊與用戶狀態
  • wallet-service:管理用戶餘額
  • order-service:管理訂單業務

當用戶下了一筆訂單流程大概包含:確認用戶狀態正常、對用戶扣款、建立訂單,這時候有兩種做法:

做法 1:每個服務只管自己的表,對外開 API 給其他服務調用

下單請求落到 order-service,由 order-service 進行:
  1. 呼叫 user-service API:確認用戶狀態
  2. 呼叫 wallet-service API:扣款
  3. 寫入自己 order-service 的 orders 表:建立訂單

三個服務各自擁有自己的資料表不共用。想要改別人的資料就呼叫對方的 API。這時問題就來了,萬一在某一步驟時調用其他服務 API 失敗了怎麼辦呢。例如用戶狀態正常、錢也扣款成功了,但在最後一步寫入自己 orders 表失敗了,那用戶的錢就此從系統中消失了(去超商提款機領錢結果手機跳通知錢扣了,在吐錢一刻大停電的概念 XD)。

為了簡單地克服這個問題,熟悉資料庫 ACID 特性的同事們第一直覺就是從工具箱搬出 DB Transaction 這個解方出來,請繼續看下面的做法 2。

做法 2:多個服務直接讀寫同一張表

下單請求落到 order-service,由 order-service:
  1. 直接撈 users 表確認用戶狀態
  2. 再去 update balances 表更新用戶餘額 (DB Transaction)
  3. 最後去 orders 表建立一筆新的訂單 (DB Transaction)
  4. commit transaction

表面上每個服務還是有自己的表,但各服務為了一致性的方便,直接跨過去讀寫別人的表,久了之後表的歸屬就模糊掉了。反正有用到就去 DB 直接撈,少了幾層 API 呼叫程式碼也比較少,開發快速又省 I/O 資料也不會錯,準時上線 PM 好開心^^。

理想上的服務與資料表歸屬:

  • user-service:users 表
  • wallet-service:balances 表
  • order-service:orders 表

實際上的服務與資料表關聯:

  • user-service:users 表、還會順便存取 balances 表
  • wallet-service:balances 表、還會順便存取 users 表
  • order-service:orders 表、還會順便存取 users 表、balances 表

服務間共用資料表雖然方便,但實際上會踩到的坑

共用資料表雖然在初期系統業務不複雜時很方便,但隨著規模越來越大,背後的問題會逐漸浮現:

業務規則散落各服務,沒有一個統一管理的地方

例如一般用戶的餘額不能扣到負數,但是暫收款類的記帳帳本餘額是允許負的;又或者用戶狀態為禁用則不允許交易,這些規則應該集中在一個地方執行。

但當 order-service 為了方便而直接存取 balances 表、user-service 也因為用戶儲值業務直接操作了 balances 表,每個服務都要再判斷一次相同的邏輯。某天, PM 發布了新的需求,讓 order-service 補上了新的驗證規則,卻忘記 user-service 也有操作 balances 表的邏輯理應也要補上,各服務的判斷邏輯就此開始分岔。

Schema 牽一髮動全身

原本系統很單純,只支援單一幣別且操作都是整數的金額,balance 等欄位用 BIGINT 存沒問題。後來因為有多幣別的需求,匯率換算讓金額出現小數點,欄位需要支援浮點數,BIGINT 不夠用了要改成 DECIMAL。

這個改動聽起來簡單,但因為 balances 表被四處操作,每一個服務有操作到 balance 欄位的地方都要跟著盤點一遍。服務一多很難保證沒有漏盤點到的地方,漏掉一處就直接噴 cannot scan numeric "XXX.XX" into *int。

擴展性受限

orders 表隨著歷年累積的訂單數量日益增大變成系統熱點,此時 backend team 考慮想把它搬到獨立的 DB、或是做 sharding 來分攤壓力,但因為建立訂單時的 DB Transaction 跨了 orders、balances 兩張表、甚至後台訂單列表功能也同時拿 orders 表去 join 了 users 表再做 filter、order by、offset、limit 樣樣來,此時會發現要拆他根本動不了,要動就要全部一起動,最後通常是誰都不敢動 (前東家真實血淋淋的例子)。

以上提到的服務間共用資料表的架構有個名字叫 Distributed Monolith:分散式單體架構 (名字聽起來超威)。服務是拆開了,但資料還是耦合在一起,拆了等於沒拆,同時匯聚微服務與單體架構的缺點。

每一張表都應該隸屬於特定一個服務 (Data Ownership)

確實在系統資料量級不大、不用考慮拆微服務拆分不同 DB 的情況下,共用資料表把所有操作包在同一個 transaction 裡非常方便。確認用戶狀態、扣款、建立訂單,一個 begin transaction 最後再 commit 即可搞定、出錯就 rollback,擁有 DB 的 ACID 保證,不需要處理任何分散式交易的複雜度。但當系統的資料量級開始起來後,這種資料關聯間的互相耦合,反而是限制系統 scale out 的絆腳石。因此,這段想要談資料的 ownership。

資料的 ownership 的意思是這個服務負責這張表的業務規則、決定 schema 怎麼演進、確保資料的正確性。以前面的例子來說:

  • users 表:user-service 擁有
  • balances 表:wallet-service 擁有
  • orders 表:order-service 擁有

如果其他服務需要存取,必須透過該服務對外提供的接口存取,不允許直接碰其他服務的表。例如 order-service 需要扣款,不是直接寫 balances 表,而是呼叫 wallet-service 對外提供的 API 操作。wallet-service 在自己的 API 裡檢查餘額是否足夠才決定要不要扣。order-service 在扣款前需要確認用戶狀態也是透過 API 的方式呼叫 user-service 詢問用戶狀態是否正常。

服務對外提供的接口是邊界,也是業務規則唯一的入口。外部服務不需要知道 balances 表長什麼樣,只需要知道打這隻 API 能扣款,並且取得結果是成功還是失敗。熟悉 DDD 的讀者可能已經認出來了,這個概念在 DDD 裡對應的就是 Aggregate 與 Bounded Context。

至於怎麼判斷一張表該歸誰呢,有一個簡單的方法,當你要新增一個跟訂單有關的功能,你第一直覺會去開哪個服務的 repo?當線上出問題時用戶反應扣款完的餘額跟下單金額怎麼對不上時,第一反應會去哪個服務找 log 看邏輯?如果答案很猶豫或是每次都不一樣,通常就是 data ownership 不清楚的訊號。好的 data ownership 拆分應該讓這個直覺很穩定。

當選擇拆服務透過調用 API 的方式存取資料後也不是一撈永逸,接下來我們來談談拆服務後會遇到幾個問題。

常見問題 1:分開打多個服務增加 I/O 會比較慢是個假議題嗎?

服務要快 latency 要低通常都是在想辦法降低 I/O,你怎麼反其道而行,為了所謂的 data ownership 而多繞去打一個服務呢?這是提出 data ownership 的概念最常聽到被 challenge 的點。

對於多繞一個服務會增加 latency 這件事首先要釐清慢在哪。多一次 API 呼叫,實際上增加的延遲大概是多少?同一個 cluster 的內網 gRPC 呼叫來回大約 1-2ms。一筆交易本身的 DB 寫入、網路傳輸、業務邏輯加起來可能是 20-50ms,多 2ms 換取清晰的 data ownership,對後續開發的可維護性來說是划算的。

真正需要解決的問題是分散式交易:data ownership 歸屬到不同服務後,怎麼確保扣款成功、訂單也一定建立成功,而不會出現錢扣了但訂單沒建起來的不一致狀態呢。以下舉了兩個例子及解法,請繼續看下去。

常見問題 2-1:跨服務一致性怎麼解決呢?TCC (Try、Confirm、Cancel)

服務拆開各自管自己的表之後,下單流程變成以下幾個主要的步驟:

order-service:
  1. 呼叫 user-service API:確認用戶狀態
  2. 呼叫 wallet-service API:扣款
  3. 寫入自己 order-service 的 orders 表:建立訂單

呼叫 wallet-service 扣款及寫入自己的 orders 表建立訂單的這兩步現在是獨立的操作,沒辦法包在同一個 DB Transaction 裡了。萬一扣款成功了,但寫入 orders 表的時候 order-service 突然掛掉,用戶的錢就憑空消失了。這就是跨服務一致性的核心問題。

TCC (Try、Confirm、Cancel) 是其中一種解法,把一個操作拆成三個 phase:

  1. Try:檢查可行性並且預留資源
  2. Confirm:確認執行,把 Try 預留的資源正式生效
  3. Cancel:放棄執行,把 Try 預留的資源釋放回去

套進下單扣款的例子:

order-service:
  1. 呼叫 user-service API:確認用戶狀態
  2. 呼叫 wallet-service API:扣款、同時新增一筆餘額變化紀錄 (Try 階段)
     (requestID: 001, userID, type: 下單扣款, amount: 100, status: 'pending', created_at: NOW())
  3. 寫入自己的 orders 表:建立訂單
  4. 呼叫 wallet-service API:將剛剛餘額異動紀錄的 pending 狀態改成 completed (Confirm 階段)

這就是 TCC 的 happy path 沒問題。步驟 2 的扣款和新增餘額變化紀錄這兩個操作都發生在 wallet-service 內,所以可以使用 DB Transaction 包起來確保一致性。接著我們來看看如果今天 order-service 在進行第 2-3 步間死掉或第 3-4 步間服務 crash 重啟該怎麼辦呢?

答案是我們還需要一個 wallet-service 的 cron job 專門對餘額變化紀錄表掃描狀態還在 pending 的餘額變化紀錄:

WHERE
    created_at < now() - INTERVAL '5 Minutes'
AND status = 'pending'
FOR UPDATE SKIP LOCKED

接著根據 type 拿 request_id 去對應服務確認應該要補走完 Confirm 還是 Cancel 邏輯。以我們剛剛的下單扣款行為,wallet-service 會去 order-service 詢問是否有成功建立 requestID: 001 的這筆訂單:

  1. 如果有的話,wallet-service 就會補做 Confirm 行為將這筆餘額的 status 從 pending 改為 completed。
  2. 如果沒有的話,wallet-service 就會補做 Cancel 行為將這筆餘額的 status 從 pending 改為 cancelled。並且將原本扣款的金額加回去,外加寫一筆狀態為 completed 的餘額變化紀錄對應這筆沖正。

這就是 TCC 完整的流程。題外話,有些讀者可能也會有疑問,為什麼這邊的流程要先扣款再建訂單,不能反過來嗎?

答案是可以的,但是如果先建訂單再扣款會有一種情境是系統需要再額外 handle 訂單建了但餘額不足的 case。更麻煩的是這個設計對惡意行為幾乎沒有防禦,用戶可以無限建立訂單佔用系統資源,反正扣款失敗頂多訂單作廢。先扣款的設計讓餘額成為建立訂單的門檻,餘額不足就在 Try 階段擋掉,訂單根本不會進來。當然如果產品設計上希望先建訂單、讓用戶看到一筆餘額不足,建立失敗的紀錄,那流程反過來也是合理的,只是要多處理那個失敗狀態。

常見問題 2-2:跨服務一致性怎麼解決呢?Transactional Outbox Pattern

TCC 解決了同步流程的一致性問題,但有些操作其實不需要到即時完成。舉下一個例子,扣款完成訂單創建後要打外部第三方 webhook 發出交易結果,第三方那邊晚個幾秒甚至幾分鐘收到交易結果完全可以接受,只要能保證在未來一定時間內必會收到訊息即可。這種只要滿足最終一致性的非同步情境有另一種叫 Outbox Pattern 的方法可以使用。

Outbox Pattern 的概念是把主要操作跟等一下要做的操作 job 包在同一個 DB Transaction 一起 commit。這樣除了確保如果主要操作完成了,必定會有等一下待處理的操作 job 存在。下一段會由 cron job 把剛剛寫入但還沒做的 job 撈出來做。

我們拿剛剛下單扣款的例子來延伸,再加上創建訂單後還需要打第三方 webhook 去通知交易狀態:

order-service:
  1. 呼叫 user-service API:確認用戶狀態
  2. 呼叫 wallet-service API:扣款、同時新增一筆餘額變化紀錄 (TCC Try 階段)
  3. 寫入自己 order-service 的 orders 表:建立訂單 (DB Transaction)
  4. 寫入自己 order-service 的 outbox 表:新增一筆 outbox 紀錄 (DB Transaction)
     (requestID: 001, type: 訂單扣款通知, payload: { orderID, userID, amount }, status: 'pending', retry_count: 0, created_at: NOW())
  5. commit transaction
  6. 呼叫 wallet-service API:將剛剛餘額異動紀錄的 pending 狀態改成 completed (TCC Confirm 階段)

這次多了步驟 4 寫 outbox 的表,並且步驟 3、步驟 4 的建立訂單和寫入 outbox 是包在同一個 transaction,要麼都成功要麼都不做,不會出現訂單建了但 outbox 沒寫進去的情況。

commit 完成後輪到 cron job 起床工作了:

order-service-outbox-worker:
  掃描 status = 'pending' AND retry_count < 5 的 outbox 紀錄
  1. 對第三方打 webhook
  2.1. 成功:將 outbox 紀錄 status 更新為 completed
  2.2. 失敗:retry_count + 1,留著 pending,下次繼續重試
         retry_count >= 5:status 更新為 failed,停止重試,等待人工介入

為了變免無限 retry (有可能是參數有問題所以對方一直 reject),所以我們也加入了 retry_count 機制。如果某筆紀錄送第三方一直 fail,那當達到重送次數上限後就不再發送,留給人工介入排查 (很 fail 到讓系統無法程式化走完流程,只好出動工人智慧)。

以上我們舉了兩個例子介紹解決跨服務一致性的技巧,也展示 TCC 跟 Outbox Pattern 不是互斥的,上面的例子就是兩個混用的情況,同步的扣款流程用 TCC 確保一致性,非同步的 webhook 通知用 Outbox 處理。實務上根據場景需要即時回應還是可以接受延遲,選對工具就好。

結論

回到開頭說的,在兩份工作、兩個不同的團隊看到幾乎一樣的 pattern,現在回頭想想會這樣其實不意外。共用資料表在系統早期真的很方便,少了一堆 API 呼叫、少了分散式交易的複雜度,開發快、上線快,在有任務 deadline 的情況下,大家都會有先能動再說的念頭,自然就走這條路了。

當然,TCC、Outbox Pattern 也不是零成本,引入這些模式會需要在系統中多寫 cron job 去檢查處理在中間狀態的資料。但其實這些複雜度是可以被管理的,現在也有專門處理分散式服務一致性的框架例如 Seata、Temporal,之後有機會再寫一篇文章介紹這兩個框架。

每張表的 owner 該歸誰,最好是在系統還小的時候就考慮清楚。如果系統還沒到要拆服務的階段,monorepo 裡先用模組邏輯拆分也是一個過渡的方式。等系統大了再來還 Distributed Monolith 這筆債,代價只會更高。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *