沒設定就當成零:預設硬上限、四眼門檻與 kill switch 故障方向
Simon Willison 十月初寫了一篇〈We’re going to need default hard budget caps on pretty much everything〉,主張按用量計費的服務應該預設有硬上限,花到上限就回錯誤,想解除的人自己勾選。因為 agent 隨手就能開一個付費服務,大多數人寧可看到錯誤,也不想收到幾千美元的帳單。HN 上這篇有 645 點、312 則留言。
他講的是雲端帳單,牽涉金錢的系統更直接。上限、門檻和開關的值,平常沒人在意,真正出事都是在沒有值可用的時候,可能漏了設定,或欄位是 NULL,也可能讀設定的來源掛了,系統接下來怎麼處理,全看寫程式時選了什麼預設值。九月我做一個遊戲商系統原型時,就在這幾個地方做了選擇,下面用簡化過的 Go 1.27.1 程式說明,輸出都是實際跑出來的。
一、金額:超出精度或硬上限就拒絕
這個原型的金額一律是點數,資料庫欄位是 numeric(38,15),小數點後最多 15 位。審查前我自己先修了幾個問題,其中一個是金額沒有上限,原本只要是正數就接受。
修正時訂了兩條規則,超過 15 位小數直接拒絕,不四捨五入,超過硬上限也直接拒絕,不截成上限值。四捨五入看起來比較友善,可是呼叫端送來的數字就和帳上記的不一樣,對帳時那一點差額沒有人能解釋。截成上限值更糟,對方以為加了一百萬零一點,實際只加了一百萬,雙方都沒收到錯誤。
const maxScale = 15
var maxAmount = mustRat("1000000")
func parseAmount(s string) (*big.Rat, error) {
if i := strings.IndexByte(s, '.'); i >= 0 && len(s)-i-1 > maxScale {
return nil, fmt.Errorf("scale %d > %d", len(s)-i-1, maxScale)
}
r, ok := new(big.Rat).SetString(s)
if !ok {
return nil, errors.New("not a decimal")
}
if r.Sign() <= 0 {
return nil, errors.New("must be positive")
}
if r.Cmp(maxAmount) > 0 {
return nil, errors.New("over hard cap")
}
return r, nil
}
100.5 ok
0.000000000000001 ok
0.0000000000000001 rejected: scale 16 > 15
1000000.000000000000001 rejected: over hard cap
-3 rejected: must be positive
範例裡的一百萬只是示意值,實際上限要看業務需求,超過時就回錯誤,讓呼叫端知道。Willison 說的硬上限也是這個意思,超過就報錯,不能默默替對方做決定。
二、四眼門檻:沒設定就當成零
後台的人工調帳、手動結算這類動作,金額達到門檻就要兩個人,一個申請、一個核准,低於門檻可以一個人做,但仍然寫進 audit log。門檻設在營運商層級,用點數計算。
門檻沒設定時最容易寫錯,Go 裡常用指標表示「可能沒有值」,直覺會寫成遇到 nil 就不檢查,結果新開的營運商在還沒人設定門檻之前,所有調帳都只要一個人:
// needsApprovalNaive treats "not configured" as "no limit".
func needsApprovalNaive(c Casino, amt *big.Rat) bool {
return c.ApprovalThreshold != nil && amt.Cmp(c.ApprovalThreshold) >= 0
}
// needsApproval treats "not configured" as zero: everything needs a second person.
func needsApproval(c Casino, amt *big.Rat) bool {
t := c.ApprovalThreshold
if t == nil {
t = new(big.Rat)
}
return amt.Cmp(t) >= 0
}
調一筆 50 點:
configured=1000 naive=false default-zero=false
never configured naive=false default-zero=true
我最後定的規則是未設定視為 0,所有金額都要四眼,修改門檻本身也永遠要四眼,不管改成多少,免得有人先把門檻調高,再自己做一筆大額調帳。
預設 0,營運初期就會有很多核准單,有人會嫌煩,但看得到問題,大家就會去把門檻設好。預設不限制,得等到有人單獨調了一筆大錢,查 audit log 才會發現。
三、重試也要有上限,但每種操作不一樣
送給營運商的 bet、win、refund 走 outbox,失敗就退避重試,如果沒有上限,對方系統壞了,佇列就永遠消不完;但上限一律設成同一個數字,又會放棄本來該送到的錢。
原型裡按操作分開處理,bet 重試 8 次就放棄,因為熱路徑上有補償用的 refund,放棄扣款不會讓玩家少錢;win 和 refund 是要加給玩家的錢,退避間隔封頂 300 秒,一直重試到 24 小時,過了 24 小時才交給人工處理。重試總要有個上限,具體數字則要看放棄這筆錢會讓誰受損。
四、kill switch 讀不到的時候
kill switch 用來在出事時停掉某個營運商或某款遊戲,本身也是設定,碰上資料庫連不上,或快取節點重啟,就可能讀不到,網路稍微不穩也會有同樣的問題。
一開始的設計是 Valkey 掛掉時改查資料庫,後來簡化成只在行程內放一份 5 秒 TTL 的快取,過期就回資料庫讀。5 秒內的故障還能靠快取應付,超過 5 秒就得決定接下來怎麼處理,我依功能分開:錢的寫入路徑 fail-closed,讀不到開關就當作已經觸發,不接新的扣款和加款;查詢、報表這類唯讀路徑 fail-open,照常回應。
func (k *KillSwitch) Allow(p Path) (bool, string) {
if !k.fetched.IsZero() && k.now().Sub(k.fetched) < k.ttl {
return !k.val, "cache"
}
killed, err := k.load()
if err == nil {
k.val, k.fetched = killed, k.now()
return !killed, "source"
}
if p == MoneyWrite {
return false, "source down, fail-closed"
}
return true, "source down, fail-open"
}
用假時鐘模擬來源在第 0 秒之後斷線:
t=0s source up money-write allow=true (source)
t=0s source up read-only allow=true (cache)
t=3s source down money-write allow=true (cache)
t=3s source down read-only allow=true (cache)
t=6s source down money-write allow=false (source down, fail-closed)
t=6s source down read-only allow=true (source down, fail-open)
維基百科的 fail-safe 條目提到,fail-safe 和 fail-secure(也叫 fail-closed)有不同要求,有時甚至互相衝突,例如火災時門禁必須解鎖讓人逃生,fail-safe 和 fail-secure 要求的方向就剛好相反。kill switch 也要依路徑決定故障時怎麼處理,錢的寫入停下來比較安全,查詢則繼續回應比較安全,畢竟客服還要查餘額,營運還要看報表,連這些都停掉只會讓事故更難處理。
原型的 e2e 測試裡有一條專門驗 kill switch 搭配 Valkey 斷線時是否維持 fail-closed,這個故障方向得寫進測試,否則下一個改程式的人,很可能為了「提高可用性」改成全部 fail-open。
五、沒設定或讀不到時的預設行為
設定沒有值或讀不到時,預設行為要讓損失比較小,也容易察覺問題。金額超出就報錯,門檻沒設就全部四眼,重試也有上限,讀不到開關時就停掉錢的寫入。
這些預設難免讓系統比較難用,像是報錯或多一張核准單。Willison 那篇也主張使用者可以自己解除上限,但必須明確操作,沒注意到這項設定的人,仍有預設上限保護。
補充筆記
- 金額超過精度或硬上限直接拒絕,不四捨五入,也不截成上限值,雙方帳上的數字才會一致。
- 四眼門檻未設定視為 0,全部要兩個人;改門檻本身永遠四眼,避免一個人先調高門檻再自己動帳。
- Go 用
nil表示未設定時,最常見的錯是nil就跳過檢查,等於預設不限制。 - 重試上限依操作決定,有補償的 bet 可以早點放棄,要加給玩家的錢就重試久一點。
- kill switch 讀不到時,錢的寫入 fail-closed,唯讀查詢 fail-open,並用 e2e 測試把方向固定下來。
延伸閱讀
- Simon Willison〈We’re going to need default hard budget caps on pretty much everything〉,HN 討論串在這裡:本文開頭的出發點,主張按用量計費的服務預設要有硬上限。
- Wikipedia〈Fail-safe〉:fail-safe 與 fail-secure 的區別,以及兩者要求相反做法的例子。
- 〈同一筆付款送了兩次:從 Santander 的聖誕節講冪等與重送〉:outbox 重試能安全重送的前提,也就是冪等。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Go 1.27.1 實測。