那天帳對不平,我才發現我們把太多正確性賭在應用層——交易系統的資料庫設計反思
一筆狀態不該存在的訂單
那次的事故,是一筆「已完成」的訂單,卻沒有對應的付款紀錄。
對帳的時候撈出來,整個團隊都愣住。照業務邏輯,訂單要先付款成功才能變成已完成,這筆卻跳過了付款。查了半天,發現是某段程式在特定的併發時序下,繞過了狀態檢查,直接把訂單更新成了已完成。程式邏輯有漏洞,這是事實。但那天真正讓我後背發涼的體悟是:我們把「訂單狀態不能亂跳」這件事,完全賭在應用層的程式碼上,資料庫本身對這種髒資料毫無抵抗力。 只要應用層破一個洞,髒資料就直接進去了,資料庫照單全收。
這篇是我那次之後,對交易與金流系統資料庫設計的重新整理。核心的轉變是:別只靠程式碼守正確性,能讓資料庫幫你守的,就讓資料庫守。
正規化是起點,不是教條
先講正規化。它的價值是消除重複、避免一份資料存很多份然後對不起來。這是起點,該做。
但我不把它當教條。實務上為了查詢效能,我會有意識地做一些反正規化——比如把常用的統計值先算好存起來。重點是這種「故意的重複」必須是你清醒地為了效能做的取捨,而且要有機制保證它們同步,而不是設計不良造成的意外重複。正規化到什麼程度,是個判斷題,不是照抄範式。
金額欄位:永遠不要用 float
這條我用另一整篇文章講過慘痛經驗,這裡只放結論:金額欄位一律用 DECIMAL/NUMERIC,明確指定精度,絕對不要 FLOAT/DOUBLE。我對帳查過一整個下午的三分錢,就是浮點誤差累積出來的。金額不容許任何近似。
用資料庫的約束,別只靠程式
這是那次事故給我最大的改變。資料庫提供了一整套武器來保證資料正確,但很多人只把它當一個存資料的桶子,正確性全丟給應用層扛:
- 外鍵:保證關聯的資料真的存在,不會有一筆訂單指向一個不存在的使用者。
- 唯一約束:像「同一筆金流訂單號不能入帳兩次」這種,用唯一約束擋在資料庫層,比在程式裡查一次再寫一次可靠得多——後者在併發下就是會有兩個請求同時查到「不存在」然後都寫進去。
- NOT NULL、CHECK:把「這個欄位不能是空」「金額不能是負數」這種規則寫進 schema。
這些約束的價值在於:就算你應用層的程式碼有 bug,資料庫這一關還會幫你擋一次。 那筆髒訂單,如果當初狀態轉移有靠資料庫層的約束或條件更新來守,就進不來。多一道防線,就多一次活命的機會。
交易與隔離等級要想清楚
交易不是包起來就沒事。隔離等級決定了併發的交易之間會不會互相看到對方沒 commit 的資料、會不會有幻讀。金流場景我對這塊特別謹慎,該用悲觀鎖鎖住關鍵資料列的地方就用,別為了效能把隔離等級調鬆,然後在某個高併發的瞬間吃到你以為不可能發生的競態。那筆跳過付款的訂單,根子上就是一個併發時序沒守住的問題。
狀態用明確的狀態機
訂單這種有生命週期的東西,我現在一定把合法的狀態轉移定義清楚,並且用「條件更新」來落實——更新的時候在 WHERE 條件裡帶上「目前必須是某個前置狀態」,讓資料庫的那一次更新本身具有原子性,而不是先查詢、再判斷、再更新那種在併發下會出事的三步驟。這正是那次事故的解法。
索引是雙面刃
索引讓查詢變快,但每個索引都會拖慢寫入、佔空間。我的原則是照實際的查詢模式來建,而不是看到欄位就加索引。定期看慢查詢日誌,缺的補、沒用到的砍。別讓一堆沒人用的索引默默拖累你的寫入效能。
別忘了保留軌跡
金流跟交易系統,資料的「變更歷史」本身就是資產。誰在什麼時候把這筆訂單從什麼狀態改成什麼,最好都留得下來。出事的時候——像我那次對帳對不平——這些軌跡就是你追查真相唯一的依據。當初要不是有操作紀錄,我根本沒辦法還原那筆訂單是怎麼跳過付款的。
小結
那筆不該存在的訂單教我的,不是某個具體的設計技巧,是一個心態的轉變:資料的正確性,不該全部押在應用層的程式碼不出錯上。 程式一定會有 bug,人一定會犯錯,但如果你的外鍵、唯一約束、條件更新、狀態機都在那裡守著,一次應用層的失誤就不會直接變成一筆髒資料。把能交給資料庫守的正確性交給它,你晚上會睡得比較安穩。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。