Software Development·2026年4月15日

交易與金流系統的資料庫設計:我踩過的一致性與正規化坑

資料庫設計這種東西,平常感覺不到它的重要,直到對帳對不平、或一個查詢把正式環境拖垮的那一刻。這篇講我在交易與金流系統裡,關於資料庫設計實際踩過、也實際學到的幾件事,重點放在「正確性」與「一致性」,因為在管錢的系統裡這兩個最貴。

正規化是起點,不是教條

學校教正規化教得很用力,到了實務你會發現它是個取捨。我的做法是:預設先正規化,把資料的單一事實來源建好,避免同一筆資料散在多處、改了 A 忘了 B。等到某個查詢真的因為太多 join 而慢,再針對那個熱點去做反正規化或加快取。

順序很重要:先求對,再求快。一開始就為了效能把資料冗餘到處塞,等於提早把「資料不一致」這個更難的問題請進門。

金額欄位:永遠不要用 float

這點我講過很多次,但值得再強調:金額用 DECIMAL/NUMERIC,或用整數存最小單位,絕對不要用 FLOAT/DOUBLE。浮點存不下 0.1,對帳時一分錢的誤差都是要寫報告的事故。這是 schema 設計階段就要定下來、不能妥協的鐵則。

用資料庫的約束,別只靠程式

我看過太多人把「不能重複」「金額不能為負」這種規則只寫在應用層。問題是應用層有 bug、有併發、有人繞過。資料庫的約束是最後一道、也最可靠的防線

  • 唯一約束(unique):這是我做冪等的基石。同一個 idempotency key 併發進來,靠唯一約束擋掉重複,比在程式裡先查再寫可靠得多
  • 外鍵:維持關聯完整性
  • check 約束:擋掉不合法的值(例如金額非負)
  • not null:別讓「不知道」變成預設

把規則下沉到資料庫,是因為它在併發下仍然成立,而應用層的「先檢查再動作」在併發下幾乎一定會破。

交易與隔離等級要想清楚

涉及錢的多步驟操作要包在交易裡,要嘛全成功、要嘛全回滾。但更進階的坑是隔離等級:預設的隔離等級在高併發下可能讓兩筆交易同時讀到舊值、各自加減,造成「更新遺失」。

實務上我會針對關鍵的扣款/改餘額操作,用適當的鎖(例如 select for update)或更高的隔離等級,確保同一筆資料不會被兩個交易同時亂改。這要依情境權衡,因為鎖越強、併發度越低。

狀態用明確的狀態機

訂單、付款這種有生命週期的東西,我會把狀態設計成明確的狀態機,並在資料層限制只能合法地轉移(例如已退款不能再變成付款成功)。把「不可能的狀態」從一開始就設計成存不進去,比事後寫一堆檢查好得多。

索引是雙面刃

慢查詢多半是缺索引;但索引不是越多越好,每個索引都會拖慢寫入、佔空間。我的習慣是看實際的查詢模式(用 explain 看執行計畫)來決定加什麼索引,而不是憑感覺亂加。金流系統寫入頻繁,過多索引的代價是實在的。

別忘了保留軌跡

金流系統常常「不能真的刪資料」。我傾向用軟刪除或狀態標記,並且重要的金額異動都另外留一份不可竄改的流水(前面講後台稽核時提過)。出事時,能回溯「這筆錢經過哪些狀態」是無價的。

小結

我在交易/金流系統做資料庫設計的原則:

  • 先正規化求對,有實際熱點再針對性反正規化求快
  • 金額用 DECIMAL 或整數,永不用 float
  • 規則下沉到資料庫約束(唯一、外鍵、check、not null),別只靠應用層
  • 關鍵扣款用交易加適當的鎖/隔離等級,避免更新遺失
  • 用狀態機讓不合法的狀態存不進去
  • 依實際查詢加索引,別亂加
  • 保留異動軌跡,重要的錢不要真的刪

資料庫是整個系統的真相所在。它設計得好不好,決定了你某天能不能很有底氣地說「帳,是對的」。

相關文章