2022年8月23日
點數系統為什麼不能直接改餘額?用交易紀錄、冪等鍵與條件更新設計一個對得起帳的點數帳本
直覺的設計:一個餘額欄位
做 B 端刊登平台時,房仲經紀人要先儲值點數,再用點數購買刊登、置頂等方案。最直覺的資料模型是在會員資料表上放一個 Balance 欄位:
-- 扣點
UPDATE Members SET Balance = Balance - 300 WHERE MemberId = @MemberId;
這個設計在開發環境裡跑得很好,但只要和「錢」有關,上線後一定會遇到這三個問題:
- 重複扣點:使用者連點兩次「購買」、前端逾時自動重送、或是訊息佇列重複投遞,同一筆購買被扣了兩次
- 併發覆蓋:如果程式先
SELECT出餘額、在程式裡算好、再UPDATE回去,兩個請求同時進來時,後寫的會蓋掉先寫的 - 對帳對不起來:客服接到「我的點數怎麼少了」,資料庫裡只有一個「現在是 600」的數字,完全不知道它是怎麼變成 600 的
換個角度:交易紀錄才是真相
銀行的存摺不是只印「餘額」,而是一筆一筆的存入、提出,餘額只是最後一欄的加總。點數也應該這樣設計:
- 交易紀錄(Ledger):每一次點數異動都新增一筆紀錄,包含異動數量、類型(加值、扣點、退點)、原因、關聯的訂單。紀錄只新增、不修改、不刪除
- 餘額快照(Balance):為了查詢效率,另外維護一個目前餘額。但它只是快取,隨時可以由交易紀錄重新加總出來
CREATE TABLE PointTransactions (
Id BIGINT IDENTITY PRIMARY KEY,
MemberId INT NOT NULL,
RequestId VARCHAR(64) NOT NULL, -- 冪等鍵
Type TINYINT NOT NULL, -- 1 加值 / 2 扣點 / 3 退點
Amount INT NOT NULL, -- 加值為正、扣點為負
OrderId INT NULL,
Note NVARCHAR(200) NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_PointTransactions_RequestId UNIQUE (RequestId)
);
CREATE TABLE PointBalances (
MemberId INT PRIMARY KEY,
Balance INT NOT NULL CHECK (Balance >= 0)
);
錯了也不刪紀錄:扣錯點,就新增一筆「退點」;退錯了,再新增一筆「扣點」。每一點的來龍去脈都留在紀錄裡。
冪等鍵:同一個請求只生效一次
「冪等(Idempotent)」的意思是:同一個操作做一次和做很多次,結果都一樣。
做法是讓每一次購買都帶一個唯一的請求識別碼(RequestId),例如前端在打開結帳畫面時產生一個 UUID,或直接用訂單編號組成 order-{OrderId}。這個值寫進交易紀錄,並設定唯一索引:
- 第一次請求:交易紀錄寫入成功,點數扣除
- 重複的請求:寫入時撞到唯一索引,整個交易失敗、點數不會再扣一次。程式捕捉到這個錯誤後,直接回傳「第一次的結果」給呼叫端
為什麼不在程式裡先查「這個 RequestId 有沒有處理過」再決定要不要寫?因為「先查再寫」在兩個請求同時進來時,兩邊都會查到「沒有」,然後都寫進去。把判斷交給資料庫的唯一索引,才是真正可靠的保證。
條件更新:避免併發覆蓋與扣成負數
餘額快照的更新不要「讀出來、算好、寫回去」,而是讓資料庫在一個陳述式裡完成判斷與更新:
BEGIN TRANSACTION;
-- 1. 先寫交易紀錄(重複請求會在這裡被唯一索引擋下)
INSERT INTO PointTransactions (MemberId, RequestId, Type, Amount, OrderId, Note)
VALUES (@MemberId, @RequestId, 2, -@Cost, @OrderId, @Note);
-- 2. 條件更新餘額:餘額不足就不更新
UPDATE PointBalances
SET Balance = Balance - @Cost
WHERE MemberId = @MemberId
AND Balance >= @Cost;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION; -- 餘額不足,連同交易紀錄一起回滾
THROW 50001, 'Insufficient points', 1;
END
COMMIT TRANSACTION;
幾個重點:
Balance = Balance - @Cost是在資料庫內原子地計算,兩個請求同時扣點,資料庫會依序執行,不會互相覆蓋WHERE Balance >= @Cost讓「檢查餘額是否足夠」和「扣除」變成同一個動作,沒有「檢查完、還沒扣之前,被別人先扣走」的空窗- 交易紀錄和餘額在同一個資料庫交易內更新,要嘛都成功、要嘛都失敗,兩者永遠一致
- 資料表上的
CHECK (Balance >= 0)是最後一道防線,就算程式寫錯,資料庫也不允許出現負數餘額
對帳:餘額永遠可以重新算出來
因為交易紀錄是真相,對帳就是一條 SQL:
SELECT b.MemberId, b.Balance, t.Total
FROM PointBalances b
JOIN (SELECT MemberId, SUM(Amount) AS Total
FROM PointTransactions
GROUP BY MemberId) t ON t.MemberId = b.MemberId
WHERE b.Balance <> t.Total;
這條查詢應該永遠回傳 0 筆。可以把它做成每天跑一次的排程,一旦有資料就發出告警。客服遇到「點數怎麼少了」的問題,也只要列出這位會員的交易紀錄,每一筆異動的時間、原因、關聯訂單都一清二楚。
使用情境:B 端刊登平台的點數
在 B 端房仲刊登平台中,所有付費方案都以點數購買:
| 動作 | 交易紀錄 | RequestId 範例 |
|---|---|---|
| 經紀人信用卡儲值 1,500 點 | 加值 +1,500 | topup-{金流交易編號} |
| 購買「刊登 1 件 90 天」 | 扣點 −300 | order-{訂單編號} |
| 購買「關注焦點 7 天」 | 扣點 −450 | order-{訂單編號} |
| 方案取消 | 退點 +450 | refund-{訂單編號} |
儲值的 RequestId 使用金流服務回傳的交易編號:金流的付款結果通知(Callback)常常會重送好幾次,有了冪等鍵,不管通知來幾次,點數都只會加一次。
一句話總結
和錢有關的數字不要只存「現在是多少」,而是把每一次異動記成一筆只增不改的交易紀錄,餘額只是可以隨時重算的快照;再用唯一索引當冪等鍵擋掉重複請求、用條件更新防止併發覆蓋與負數餘額,點數帳本就能做到每一點都查得到來源、每天都對得起帳。