← 回文章列表

2022年8月23日

點數系統為什麼不能直接改餘額?用交易紀錄、冪等鍵與條件更新設計一個對得起帳的點數帳本

系統設計資料庫MSSQL冪等性金流

直覺的設計:一個餘額欄位

做 B 端刊登平台時,房仲經紀人要先儲值點數,再用點數購買刊登、置頂等方案。最直覺的資料模型是在會員資料表上放一個 Balance 欄位:

SQL
-- 扣點
UPDATE Members SET Balance = Balance - 300 WHERE MemberId = @MemberId;

這個設計在開發環境裡跑得很好,但只要和「錢」有關,上線後一定會遇到這三個問題:

  1. 重複扣點:使用者連點兩次「購買」、前端逾時自動重送、或是訊息佇列重複投遞,同一筆購買被扣了兩次
  2. 併發覆蓋:如果程式先 SELECT 出餘額、在程式裡算好、再 UPDATE 回去,兩個請求同時進來時,後寫的會蓋掉先寫的
  3. 對帳對不起來:客服接到「我的點數怎麼少了」,資料庫裡只有一個「現在是 600」的數字,完全不知道它是怎麼變成 600 的

換個角度:交易紀錄才是真相

銀行的存摺不是只印「餘額」,而是一筆一筆的存入、提出,餘額只是最後一欄的加總。點數也應該這樣設計:

點數帳本設計比較:左邊直接改餘額只留下現在的數字,會有重複扣點、併發覆蓋、無法對帳的問題;右邊以交易紀錄為主,每筆加值、扣點、退點都有唯一的 RequestId,重複請求會被唯一鍵擋下,餘額快照與交易紀錄在同一個資料庫交易內一起更新,並且永遠等於紀錄的加總

  • 交易紀錄(Ledger):每一次點數異動都新增一筆紀錄,包含異動數量、類型(加值、扣點、退點)、原因、關聯的訂單。紀錄只新增、不修改、不刪除
  • 餘額快照(Balance):為了查詢效率,另外維護一個目前餘額。但它只是快取,隨時可以由交易紀錄重新加總出來
SQL
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 有沒有處理過」再決定要不要寫?因為「先查再寫」在兩個請求同時進來時,兩邊都會查到「沒有」,然後都寫進去。把判斷交給資料庫的唯一索引,才是真正可靠的保證。

條件更新:避免併發覆蓋與扣成負數

餘額快照的更新不要「讀出來、算好、寫回去」,而是讓資料庫在一個陳述式裡完成判斷與更新:

SQL
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:

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,500topup-{金流交易編號}
購買「刊登 1 件 90 天」扣點 −300order-{訂單編號}
購買「關注焦點 7 天」扣點 −450order-{訂單編號}
方案取消退點 +450refund-{訂單編號}

儲值的 RequestId 使用金流服務回傳的交易編號:金流的付款結果通知(Callback)常常會重送好幾次,有了冪等鍵,不管通知來幾次,點數都只會加一次。

一句話總結

和錢有關的數字不要只存「現在是多少」,而是把每一次異動記成一筆只增不改的交易紀錄,餘額只是可以隨時重算的快照;再用唯一索引當冪等鍵擋掉重複請求、用條件更新防止併發覆蓋與負數餘額,點數帳本就能做到每一點都查得到來源、每天都對得起帳。