第 26 天,预算提前用完
订单团队给生产环境定了每月 10,000 元的预算。这个月前 25 天每天花 400 元,第 26 天累计越过 10,000 元,财务在群里发了截图。团队的第一反应是“谁超支了”,但账单上只有一条平滑上升的曲线,没有任何一天特别突出。真正的问题是每天 400 元的基线本身就高于预算:按 30 天算,月末会到 12,000 元。
如果只看“本月已花”,第 25 天时 10,000 元还没花完,系统不会报警;等到第 26 天越线,已经只剩 4 天可以反应。预算治理要提前回答三个问题:按当前速度月底会花多少、什么时候会耗尽、超支之前谁必须收到通知。这三个问题分别对应预测、耗尽日和责任团队。
另一个月,账单在平稳的 10 元基线后突然出现一天 50 元。均值会被这一天拉高,但中位数不会。异常检测要用对统计量,否则要么漏报,要么把正常波动当成事故。站内 SLO 与错误预算 用的是同一套思路:先把“正常”定义清楚,再让偏离触发动作。
预算规则要能被解释
预算不是年底填的一个数字,而是一组可执行规则:限额、预警比例、预测窗口、耗尽日、责任人。规则越复杂,团队越难理解,越容易在超支后争论“系统为什么没提醒”。arch-lab 只保留五条,并且每条都能用一句话说明。
| 字段 | 含义 | 判据 | 触发后的动作 |
|---|---|---|---|
LimitCents | 本周期上限 | 预测值超过即 breach | 冻结非必要变更,进入评审 |
WarnRatio | 预警比例 | 已花达到 80% 即 warn | 通知责任团队,准备削减方案 |
PeriodDays | 周期天数 | 固定 30 天,跨月不滚动 | 月末重置,历史留档 |
ForecastCents | 末七日均值外推 | 用于提前判断趋势 | 与限额比较,不直接触发扣款 |
ExhaustDay | 累计越线日 | 已发生才记录 | 用于复盘“提前了几天” |
预测用末七天而不是全月,是为了让规则对最近变化敏感;代价是月初样本少,预测会偏保守。把窗口和周期写进结构体,评审时可以复算,而不是相信某个报表的截图。
预算还要有明确的归属。一个 scope 没有责任人时,系统可以兜底到平台团队,但必须把“这是兜底”暴露在报表里;否则平台团队会默默承担所有无人认领的成本,而真正该优化的团队永远收不到信号。决策权与责任边界在 决策权与团队契约 里讨论过,成本预算应当沿用同一张责任表。
用中位数找异常,不用均值
云成本序列通常有缓慢增长趋势和少量尖峰。均值对尖峰敏感:一天 50 元会把 10 元基线拉到 13 元,下一次 20 元就不显得异常。中位数稳健得多,再配中位数绝对偏差(MAD),就能得到不受极端值影响的偏离分数。
规则是 modified z-score:0.6745 * (x - median) / MAD。常数 0.6745 让分数在正态分布下与标准差同尺度,阈值 3.5 是常用的起点。MAD 为零时说明基线完全平坦,公式会除零,此时退化为“超过中位数 1.5 倍”的比例规则,并在结果里保留 MAD=0,让排障的人知道用的是哪条分支。
异常检测的输出不是“关掉它”,而是“去看这一天”。成本尖峰可能是促销、批处理重跑、数据回填,也可能是配置错误;没有业务上下文,任何自动处置都会误伤。告警应该带上当天金额、基线中位数和偏离分数,让人能在五分钟内判断是否需要动作,这与 告警到动作的闭环 的要求一致。
责任到团队:兜底必须可见
Owner 解析只做两件事:命中显式映射就返回团队并标记 explicit=true,否则返回平台团队并标记 explicit=false。调用方必须把第二个返回值写进报表,而不是只取团队名。这样“平台团队承担了 30% 的兜底成本”会成为一个可查询的指标,而不是年底才发现的事实。
兜底值本身也要稳定。今天叫 team-platform,明天改成 platform,历史报表就对不上。把它定义成常量、写进数据字典,变更时走一次迁移,而不是在每个服务里各写各的字符串。
可运行实现
下面的包实现预算评估、异常检测与责任解析。所有输入都做显式校验:重复日期、负支出、非法预警比例直接报错,避免脏数据悄悄进入预测。
// file: internal/platform/cost/governance.go
// tier: L1
// verify: go test ./internal/platform/cost/ -count=1
package cost
import (
"fmt"
"sort"
"strings"
)
// SpendPoint is one day of spend for a scope.
type SpendPoint struct {
Day int
Cents int64
}
// BudgetStatus is the outcome of a budget evaluation.
type BudgetStatus string
const (
BudgetOK BudgetStatus = "ok"
BudgetWarn BudgetStatus = "warn"
BudgetBreach BudgetStatus = "breach"
)
// Budget is a cap owned by one team or service.
type Budget struct {
Scope string
LimitCents int64
PeriodDays int
WarnRatio float64
}
// BudgetEval is the projected end-of-period position.
type BudgetEval struct {
Scope string
SpentCents int64
ForecastCents int64
LimitCents int64
Status BudgetStatus
ExhaustDay int // first day cumulative spend exceeds the limit, or -1
}
// EvaluateBudget forecasts the period from the trailing seven days. The rule
// is intentionally naive: a gate must be explainable to the team paying.
func EvaluateBudget(b Budget, points []SpendPoint) (BudgetEval, error) {
if strings.TrimSpace(b.Scope) == "" || b.LimitCents <= 0 || b.PeriodDays <= 0 {
return BudgetEval{}, fmt.Errorf("cost: budget %q has invalid scope, limit, or period", b.Scope)
}
if b.WarnRatio <= 0 || b.WarnRatio > 1 {
return BudgetEval{}, fmt.Errorf("cost: budget %q warn ratio must be within (0,1]", b.Scope)
}
pts := append([]SpendPoint(nil), points...)
sort.Slice(pts, func(i, j int) bool { return pts[i].Day < pts[j].Day })
eval := BudgetEval{Scope: b.Scope, LimitCents: b.LimitCents, ExhaustDay: -1}
for i, p := range pts {
if p.Cents < 0 {
return BudgetEval{}, fmt.Errorf("cost: budget %q has negative spend on day %d", b.Scope, p.Day)
}
if i > 0 && p.Day == pts[i-1].Day {
return BudgetEval{}, fmt.Errorf("cost: budget %q has duplicate day %d", b.Scope, p.Day)
}
eval.SpentCents += p.Cents
}
if n := len(pts); n > 0 {
window := n
if window > 7 {
window = 7
}
var sum int64
for _, p := range pts[n-window:] {
sum += p.Cents
}
avg := float64(sum) / float64(window)
eval.ForecastCents = int64(avg*float64(b.PeriodDays) + 0.5)
}
var cumulative int64
for _, p := range pts {
cumulative += p.Cents
if cumulative > b.LimitCents {
eval.ExhaustDay = p.Day
break
}
}
switch {
case eval.ForecastCents > b.LimitCents:
eval.Status = BudgetBreach
case float64(eval.SpentCents) >= b.WarnRatio*float64(b.LimitCents):
eval.Status = BudgetWarn
default:
eval.Status = BudgetOK
}
return eval, nil
}
// Anomaly is a day whose spend is far from the trailing median.
type Anomaly struct {
Day int
Cents int64
Median int64
MAD int64
Score float64
}
// DetectSpikes flags days above the trailing median using a modified z-score.
// A zero MAD means a flat baseline, so the rule falls back to a 1.5x ratio
// instead of dividing by zero.
func DetectSpikes(points []SpendPoint, window int, threshold float64) []Anomaly {
if window < 3 {
window = 3
}
if threshold <= 0 {
threshold = 3.5
}
pts := append([]SpendPoint(nil), points...)
sort.Slice(pts, func(i, j int) bool { return pts[i].Day < pts[j].Day })
var out []Anomaly
for i := window; i < len(pts); i++ {
win := make([]int64, 0, window)
for _, p := range pts[i-window : i] {
win = append(win, p.Cents)
}
med := medianInt64(win)
mad := medianInt64(absoluteDeviations(win, med))
var score float64
spike := false
if mad == 0 {
if med > 0 && pts[i].Cents*2 > med*3 {
spike, score = true, float64(pts[i].Cents)/float64(med)
}
} else {
score = 0.6745 * float64(pts[i].Cents-med) / float64(mad)
spike = score > threshold
}
if spike {
out = append(out, Anomaly{Day: pts[i].Day, Cents: pts[i].Cents,
Median: med, MAD: mad, Score: score})
}
}
return out
}
func medianInt64(values []int64) int64 {
if len(values) == 0 {
return 0
}
sorted := append([]int64(nil), values...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
mid := len(sorted) / 2
if len(sorted)%2 == 1 {
return sorted[mid]
}
return (sorted[mid-1] + sorted[mid]) / 2
}
func absoluteDeviations(values []int64, center int64) []int64 {
out := make([]int64, 0, len(values))
for _, v := range values {
d := v - center
if d < 0 {
d = -d
}
out = append(out, d)
}
return out
}
// Owner resolves the accountable team for a scope. explicit is false when the
// scope falls back to the platform team, which the report must surface rather
// than hide.
func Owner(scope string, owners map[string]string) (team string, explicit bool) {
if t := strings.TrimSpace(owners[scope]); t != "" {
return t, true
}
return "team-platform", false
}
测试用三段固定月度序列验证预算状态,用两条序列验证异常检测的两个分支,再验证责任解析的命中与兜底。非法输入在实现里直接返回错误,不再单独列出用例。
// file: internal/platform/cost/governance_test.go
// verify: go test ./internal/platform/cost/ -run 'TestEvaluateBudget|TestDetectSpikes|TestOwner' -count=1 -v
package cost
import "testing"
func monthOf(daily int64, days int) []SpendPoint {
pts := make([]SpendPoint, 0, days)
for d := 1; d <= days; d++ {
pts = append(pts, SpendPoint{Day: d, Cents: daily})
}
return pts
}
func TestEvaluateBudgetStatuses(t *testing.T) {
b := Budget{Scope: "team-order", LimitCents: 1000000, PeriodDays: 30, WarnRatio: 0.8}
cases := []struct {
name string
pts []SpendPoint
want BudgetStatus
spent int64
}{
{"on-track", monthOf(20000, 15), BudgetOK, 300000},
{"warn", monthOf(30000, 30), BudgetWarn, 900000},
{"breach", monthOf(40000, 30), BudgetBreach, 1200000},
}
for _, c := range cases {
got, err := EvaluateBudget(b, c.pts)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if got.Status != c.want || got.SpentCents != c.spent {
t.Fatalf("%s: status=%s spent=%d", c.name, got.Status, got.SpentCents)
}
t.Logf("case=%s spent=%d forecast=%d status=%s exhaust_day=%d",
c.name, got.SpentCents, got.ForecastCents, got.Status, got.ExhaustDay)
}
}
func TestDetectSpikesOnFlatBaseline(t *testing.T) {
pts := append(monthOf(1000, 10), SpendPoint{Day: 11, Cents: 5000})
got := DetectSpikes(pts, 5, 3.5)
if len(got) != 1 || got[0].Day != 11 || got[0].MAD != 0 {
t.Fatalf("anomalies = %+v", got)
}
t.Logf("day=%d cents=%d median=%d mad=%d score=%.2f",
got[0].Day, got[0].Cents, got[0].Median, got[0].MAD, got[0].Score)
}
func TestDetectSpikesOnNoisyBaseline(t *testing.T) {
daily := []int64{100, 120, 90, 110, 105, 100, 95, 100, 500}
pts := make([]SpendPoint, 0, len(daily))
for i, v := range daily {
pts = append(pts, SpendPoint{Day: i + 1, Cents: v})
}
got := DetectSpikes(pts, 4, 3.5)
if len(got) != 1 || got[0].Day != 9 {
t.Fatalf("anomalies = %+v, want day 9 only", got)
}
t.Logf("day=%d cents=%d median=%d mad=%d score=%.2f",
got[0].Day, got[0].Cents, got[0].Median, got[0].MAD, got[0].Score)
}
func TestOwnerFallsBackToPlatform(t *testing.T) {
owners := map[string]string{"order-api": "team-order", "payment": " "}
if team, ok := Owner("order-api", owners); team != "team-order" || !ok {
t.Fatalf("owner = %q explicit=%v", team, ok)
}
if team, ok := Owner("payment", owners); team != "team-platform" || ok {
t.Fatalf("fallback = %q explicit=%v", team, ok)
}
t.Logf("explicit=team-order fallback=team-platform")
}
本机实测
环境为 macOS darwin/arm64、Go 1.26.0,gofmt -l . 与 go vet ./... 无输出。以下是预算、异常与责任用例的原始输出。
$ go test ./internal/platform/cost/ -run 'TestEvaluateBudget|TestDetectSpikes|TestOwner' -v -count=1
=== RUN TestEvaluateBudgetStatuses
governance_test.go:33: case=on-track spent=300000 forecast=600000 status=ok exhaust_day=-1
governance_test.go:33: case=warn spent=900000 forecast=900000 status=warn exhaust_day=-1
governance_test.go:33: case=breach spent=1200000 forecast=1200000 status=breach exhaust_day=26
--- PASS: TestEvaluateBudgetStatuses (0.00s)
=== RUN TestDetectSpikesOnFlatBaseline
governance_test.go:44: day=11 cents=5000 median=1000 mad=0 score=5.00
--- PASS: TestDetectSpikesOnFlatBaseline (0.00s)
=== RUN TestDetectSpikesOnNoisyBaseline
governance_test.go:58: day=9 cents=500 median=100 mad=2 score=134.90
--- PASS: TestDetectSpikesOnNoisyBaseline (0.00s)
=== RUN TestOwnerFallsBackToPlatform
governance_test.go:70: explicit=team-order fallback=team-platform
--- PASS: TestOwnerFallsBackToPlatform (0.00s)
PASS
ok archlab/internal/platform/cost 0.251s
预算三行读起来是一条时间线:15 天每天 2,000 分,已花 3,000 元、预测 6,000 元,状态 ok;30 天每天 3,000 分,预测 9,000 元、已花 9,000 元,刚好触发 80% 预警;30 天每天 4,000 分,预测 12,000 元,第 26 天累计越过 10,000 元。注意第二行 exhaust_day=-1:状态是 warn,但按当前速度不会越线,只是已经花掉九成,这正是“预警”与“超支”要分开的原因。
异常两行展示了两个分支。平坦基线 10 元后出现 50 元,MAD 为零,走 1.5 倍比例规则,分数是 5.00;有噪声的基线中位数 10 元、MAD 0.02 元,500 分的尖峰得到 134.90 的 modified z-score。后者的分数远高于阈值,说明在低波动序列里,异常检测非常敏感,阈值不能照搬到高波动业务。
评审判据
| 观测 | 判据 | 动作 | 退出条件 |
|---|---|---|---|
| 预测状态 | 连续 3 天 breach | 冻结非必要变更,提交削减方案 | 预测回落到限额内 |
| 已花比例 | ≥ WarnRatio | 通知责任团队,准备清单 | 月末结算或方案生效 |
| 异常分数 | > 3.5 且金额 > 日均 1.5 倍 | 人工确认业务原因 | 记录为事件或误报 |
| 兜底归属 | explicit=false 占比 > 10% | 补 owner 映射,纳入平台预算 | 占比回落到 5% 以下 |
| 评审材料 | 无节省金额或责任人缺失 | 退回,不进入决策会 | 补齐金额、负责人、回滚方案 |
常见错误与边界
第一,用本月累计判断是否超支。累计值在月中永远“还没超”,等到越线时只剩几天;预测和耗尽日必须和累计值一起看。
第二,预测窗口拍脑袋。窗口太短会被单日尖峰带偏,太长会掩盖最近的变化;七天是一个可解释的起点,但要在评审记录里写明,不能每个报表各用一套。
第三,异常检测用均值加标准差。云成本的长尾会让标准差被少数尖峰撑大,真正的异常反而落在阈值内;中位数加 MAD 更稳,代价是要处理 MAD 为零的退化分支。
第四,兜底归属不写进报表。Owner 返回平台团队时如果不带 explicit=false,平台团队就会成为所有无人认领成本的默认承担者,而真正该优化的团队永远看不到自己的账单。
小结
预算治理的核心不是限额本身,而是三条可解释的规则:按当前速度月底会花多少、哪一天会耗尽、谁负责。把预测、耗尽日、预警比例和责任团队放进同一个结构体,评审时才能复算,而不是相信一张截图。
异常检测要用稳健统计量。本机固定序列下,平坦基线后的 5 倍尖峰得到 5.00 的比例分数,有噪声基线后的尖峰得到 134.90 的 modified z-score;两组数说明同一条阈值不能跨业务照搬,必须结合波动率校准。
责任解析的第二个返回值同样重要。兜底到平台团队不是错误,隐藏兜底才是。把 explicit=false 做成可查询指标,成本治理才从“月底对账”变成“持续纠偏”。
阅读导航:上一节:12.2 容量与成本:预留、弹性与闲置回收 · 下一节:13.1 arch-lab 从模块化单体到两服务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。