一张账单,三种产品
月度成本评审上,财务给出一个数字:北辰工作室上个月基础设施支出示例为 100 万元,比前一个月多了 23%。工程侧的第一反应是“DAU 也涨了”,但涨幅只有 11%。剩下 12 个百分点去了哪里,没有人能立刻回答。
《裂隙战场》刚加了观战回放,《群岛纪元》开了一个新赛季并延长了结算日志保留期,《泡泡工坊》接入了第二家广告归因服务。三件事都可能让账单上涨,但账单只给总量,不给业务解释。没有归因,成本讨论就会退化成“大家省着点”。
这一节不谈怎么砍成本,只解决一个前置问题:这笔钱是谁花的,按什么口径算,和上个月比到底是变好还是变坏。 归因做不对,后面的配额、混部、体验取舍都无从谈起;你会把预算砍到声音最大的团队,而不是成本最高的链路。
先按账单维度拆,不按组织架构拆
成本行要能和发票对上,所以第一层分类必须跟着账单走:
| 账单维度 | 典型内容 | 归因难点 |
|---|---|---|
| 计算 | 房间服、大厅服、批处理 | 共享节点、预留实例、闲置容量 |
| 带宽 | 公网出流量、跨区复制 | 峰值计费、方向不对称 |
| 存储 | 对象存储、块存储、备份 | 多版本、冷热分层、生命周期 |
| 数据库 | 实例、只读副本、备份 | 连接池共享、跨产品共库 |
| CDN | 静态资源、补丁分发 | 按域名还是按产品 |
| 第三方 | 登录、支付、推送、反作弊 | 按调用量还是按 DAU 阶梯 |
按组织架构拆(客户端、服务端、平台)看起来顺眼,但对不上账单。一个团队可能同时消耗计算和第三方服务,而同一台宿主机上跑着三个产品的房间服。正确的顺序是:先把发票拆成成本行,再把成本行映射到产品和玩法。
每行至少带四个标签:product、env、feature、owner。env 不能省,测试环境的成本混进生产,会让单位经济永远算不准;feature 用于回答“回放功能到底多少钱”,没有它就只能按产品平均分摊,而平均值无法支持功能级决策。
标签覆盖率要作为一项指标公开。示例目标可以是 95%,但不要假装 100%:预留实例未分摊、跨产品共库、平台工具链,天然存在无法直接归因的部分。无法归因的部分必须单列,不能摊进任何一个产品。 摊进去会让那个产品的单位成本虚高,下一次评审就会砍错地方。
共享成本:能摊的摊,不能摊的单列
平台侧有大量共享支出:网关、登录、监控、日志、控制面、安全扫描。它们服务所有产品,账单上却只有一行。常见做法是按收入分摊,听起来公平,实际会把“赚钱的产品”惩罚成“成本高的产品”,而真正消耗资源的免费玩家活动被隐藏了。
更可靠的分摊基准是资源使用量,不是收入:
| 共享项 | 分摊基准 | 理由 |
|---|---|---|
| 网关与登录 | 请求数或 DAU | 与流量成正比 |
| 监控与日志 | 写入字节数 | 谁打日志谁付费 |
| 控制面 | 实例数或房间数 | 与调度对象数量相关 |
| 安全与合规扫描 | 资产数 | 与暴露面相关 |
| 客服工具 | 工单数 | 与运营强度相关 |
分摊要满足三个性质。第一,可复算:给定原始行和权重,任何人重跑都得到同一结果。第二,不留小数幻觉:整数分摊产生的余数进入“未归因”,不要为了方便把最后几厘钱塞给最大的产品。第三,可解释:产品负责人能看到自己的权重从哪来。用 DAU 分摊,就要能导出那个 DAU 的口径;用房间小时分摊,就要说明房间小时怎么统计。
《泡泡工坊》曾经按收入分摊共享成本,结果它的单位成本看起来比《裂隙战场》还高。换成按请求数分摊后,真相是休闲产品的请求量极大、单请求成本极低,收入分摊完全扭曲了它的资源画像。这个例子说明:分摊基准选错,比不分摊更危险,因为它看起来精确。
单位经济:四个分母,峰值与均值分开
总量对比只能回答“花得多了还是少了”,单位经济才能回答“效率变好了还是变坏了”。游戏服务端至少需要四个分母:
| 指标 | 分母 | 适用产品 | 用途 |
|---|---|---|---|
| 每 DAU 成本 | 日活跃用户 | 三款通用 | 大盘效率、版本对比 |
| 每房间小时成本 | 房间运行小时 | 《裂隙战场》《泡泡工坊》 | 房间服容量与定价 |
| 每千次对局成本 | 完成对局数 | 《裂隙战场》 | 匹配与战斗链路 |
| 每 GB 存储成本 | 月末存储量 | 《群岛纪元》 | 日志、回放、存档 |
分母必须和成本同周期。拿月末存储量去除以整月存储账单,会低估成本,因为存储是逐日增长的;正确做法是用“GB·天”积分,或者至少用月初月末均值并注明口径。
峰值和均值要分开算。带宽按 95 峰值计费,用平均流量算单位成本会得到一个漂亮的假数字;《裂隙战场》晚八点的观战流量是凌晨的四倍,示例峰值带宽成本占总带宽成本的六成,而均值只占三成。容量决策看峰值,效率趋势看均值,两个口径都要保留。
环比:总量涨不等于单位成本恶化
环比最容易犯的错,是只报一个百分比。正确的拆法是三层:
- 总量变化:账单从 100 万变成 123 万,示例涨幅 23%。
- 规模变化:DAU 从 50 万变成 55.5 万,示例涨幅 11%。
- 单位成本变化:每 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 资源配额与混部 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。