一张 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 容量与成本:预留、弹性与闲置回收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。