《后端架构设计·演进与治理》12.3 成本治理机制:预算、评审与责任到团队

从 arch-lab 订单团队第 26 天用完整月预算的场景切入,说明预算预警、预测与耗尽日如何构成可解释的门禁,给出基于中位数绝对偏差的异常检测、责任团队解析的可运行 Go 实现,以及本机真实测试输出、评审判据与常见错误,全部附原始输出。

第 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 从模块化单体到两服务 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查