《后端架构设计·演进与治理》12.1 成本归因:标签、分摊与单位经济

从 arch-lab 一张 5500 元账单里 23.6% 找不到负责人的场景切入,说明租户、服务、环境、负责人四个归因维度为何是分摊契约,给出保留未分摊桶、按驱动量拆分共享池的 Go 实现,并用固定账单数据实测归因结果、最大余数分摊与单位经济。

一张 5,500 元的账单,23.6% 找不到主人

月末关账,财务把 arch-lab 的云账单发给平台组:总额 5,500 元,其中 1,300 元没有负责人。订单团队说自己只该承担订单 API 和订单库;结算团队说批处理机器是平台统一申请的;平台组说 NAT 网关所有团队都在用。三方都没有撒谎,问题出在账单上没有足够信息把成本还原到责任主体。

这张账单里有四类资源:订单 API 的虚拟机、订单库、结算批处理机、共享 NAT 网关。前两个四个标签齐全,批处理机缺 owner,NAT 网关的 tenant 被写成 shared。归因代码要做的不是“猜一个最像的团队”,而是把 4,200 元分到明确的维度上,把 1,300 元留在未分摊桶里,并记录每一笔为什么没分出去。

这也是成本治理和财务报表的分界线。财务关心总额对不对,架构治理关心每一元钱是否对应一个能做技术决策的人。没有责任主体的成本,任何优化都不会有人认领;反过来,强行把共享成本平均摊给所有团队,又会让他们对不可控的数字失去信任。站内 FinOps 与成本治理 讨论过组织流程,本节只解决一个更窄的问题:账单行到责任主体的可验证映射。

四个维度:租户、服务、环境、负责人

成本标签不是越多越好。每增加一个强制维度,就要多一条校验规则、多一次历史数据回填。arch-lab 最终只保留四个,因为它们分别回答四个不同的问题,缺一个就会让某类决策失去依据。

维度回答的问题缺失后的后果可接受的兜底
tenant哪个客户或业务单元在用无法做租户毛利与退订分析内部系统用固定值 internal,不能用空值
service哪个可部署单元产生无法定位到服务负责人和技术优化点按部署单元名强制生成,禁止手工填写
env生产、预发还是开发测试环境成本混入生产毛利由部署流水线注入,不接受运行时覆盖
owner谁为这笔钱做决策优化建议无人认领,预算无法下放无兜底,缺失即进入未分摊桶

owner 是唯一不允许兜底的维度。前三个可以靠平台约定补全,负责人必须由团队显式声明;一旦允许“默认平台组”,所有没人认领的资源都会沉到平台组,平台组又没有任何权限关掉业务团队的机器,责任链在这里断掉。这与 数据所有权与存储决策 里“数据归属必须落到一个能做取舍的角色”是同一条原则,只是对象从数据换成了账单。

多租户场景还要多一层约束:tenant 必须是签约主体,不是用户 ID。把每个用户都当成租户会让标签基数爆炸,账单系统按标签聚合时先被内存打爆。租户隔离策略与标签口径应一起设计,可参考 多租户架构 。

未分摊不是误差,是负债

很多团队的第一反应是把未分摊成本按各团队已分摊金额的比例摊回去,让报表“平掉”。这会让报表好看,但把问题永久隐藏:批处理机到底该谁关、NAT 网关该不该降配,仍然没有答案。正确做法是保留未分摊桶,并记录原因分布。

原因不需要很细,能驱动动作即可。missing:owner 对应“去补标签或下线资源”,shared:tenant 对应“选一个驱动量拆池”,missing:env 对应“流水线注入失败”。如果一个月后 missing:owner 仍是最大项,说明治理动作没有落地,而不是归因算法不够聪明。

共享池的拆分必须显式选择驱动量,并且驱动量要能被业务团队影响。观测平台按请求数拆,订单团队多发一次请求就多承担一点;按收入拆,团队会说自己控制不了定价。两种都能用,但要在评审记录里写清楚,否则每次账单出来都要重新吵一遍。纯平均分只在没有任何可用驱动量时使用,而且应当被视为临时状态。

拆分算法本身有一个容易忽略的工程要求:份额之和必须精确等于池总额,不能因为四舍五入丢一分钱。最大余数法先按整数除法分配,再把剩余的单位依次给余数最大的项;余数相同时按 key 排序,保证同样输入每次得到同样结果。财务对账时,“差一分”会被当成系统 bug,解释成本远高于多写十行代码。

从总账到单位经济

把成本分到团队只是第一步。订单团队拿到 4,200 元之后,真正要回答的是“每单成本多少、降价空间多大”。这里必须区分两个数:全loaded 成本把固定投入也算进每一单,边际成本只算随订单量变化的部分。

固定部分包括预留容量、控制面、常驻观测组件;变动部分包括按量计费的弹性实例、存储读写和支付通道费。当月订单 20,000 单、总成本 10,000 元、固定成本 4,000 元时,全loaded 每单 0.50 元,边际每单 0.30 元。一个 0.40 元的折扣单,按全loaded 看是亏损,按边际看仍然贡献 0.10 元。两个数都不是“错”的:前者用于长期定价和容量决策,后者用于短期增量订单,混用就会得出相反结论。

单位经济的口径还要固定分母。订单数用支付成功单还是创建单,会让每单成本差出十几个百分点;跨月退款算在哪个月,也会影响趋势。口径一旦写进报表就不要再改,改口径必须像改 API 一样发版本说明。

可运行实现

下面的包只依赖标准库,包含四维归因、未分摊原因、共享池最大余数拆分和单位经济。归因结果同时保留 Allocated 与 Unallocated,Total 不再遍历维度求和,避免同一笔资源被四个维度重复计算。

// file: internal/platform/cost/attribution.go
// tier: L1
// verify: go test ./internal/platform/cost/ -count=1
package cost

import (
	"fmt"
	"sort"
)

// Resource is one billable line from a cloud bill.
type Resource struct {
	ID           string
	MonthlyCents int64
	Tags         map[string]string
}

// Dimension is an attribution axis every resource must carry.
type Dimension string

const (
	DimTenant  Dimension = "tenant"
	DimService Dimension = "service"
	DimEnv     Dimension = "env"
	DimOwner   Dimension = "owner"
)

// RequiredDimensions is the minimum tag set finance accepts as attributable.
var RequiredDimensions = []Dimension{DimTenant, DimService, DimEnv, DimOwner}

// Attribution is cost grouped by tag value plus the bucket nobody owns.
type Attribution struct {
	ByDimension map[Dimension]map[string]int64
	Allocated   int64
	Unallocated int64
	Reasons     map[string]int64
}

// Total returns all cost, including the unallocated bucket. It must not sum
// ByDimension, because each resource appears once per dimension.
func (a Attribution) Total() int64 { return a.Allocated + a.Unallocated }

// UnallocatedShare is the fraction of total cost with no owner.
func (a Attribution) UnallocatedShare() float64 {
	if a.Total() == 0 {
		return 0
	}
	return float64(a.Unallocated) / float64(a.Total())
}

// Attribute groups resources by every required dimension. A resource is
// unallocated when a required tag is missing, empty, or the literal "shared".
func Attribute(resources []Resource) Attribution {
	a := Attribution{
		ByDimension: make(map[Dimension]map[string]int64, len(RequiredDimensions)),
		Reasons:     make(map[string]int64),
	}
	for _, d := range RequiredDimensions {
		a.ByDimension[d] = make(map[string]int64)
	}
	for _, r := range resources {
		reason := ""
		for _, d := range RequiredDimensions {
			v := r.Tags[string(d)]
			if v == "" {
				reason = "missing:" + string(d)
				break
			}
			if v == "shared" {
				reason = "shared:" + string(d)
				break
			}
		}
		if reason != "" {
			a.Unallocated += r.MonthlyCents
			a.Reasons[reason] += r.MonthlyCents
			continue
		}
		for _, d := range RequiredDimensions {
			a.ByDimension[d][r.Tags[string(d)]] += r.MonthlyCents
		}
		a.Allocated += r.MonthlyCents
	}
	return a
}

// SharedPool is untagged platform cost split by a driver such as request
// count or active tenant count.
type SharedPool struct {
	Name    string
	Cents   int64
	Weights map[string]int64
}

// Split uses the largest remainder method so shares always sum to Cents.
// Ties are broken by key, which makes the result reproducible.
func (p SharedPool) Split() (map[string]int64, error) {
	if p.Cents < 0 {
		return nil, fmt.Errorf("cost: pool %q has negative amount", p.Name)
	}
	if len(p.Weights) == 0 {
		return nil, fmt.Errorf("cost: pool %q has no weights", p.Name)
	}
	keys := make([]string, 0, len(p.Weights))
	var total int64
	for k, w := range p.Weights {
		if w < 0 {
			return nil, fmt.Errorf("cost: pool %q has negative weight for %q", p.Name, k)
		}
		total += w
		keys = append(keys, k)
	}
	if total == 0 {
		return nil, fmt.Errorf("cost: pool %q weight sum is zero", p.Name)
	}
	sort.Strings(keys)
	out := make(map[string]int64, len(keys))
	type rem struct {
		key string
		r   int64
	}
	rems := make([]rem, 0, len(keys))
	var assigned int64
	for _, k := range keys {
		exact := p.Cents * p.Weights[k]
		out[k] = exact / total
		assigned += out[k]
		rems = append(rems, rem{key: k, r: exact % total})
	}
	sort.Slice(rems, func(i, j int) bool {
		if rems[i].r != rems[j].r {
			return rems[i].r > rems[j].r
		}
		return rems[i].key < rems[j].key
	})
	for i := int64(0); i < p.Cents-assigned; i++ {
		out[rems[i%int64(len(rems))].key]++
	}
	return out, nil
}

// UnitEconomics turns a monthly bill into a per-order number. FixedCents does
// not shrink when volume drops: reserved capacity and control-plane spend.
type UnitEconomics struct {
	TotalCents int64
	FixedCents int64
	OrderCount int64
}

// CostPerOrder is fully loaded cost divided by orders.
func (u UnitEconomics) CostPerOrder() float64 {
	if u.OrderCount <= 0 {
		return 0
	}
	return float64(u.TotalCents) / float64(u.OrderCount)
}

// MarginalCostPerOrder removes fixed spend; it is the number that should
// drive discount approval.
func (u UnitEconomics) MarginalCostPerOrder() float64 {
	if u.OrderCount <= 0 {
		return 0
	}
	variable := u.TotalCents - u.FixedCents
	if variable < 0 {
		variable = 0
	}
	return float64(variable) / float64(u.OrderCount)
}

// ContributionCents is revenue minus fully loaded cost.
func (u UnitEconomics) ContributionCents(revenueCents int64) int64 {
	return revenueCents - u.TotalCents
}

三个实现选择值得说明。第一,缺失原因用字符串前缀而不是枚举,报表可以直接按前缀分组,新增维度时不用改账务系统。第二,shared 被视为未分摊而不是自动平均,强制调用方走 SharedPool.Split 并留下驱动量记录。第三,拆分全程使用整数,最后才把剩余单位按余数分配,避免浮点在百万级账单上累积误差。

测试覆盖四类行为:四维归因与未分摊原因、空字符串标签、共享池总额守恒与并列时的确定性、单位经济的固定与变动拆分。

// file: internal/platform/cost/attribution_test.go
// verify: go test ./internal/platform/cost/ -run 'TestAttribute|TestShared|TestUnit' -count=1 -v
package cost

import "testing"

func TestAttributeGroupsAndUnallocated(t *testing.T) {
	resources := []Resource{
		{ID: "vm-web-1", MonthlyCents: 120000, Tags: map[string]string{
			"tenant": "acme", "service": "order-api", "env": "prod", "owner": "team-order"}},
		{ID: "db-order", MonthlyCents: 300000, Tags: map[string]string{
			"tenant": "acme", "service": "order-db", "env": "prod", "owner": "team-order"}},
		{ID: "vm-batch", MonthlyCents: 50000, Tags: map[string]string{
			"tenant": "acme", "service": "settlement", "env": "prod"}},
		{ID: "nat-gw", MonthlyCents: 80000, Tags: map[string]string{
			"tenant": "shared", "service": "platform", "env": "prod", "owner": "team-platform"}},
	}
	got := Attribute(resources)
	if got.Total() != 550000 || got.Unallocated != 130000 {
		t.Fatalf("total=%d unallocated=%d", got.Total(), got.Unallocated)
	}
	if got.Reasons["missing:owner"] != 50000 || got.Reasons["shared:tenant"] != 80000 {
		t.Fatalf("reasons = %v", got.Reasons)
	}
	if got.ByDimension[DimOwner]["team-order"] != 420000 {
		t.Fatalf("owner = %v", got.ByDimension[DimOwner])
	}
	t.Logf("total=%d unallocated=%d share=%.4f owner.team-order=%d",
		got.Total(), got.Unallocated, got.UnallocatedShare(),
		got.ByDimension[DimOwner]["team-order"])
}

func TestAttributeTreatsEmptyTagAsMissing(t *testing.T) {
	got := Attribute([]Resource{{
		ID: "vm-orphan", MonthlyCents: 10000,
		Tags: map[string]string{"tenant": "acme", "service": "", "env": "prod", "owner": "team-order"},
	}})
	if got.Unallocated != 10000 || got.Reasons["missing:service"] != 10000 {
		t.Fatalf("attribution = %+v", got)
	}
}

func TestSharedPoolSplitKeepsTotal(t *testing.T) {
	pool := SharedPool{
		Name:    "observability",
		Cents:   100000,
		Weights: map[string]int64{"order-api": 1, "inventory": 1, "payment": 1},
	}
	shares, err := pool.Split()
	if err != nil {
		t.Fatalf("Split: %v", err)
	}
	var sum int64
	for k, v := range shares {
		sum += v
		t.Logf("pool=%s key=%s share=%d", pool.Name, k, v)
	}
	if sum != pool.Cents {
		t.Fatalf("sum = %d, want %d", sum, pool.Cents)
	}
	if shares["inventory"] != 33334 || shares["order-api"] != 33333 {
		t.Fatalf("tie-break = %v", shares)
	}
}

func TestSharedPoolRejectsBadInput(t *testing.T) {
	for _, c := range []SharedPool{
		{Name: "empty", Cents: 100},
		{Name: "zero", Cents: 100, Weights: map[string]int64{"a": 0}},
		{Name: "negative", Cents: -1, Weights: map[string]int64{"a": 1}},
	} {
		if _, err := c.Split(); err == nil {
			t.Fatalf("pool %q: expected error", c.Name)
		}
	}
}

func TestUnitEconomicsSeparatesFixed(t *testing.T) {
	u := UnitEconomics{TotalCents: 1000000, FixedCents: 400000, OrderCount: 20000}
	if got := u.CostPerOrder(); got != 50 {
		t.Fatalf("cost per order = %v, want 50", got)
	}
	if m := u.MarginalCostPerOrder(); m != 30 {
		t.Fatalf("marginal = %v, want 30", m)
	}
	if c := u.ContributionCents(1200000); c != 200000 {
		t.Fatalf("contribution = %d, want 200000", c)
	}
	t.Logf("orders=%d fully_loaded=%.2f marginal=%.2f", u.OrderCount,
		u.CostPerOrder(), u.MarginalCostPerOrder())
}

本机实测

环境为 macOS darwin/arm64、Go 1.26.0,模块 archlab,命令在 .local/checks/be-arch-gov-ch12 下执行。gofmt -l . 与 go vet ./... 均无输出;以下是归因相关用例的原始输出,同章 12.2、12.3 的用例在同一个包里运行,完整日志见 .local/reports/be-arch-gov/ch12-full.txt。

$ go test ./internal/platform/cost/ -run 'TestAttribute|TestShared|TestUnit' -v -count=1
=== RUN   TestAttributeGroupsAndUnallocated
    attribution_test.go:26: total=550000 unallocated=130000 share=0.2364 owner.team-order=420000
--- PASS: TestAttributeGroupsAndUnallocated (0.00s)
=== RUN   TestAttributeTreatsEmptyTagAsMissing
--- PASS: TestAttributeTreatsEmptyTagAsMissing (0.00s)
=== RUN   TestSharedPoolSplitKeepsTotal
    attribution_test.go:54: pool=observability key=order-api share=33333
    attribution_test.go:54: pool=observability key=payment share=33333
    attribution_test.go:54: pool=observability key=inventory share=33334
--- PASS: TestSharedPoolSplitKeepsTotal (0.00s)
=== RUN   TestSharedPoolRejectsBadInput
--- PASS: TestSharedPoolRejectsBadInput (0.00s)
=== RUN   TestUnitEconomicsSeparatesFixed
    attribution_test.go:87: orders=20000 fully_loaded=50.00 marginal=30.00
--- PASS: TestUnitEconomicsSeparatesFixed (0.00s)
PASS
ok  	archlab/internal/platform/cost	0.258s

读数有三层。归因层,5,500 元里 4,200 元落到 owner=team-order,1,300 元进入未分摊,占比 23.64%,与开场那张账单一致。拆分层,100,000 分按三份等权拆,两个团队各 33,333,另一个拿 33,334,总和精确等于池额,并列时由 key 排序决定谁多拿一分。单位经济层,全loaded 0.50 元、边际 0.30 元,说明固定成本占了四成,折扣审批必须看边际而不是全loaded。

边界要说清楚:这些是固定输入下的确定性计算,不是对真实云账单分布的统计;0.258s 只是本机这个小数据集的运行时间。真实账单还要处理负金额(退款、抵扣)、跨币种和按小时粒度的资源,归因前应先做金额归一化。

归因判据

观测判据动作退出条件
未分摊占比连续两月 > 5%按原因排序,先补 owner回落到 2% 以下
单一原因missing:owner 占未分摊一半以上冻结该团队新资源申请补标签或下线完成
共享池无书面驱动量暂缓拆分,先定驱动量评审记录中有驱动量与样本期
拆分误差份额之和与池额不等停止发布报表,修算法固定样本重复运行结果一致
单位成本全loaded 与边际差距 > 30%定价看边际,容量看全loaded两个口径分别写入报表

常见错误与边界

第一,用资源名推断归属。vm-order-prod-3 看起来属于订单团队,但可能是平台组为压测临时开的;名字不是契约,标签才是。推断规则一旦写进代码,就会在资源改名后静默失效。

第二,把未分摊成本按比例摊回去。报表平了,责任没了。可以展示“如果按收入分摊会是多少”作为参考列,但不能让它替代未分摊桶。

第三,共享池按团队数量平均分。人数和资源消耗几乎不相关,平均分会让小团队为大团队的流量买单,下一次预算评审就没人愿意参加。没有驱动量时宁可先不分。

第四,标签值不做归一化。acme、Acme、acme (尾随空格)会变成三个租户,报表上每个都小得看不见。归一化应在入库时做,而不是在查询时用 lower() 补救,否则历史数据永远修不干净。

小结

成本归因的产物不是一张更漂亮的报表,而是一条可执行的责任链:每一笔钱要么落到一个能做技术决策的负责人,要么带着明确原因留在未分摊桶。四个维度里,owner 不能兜底,shared 不能自动平均,这是两条必须写进校验的规则。

共享池拆分用最大余数法保证总额守恒和结果可复现,单位经济则要同时给出全loaded 与边际两个口径。前者回答“这门生意长期赚不赚钱”,后者回答“这一单该不该接”,混用会得到相反的结论。

归因稳定之后,下一步才是容量决策:预留多少、弹性多少、闲置怎么回收。下一节把这几个数放进同一个成本模型。

阅读导航:上一节:11.3 平台度量:采纳率、交付效率与反悔成本 · 下一节:12.2 容量与成本:预留、弹性与闲置回收 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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