服務裡有一套點數機制:完成某些里程碑(首次設定、連續使用等)發放點數,點數可以兌換進階內容。需求聽起來是 CRUD 入門題:「達成條件 → 發點數」。
但「發放型」邏輯藏著一個所有支付、獎勵、優惠券系統共通的深坑:重複發放。
def grant_milestone_reward(user, milestone):
if has_granted(user, milestone): # 檢查發過沒
return
add_points(user, amount) # 發點
record_grant(user, milestone) # 記錄
看起來滴水不漏?劇本來了:使用者的網路卡頓、連點兩下按鈕、兩個請求幾乎同時到達。兩個請求都跑 has_granted——都回傳 False(因為誰都還沒寫紀錄)——於是兩個請求都發了點。
這不是理論風險。行動網路環境下的重複請求是日常,而點數直接對應真金白銀的成本(兌換的進階內容有 AI 呼叫費用)。重複發放不會報錯、不會當機,只會在帳務上安靜地失血。
修法的核心是調換順序,並且讓「防重」由資料庫的唯一性約束來保證:
CREATE TABLE reward_grants (
username TEXT,
milestone TEXT,
granted_at TIMESTAMP,
UNIQUE (username, milestone) -- 同一人同一里程碑,物理上只能有一筆
);
def grant_milestone_reward(user, milestone):
try:
insert_grant_record(user, milestone) # 第一步:先搶佔紀錄
except UniqueViolation:
return # 搶輸了 = 已經發過,安靜退出
add_points(user, amount) # 第二步:搶贏的才發點
兩個併發請求同時 INSERT,資料庫的唯一性約束保證只有一個會成功——這是資料庫給你的原子性承諾,不需要應用層加鎖。搶贏的發點,搶輸的收到違反約束的錯誤,安靜退出。
順序為什麼不能反?如果先發點再寫紀錄,「發了點但還沒寫紀錄」的空窗期裡,任何重複請求都能鑽進去。而反過來的失敗模式是「寫了紀錄但發點失敗」——這是少發,使用者會回報,補發就好;多發則無聲無息,你只會在成本報表上看到結果。兩種失敗模式的可觀測性天差地遠,永遠選擇會被使用者回報的那種失敗。
所有「發放型」動作(點數、額度、優惠券、通知)的安全結構是同一個:
has_granted 檢查可以留著當快速路徑,但不能是唯一防線
這條也進了地雷清單,一句話版本:「發放類:先 INSERT 防重紀錄、再發放,順序不能反。」