《游戏服务端架构·规模化、全球化与成本》9.1 成本归因与单位经济

《群岛纪元》的月度账单涨了 23%,却没人说得清是哪个产品、哪个玩法烧掉的。本节把计算、带宽、存储、数据库、CDN 与第三方服务拆成可归因的成本行,给出每 DAU、每房间小时、每千次对局与每 GB 的单位经济算法,并用可运行的 Go 夹具演示共享成本分摊、环比对比与账目平衡校验。

一张账单,三种产品

月度成本评审上,财务给出一个数字:北辰工作室上个月基础设施支出示例为 100 万元,比前一个月多了 23%。工程侧的第一反应是“DAU 也涨了”,但涨幅只有 11%。剩下 12 个百分点去了哪里,没有人能立刻回答。

《裂隙战场》刚加了观战回放,《群岛纪元》开了一个新赛季并延长了结算日志保留期,《泡泡工坊》接入了第二家广告归因服务。三件事都可能让账单上涨,但账单只给总量,不给业务解释。没有归因,成本讨论就会退化成“大家省着点”。

这一节不谈怎么砍成本,只解决一个前置问题:这笔钱是谁花的,按什么口径算,和上个月比到底是变好还是变坏。 归因做不对,后面的配额、混部、体验取舍都无从谈起;你会把预算砍到声音最大的团队,而不是成本最高的链路。

先按账单维度拆,不按组织架构拆

成本行要能和发票对上,所以第一层分类必须跟着账单走:

账单维度典型内容归因难点
计算房间服、大厅服、批处理共享节点、预留实例、闲置容量
带宽公网出流量、跨区复制峰值计费、方向不对称
存储对象存储、块存储、备份多版本、冷热分层、生命周期
数据库实例、只读副本、备份连接池共享、跨产品共库
CDN静态资源、补丁分发按域名还是按产品
第三方登录、支付、推送、反作弊按调用量还是按 DAU 阶梯

按组织架构拆(客户端、服务端、平台)看起来顺眼,但对不上账单。一个团队可能同时消耗计算和第三方服务,而同一台宿主机上跑着三个产品的房间服。正确的顺序是:先把发票拆成成本行,再把成本行映射到产品和玩法。

每行至少带四个标签:product、env、feature、owner。env 不能省,测试环境的成本混进生产,会让单位经济永远算不准;feature 用于回答“回放功能到底多少钱”,没有它就只能按产品平均分摊,而平均值无法支持功能级决策。

标签覆盖率要作为一项指标公开。示例目标可以是 95%,但不要假装 100%:预留实例未分摊、跨产品共库、平台工具链,天然存在无法直接归因的部分。无法归因的部分必须单列,不能摊进任何一个产品。 摊进去会让那个产品的单位成本虚高,下一次评审就会砍错地方。

共享成本:能摊的摊,不能摊的单列

平台侧有大量共享支出:网关、登录、监控、日志、控制面、安全扫描。它们服务所有产品,账单上却只有一行。常见做法是按收入分摊,听起来公平,实际会把“赚钱的产品”惩罚成“成本高的产品”,而真正消耗资源的免费玩家活动被隐藏了。

更可靠的分摊基准是资源使用量,不是收入:

共享项分摊基准理由
网关与登录请求数或 DAU与流量成正比
监控与日志写入字节数谁打日志谁付费
控制面实例数或房间数与调度对象数量相关
安全与合规扫描资产数与暴露面相关
客服工具工单数与运营强度相关

分摊要满足三个性质。第一,可复算:给定原始行和权重,任何人重跑都得到同一结果。第二,不留小数幻觉:整数分摊产生的余数进入“未归因”,不要为了方便把最后几厘钱塞给最大的产品。第三,可解释:产品负责人能看到自己的权重从哪来。用 DAU 分摊,就要能导出那个 DAU 的口径;用房间小时分摊,就要说明房间小时怎么统计。

《泡泡工坊》曾经按收入分摊共享成本,结果它的单位成本看起来比《裂隙战场》还高。换成按请求数分摊后,真相是休闲产品的请求量极大、单请求成本极低,收入分摊完全扭曲了它的资源画像。这个例子说明:分摊基准选错,比不分摊更危险,因为它看起来精确。

单位经济:四个分母,峰值与均值分开

总量对比只能回答“花得多了还是少了”,单位经济才能回答“效率变好了还是变坏了”。游戏服务端至少需要四个分母:

指标分母适用产品用途
每 DAU 成本日活跃用户三款通用大盘效率、版本对比
每房间小时成本房间运行小时《裂隙战场》《泡泡工坊》房间服容量与定价
每千次对局成本完成对局数《裂隙战场》匹配与战斗链路
每 GB 存储成本月末存储量《群岛纪元》日志、回放、存档

分母必须和成本同周期。拿月末存储量去除以整月存储账单,会低估成本,因为存储是逐日增长的;正确做法是用“GB·天”积分,或者至少用月初月末均值并注明口径。

峰值和均值要分开算。带宽按 95 峰值计费,用平均流量算单位成本会得到一个漂亮的假数字;《裂隙战场》晚八点的观战流量是凌晨的四倍,示例峰值带宽成本占总带宽成本的六成,而均值只占三成。容量决策看峰值,效率趋势看均值,两个口径都要保留。

环比:总量涨不等于单位成本恶化

环比最容易犯的错,是只报一个百分比。正确的拆法是三层:

  1. 总量变化:账单从 100 万变成 123 万,示例涨幅 23%。
  2. 规模变化:DAU 从 50 万变成 55.5 万,示例涨幅 11%。
  3. 单位成本变化:每 DAU 成本从 2.00 元变成 2.22 元,示例涨幅约 11%。

如果单位成本也涨了,再往下拆是哪个维度涨:计算、带宽、存储还是第三方。如果单位成本没涨,总量上涨只是规模扩张,不该触发“降本”动作,而该检查容量规划是否跟得上。容量与预留的口径见1.3 容量模型与预留 ,缩容释放的成本见2.3 缩容与排水成本 。

还有一种更隐蔽的情况:总量下降、单位成本上升。通常发生在缩容之后——实例少了,但预留和固定费用没退,分母掉得比分子快。这时该做的是处理预留和闲置容量,而不是继续缩容。缩容省的是变动成本,固定成本要靠合同和预留策略解决。

数据模型与可运行片段

internal/costattribution 把上面的口径落成四组函数:TotalByCategory 对齐账单维度,AllocateShared 按整数权重分摊共享行并把余数留在未归因,UnitEconomics 计算四个分母,Compare 做环比且拒绝零基线,CheckBalance 强制“已归因 + 未归因 = 账单总额”。

// file: internal/costattribution/costattribution.go
// tier: L1
// verify: go test ./internal/costattribution/
package costattribution

import (
	"errors"
	"fmt"
	"sort"
)

// Category is the billing dimension a line belongs to. It deliberately
// matches how the invoice is read, not how the service is deployed.
type Category string

const (
	Compute    Category = "compute"
	Bandwidth  Category = "bandwidth"
	Storage    Category = "storage"
	Database   Category = "database"
	CDN        Category = "cdn"
	ThirdParty Category = "third_party"
)

// Line is one metered cost line. An empty Product marks a shared line
// (gateway, observability, control plane) that must be allocated.
type Line struct {
	Product  string
	Category Category
	Micros   int64
	Tag      string
}

// Total sums every line, including shared lines.
func Total(lines []Line) int64 {
	var sum int64
	for _, l := range lines {
		sum += l.Micros
	}
	return sum
}

// TotalByCategory is the first view finance and engineering must agree on.
func TotalByCategory(lines []Line) map[Category]int64 {
	out := make(map[Category]int64)
	for _, l := range lines {
		out[l.Category] += l.Micros
	}
	return out
}

// AllocateShared assigns lines with an empty Product by integer weights
// (DAU, room-hours, requests). Integer truncation is intentional: the
// remainder is reported as unattributed instead of being invented.
func AllocateShared(lines []Line, weights map[string]int64) (map[string]int64, int64) {
	out := make(map[string]int64)
	var unattributed int64

	names := make([]string, 0, len(weights))
	var totalWeight int64
	for name, w := range weights {
		if w <= 0 {
			continue
		}
		names = append(names, name)
		totalWeight += w
	}
	sort.Strings(names)

	for _, l := range lines {
		if l.Product != "" {
			out[l.Product] += l.Micros
			continue
		}
		if totalWeight == 0 {
			unattributed += l.Micros
			continue
		}
		var given int64
		for _, name := range names {
			share := l.Micros * weights[name] / totalWeight
			out[name] += share
			given += share
		}
		unattributed += l.Micros - given
	}
	return out, unattributed
}

// Units is the denominator set for unit economics. All four are counted
// over the same period as the cost snapshot.
type Units struct {
	DAU       int64
	RoomHours int64
	Matches   int64
	StorageGB int64
}

// UnitCosts is expressed in micros of currency per unit. A zero denominator
// yields zero rather than NaN so a sparse product does not poison a report.
type UnitCosts struct {
	PerDAU             float64
	PerRoomHour        float64
	PerThousandMatches float64
	PerGBStorage       float64
}

// UnitEconomics converts a total into the four denominators the studio uses
// in reviews. Matches are scaled to thousands to keep the number readable.
func UnitEconomics(totalMicros int64, u Units) UnitCosts {
	var c UnitCosts
	if u.DAU > 0 {
		c.PerDAU = float64(totalMicros) / float64(u.DAU)
	}
	if u.RoomHours > 0 {
		c.PerRoomHour = float64(totalMicros) / float64(u.RoomHours)
	}
	if u.Matches > 0 {
		c.PerThousandMatches = float64(totalMicros) * 1000 / float64(u.Matches)
	}
	if u.StorageGB > 0 {
		c.PerGBStorage = float64(totalMicros) / float64(u.StorageGB)
	}
	return c
}

// Snapshot is one period of cost plus the units it served.
type Snapshot struct {
	Period      string
	TotalMicros int64
	Units       Units
}

// Delta compares two periods in absolute micros and percent.
type Delta struct {
	AbsoluteMicros int64
	Percent        float64
	PerDAUPercent  float64
}

// ErrNoBaseline is returned when the previous period has no cost, because a
// percentage against zero is not a trend.
var ErrNoBaseline = errors.New("costattribution: previous total is zero")

// Compare reports period-over-period movement. It refuses to divide by a
// zero baseline instead of printing an infinite percentage.
func Compare(current, previous Snapshot) (Delta, error) {
	if previous.TotalMicros == 0 {
		return Delta{}, ErrNoBaseline
	}
	d := Delta{AbsoluteMicros: current.TotalMicros - previous.TotalMicros}
	d.Percent = float64(d.AbsoluteMicros) / float64(previous.TotalMicros) * 100

	if previous.Units.DAU > 0 && current.Units.DAU > 0 {
		prevPerDAU := float64(previous.TotalMicros) / float64(previous.Units.DAU)
		curPerDAU := float64(current.TotalMicros) / float64(current.Units.DAU)
		if prevPerDAU != 0 {
			d.PerDAUPercent = (curPerDAU - prevPerDAU) / prevPerDAU * 100
		}
	}
	return d, nil
}

// BalanceError names the gap between the invoice total and the sum of
// product allocations plus the explicit unattributed bucket.
type BalanceError struct {
	Total        int64
	Attributed   int64
	Unattributed int64
}

func (e *BalanceError) Error() string {
	return fmt.Sprintf("costattribution: total=%d attributed=%d unattributed=%d gap=%d",
		e.Total, e.Attributed, e.Unattributed,
		e.Total-e.Attributed-e.Unattributed)
}

// CheckBalance enforces "attributed + unattributed == invoice total". A
// non-zero result means the tagging pipeline lost or duplicated a line.
func CheckBalance(total int64, perProduct map[string]int64, unattributed int64) error {
	var attributed int64
	for _, v := range perProduct {
		attributed += v
	}
	if attributed+unattributed != total {
		return &BalanceError{Total: total, Attributed: attributed, Unattributed: unattributed}
	}
	return nil
}

本机验证命令与结果:

cd .local/checks/gsarch-lab
go test -count=1 ./internal/costattribution/
ok  gsarchlab/internal/costattribution

该包共有 5 个表驱动测试,覆盖分类汇总、共享分摊(等权、加权、单产品、无权重)、单位经济、环比与零基线、账目平衡;gofmt -l 无输出,go vet 退出码为 0。

归因链路的四种失败模式

失败模式现象后果兜底
标签缺失一批实例没有 product成本进入未归因,越积越大上线即打标,缺失率告警
重复分摊共享行既按 DAU 又按收入摊各产品之和大于账单CheckBalance 阻断报表发布
基准漂移分摊权重每月换口径环比失去可比性权重版本化,变更需记录
账期错配月末流量与整月账单对比单位成本被低估统一按日积分或注明口径

前两个是数据问题,后两个是方法问题。数据问题可以靠校验拦住;方法问题只能靠把口径写进报表模板,让每次评审都看到同样的定义。成本报表不是财务的附属品,它是架构决策的输入,定义必须和容量模型一样被版本管理。

小结

成本归因的目标不是把每一分钱都摊干净,而是让每个产品、每个玩法都能回答“我花了多少、单位效率如何、比上期变好还是变坏”。账单维度优先于组织架构;共享成本按资源使用量分摊,摊不掉的单列;单位经济至少看 DAU、房间小时、千次对局、GB 存储四个分母;环比要拆开规模与效率,零基线不能算百分比。

常见错误有四个:按收入分摊共享成本、把未归因成本硬塞给最大产品、用平均流量解释峰值计费、只报总量百分比不报单位成本。它们共同的特点是“看起来精确”,因而比明显的估算更容易误导决策。

下一篇讨论配额与混部:归因告诉你钱花在哪,配额决定每个产品最多能花多少,混部决定这些额度能不能放进同一台机器。

阅读导航:上一节:8.3 有状态服务的灾难恢复 · 下一节:9.2 资源配额与混部 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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