沒設定就當成零:預設硬上限、四眼門檻與 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 測試把方向固定下來。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Go 1.27.1 實測。