# 沒設定就當成零：預設硬上限、四眼門檻與 kill switch 故障方向

> 上限、門檻和 kill switch 的預設值，決定設定漏掉或來源壞掉時系統會怎麼走。金額超出精度直接拒絕、四眼門檻未設定視為 0、kill switch 讀不到時錢的寫入停下、查詢照常，附 Go 1.27 範例。

原文：https://sheng.page/posts/default-hard-caps-and-kill-switches/ · 發布：2026-10-13 · 作者：Sheng

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 位小數直接拒絕，不四捨五入，超過硬上限也直接拒絕，不截成上限值。四捨五入看起來比較友善，可是呼叫端送來的數字就和帳上記的不一樣，對帳時那一點差額沒有人能解釋。截成上限值更糟，對方以為加了一百萬零一點，實際只加了一百萬，雙方都沒收到錯誤。

```go
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
}
```

```text
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` 就不檢查，結果新開的營運商在還沒人設定門檻之前，所有調帳都只要一個人：

```go
// 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 點：

```text
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，照常回應。

```go
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 秒之後斷線：

```text
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](https://simonwillison.net/2026/Oct/3/default-hard-budget-caps/)〉，HN 討論串在[這裡](https://news.ycombinator.com/item?id=49949235)：本文開頭的出發點，主張按用量計費的服務預設要有硬上限。
- Wikipedia〈[Fail-safe](https://en.wikipedia.org/wiki/Fail-safe)〉：fail-safe 與 fail-secure 的區別，以及兩者要求相反做法的例子。
- 〈[同一筆付款送了兩次：從 Santander 的聖誕節講冪等與重送](/posts/idempotency-and-retries/)〉：outbox 重試能安全重送的前提，也就是冪等。

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