三张表不是一个汇总页
《群岛纪元》第四赛季扩容时,容量表从 42 行涨到 61 行:大地图分片多了 6 个,行军结算队列多了 3 个消费者组,跨区同步链路多了 2 个区域。成本表没有动。两个月后财务对账,发现月度云账单比预算高出 23%,但没有人能说清是哪一次扩容带来的。
同一季度,合规团队把欧洲区账号删除的承诺从 30 天改成 14 天。合规表更新了,架构没有复审。三个月后的一次删除演练暴露了问题:备份保留周期是 35 天,主库删了,备份里还在。合规承诺在纸面上成立,在介质上不成立。
这两件事的根因相同:三张表被当成了三个部门的季度附件,而不是三条互相咬合的控制回路。容量表回答“还能接多少”,成本表回答“每个单位花多少”,合规表回答“哪些数据必须怎么处理”。任何一张变化,另外两张都可能失效。
三张表各自的读者与节奏
先明确谁看、谁填、多久填一次。没有这一步,字段设计得再漂亮也会过期。
| 表 | 主读者 | 填写人 | 更新频率 | 触发更新的额外事件 |
|---|---|---|---|---|
| 容量表 | SRE、技术负责人 | 服务 owner + SRE | 每月 | 大版本、活动、事故、扩容 |
| 成本表 | 产品负责人、财务 | 技术负责人 + 财务 BP | 每月 | 容量变化、配额调整、续约 |
| 合规表 | 合规、平台、法务 | 合规对接人 + 数据 owner | 每季度 | 新法规、新区域、新数据类 |
“每月”不是指月底抄一次监控。容量表的月度更新要重新核对峰值窗口、扩容触发线和故障域余量;成本表要重新算单位成本;合规表即使没有法规变化,也要抽查一条删除链路是否真的跑通。
表一:容量表
容量表的最小可用字段如下。它必须能独立回答“现在多满、掉一个区会怎样、什么时候必须扩”。
| 字段 | 含义 | 口径 | 来源 |
|---|---|---|---|
service | 服务稳定标识 | 与计费、告警同名 | 服务目录 |
layer | cpu / memory / bandwidth / connections | 四层分开 | 第 1 章 |
peak | 峰值需求 | 5 分钟窗口 P95,活动另记 | 监控 |
capacity | 可用容量 | 同层同单位 | 容量模型 |
trigger | 扩容触发线 | 0–1 的利用率 | 扩容时长反推 |
failure_loss | 最大单故障域损失比例 | 0–1,不含 1 | 多活拓扑 |
owner | 对该行负责的人 | 必须是个人或轮值组 | 组织架构 |
evidence | 数据来源 | 看板或压测报告链接 | 可审计 |
updated | 最后核对日期 | ISO 日期 | 流程 |
三个口径最容易写错。
第一,peak 不是月平均在线折算出来的。它要取业务峰值窗口的实际值;如果本月有活动,活动峰值单独占一行,不能和日常峰值混在一起。
第二,capacity 必须是可购买、可观测的绝对量,不能写“剩余 30%”。百分比无法参与故障域计算,也无法和成本表对齐。
第三,failure_loss 要写真实拓扑。三可用区各三分之一,失去一个区就是 0.333;如果分片不能跨区迁移,损失不是容量,而是那部分玩家,这一行要备注“不可迁移”,不能只用数字表达。
以三款产品为例,容量表的粒度不同:
| 产品 | 主要行 | 峰值口径 | 故障域 |
|---|---|---|---|
| 《裂隙战场》 | 房间服 CPU、网关带宽、观战出口 | 同时在线房间数 × 单房成本 | 单可用区 |
| 《群岛纪元》 | 地图分片 CPU、结算队列积压、跨区链路 | 赛季开服 30 分钟 | 单区域 |
| 《泡泡工坊》 | 大厅 QPS、房间创建速率、CDN 回源 | 买量投放小时 | 单可用区 |
《裂隙战场》按房间算,因为它的成本随房间数线性增长;《群岛纪元》按地图分片算,因为单分片能承载的单位数有硬上限;《泡泡工坊》按入口 QPS 算,因为大厅无状态,真正的瓶颈在房间创建和 CDN。
表二:成本表
成本表要能回答三个问题:每个单位花多少、离配额还有多远、规模到多少才开始划算。
| 字段 | 含义 | 口径 |
|---|---|---|
service | 服务稳定标识 | 与容量表一致 |
monthly_usd | 当月总成本 | 含计算、存储、出口、许可 |
unit_name | 业务单位 | room-hour、MAU、GB-month |
units | 当月单位数 | 与 unit_name 同口径 |
unit_cost | 单位成本 | monthly_usd / units |
quota_usd | 月度配额 | 0 表示未设 |
break_even_units | 固定成本摊平所需单位数 | 0 表示不适用 |
owner | 预算负责人 | 必须是能签字的人 |
source | 账单导出标识 | 可追溯到账单行 |
updated | 最后核对日期 | ISO 日期 |
单位的选择决定这张表有没有用。同一个服务,用“每房间小时”算,和用“每 MAU”算,会得到完全不同的结论。前者适合《裂隙战场》,因为成本主要由对局时长驱动;后者适合《泡泡工坊》,因为大量玩家只登录领奖励,不开房间。
配额不是预算的同义词。预算是“最多能花多少”,配额是“超过多少必须解释”。成本表里 quota_usd 的作用是触发讨论,不是自动停机。真正要停机的是单位成本持续高于收入贡献,那是另一个决策。
拐点是成本表里最容易被忽略的一列。固定成本(专用节点、预留实例、跨区专线)在低规模时摊不平;break_even_units 告诉你“这个架构至少要跑到多少单位才比按量付费便宜”。低于拐点还坚持专用集群,通常是在为“架构看起来专业”付费。
表三:合规表
合规表不是法规摘抄。它要把一条规则落到数据类、服务、区域和两个时限上。
| 字段 | 含义 | 示例 |
|---|---|---|
rule | 规则标识 | eu-account-deletion |
data_class | 数据分类 | account、payment、telemetry |
services | 持有该数据的服务 | 稳定服务标识列表 |
region | 适用区域 | eu、cn、global |
retention_days | 允许保留天数 | 180 |
deletion_days | 删除请求完成时限 | 14 |
legal_basis | 合法性依据 | 合同、同意、法定义务 |
owner | 数据 owner | 平台或产品 |
evidence | 删除演练或审计记录 | 链接 |
updated | 最后核对日期 | ISO 日期 |
两个时钟必须分开写。retention_days 是“最多能留多久”,deletion_days 是“用户提出删除后多久必须删完”。前者约束数据生命周期,后者约束删除链路。把两者合成一个“保留 30 天”,最后一定会在备份、日志、数仓三处出现口径冲突。
services 必须写服务标识,不能写团队名。写“数据平台组”看起来责任清楚,但代码无法据此找到持有数据的服务,架构复审时也无从判断影响面。
区域字段不能只写 global。全球同服不等于全球同一套数据规则;《群岛纪元》的账号数据按区域驻留,战斗统计可以跨境,这两类数据要拆成两行。
三张表怎么联动
三张表放在同一个服务标识体系下,才能形成回路:
容量表 --(Δ≥10% 或新增服务)--> 成本表 --(超配额 10%)--> 预算/拓扑决策
| ^
|(故障域利用率 > 1) |(单位成本变化)
v |
架构复审 <--(新数据类/新区域)------ 合规表
具体规则可以写成四条:
| 变化 | 触发 | 动作 | 时限 |
|---|---|---|---|
| 容量变化 ≥10% 或新增服务 | 成本表 | 重算单位成本与拐点 | 5 个工作日 |
| 故障域利用率 >1 | 架构复审 | 补余量、改拓扑或接受风险 | 下次发布前 |
| 新数据类、新区域、法规变化 | 合规表 + 架构复审 | 重画数据流与删除链路 | 10 个工作日 |
| 删除时限短于备份保留 | 合规表 + 容量表 | 改备份策略或缩短保留 | 30 天 |
第一条是容量到成本的单向触发。扩容 10% 不一定让成本涨 10%,因为固定成本被摊薄;但不重算就永远不知道真实单位成本。
第二条是容量到架构的反向触发。故障域利用率超过 1,说明当前拓扑已经接不住一次单区故障,这不是“再加一台机器”能解决的。
第三条是合规到架构的触发。新数据类往往意味着新的存储、新的跨境路径、新的删除链路,必须重新做数据流图。
第四条是合规内部的冲突检测。删除时限比备份保留还短,说明承诺和介质不一致;这类冲突必须在演练前发现,而不是在监管问询时发现。
可运行片段
下面的 link.go 把上述联动写成可执行规则。同包的 table.go 定义三张表的行结构、整表校验和缺项扫描;所有校验方法都围绕“未知不能写成 0”这条契约。
// file: internal/capacitytable/link.go
// tier: L1
// verify: go test ./internal/capacitytable/
package capacitytable
import "fmt"
type ChangeKind string
const (
CapacityChanged ChangeKind = "capacity"
ComplianceChanged ChangeKind = "compliance"
)
type Change struct {
Kind ChangeKind
Service string
Delta float64 // fractional capacity change; zero means "review only"
}
type Impact struct {
Service string
RecomputeCost bool
ArchitectureReview bool
Reasons []string
}
// Link encodes the two rules the three tables cannot enforce by themselves:
// a capacity change invalidates the unit-economics row, and a compliance
// change on a held data class reopens the architecture decision.
func Link(t Table, c Change) (Impact, error) {
impact := Impact{Service: c.Service}
switch c.Kind {
case CapacityChanged:
found := false
for _, r := range t.Capacity {
if r.Service == c.Service {
found = true
break
}
}
if !found {
return Impact{}, fmt.Errorf("%w: no capacity row for %s", ErrTable, c.Service)
}
if c.Delta != 0 {
impact.RecomputeCost = true
impact.Reasons = append(impact.Reasons, fmt.Sprintf("capacity changed by %.0f%%", c.Delta*100))
}
for _, r := range t.Capacity {
if r.Service == c.Service && r.FailureUtil() > 1 {
impact.ArchitectureReview = true
impact.Reasons = append(impact.Reasons, fmt.Sprintf("%s no longer survives losing %.0f%% capacity", r.Layer, r.FailureDomainLoss*100))
}
}
case ComplianceChanged:
found := false
for _, r := range t.Compliance {
for _, svc := range r.Services {
if svc != c.Service {
continue
}
found = true
impact.ArchitectureReview = true
impact.Reasons = append(impact.Reasons, fmt.Sprintf("%s applies to %s (%d-day retention)", r.Rule, r.DataClass, r.RetentionDays))
}
}
if !found {
return Impact{}, fmt.Errorf("%w: no compliance row covers %s", ErrTable, c.Service)
}
default:
return Impact{}, fmt.Errorf("%w: unknown change kind %q", ErrTable, c.Kind)
}
return impact, nil
}
Link 只接受两类变化:容量变化和合规变化。成本变化不触发架构复审,因为成本超配额首先是一个预算决策,不是拓扑决策;它应该走配额流程,而不是自动改架构。
读代码:联动规则的三个边界
第一,Delta == 0 表示“只复审、不重算”。它用于季度评审:容量没变,但拓扑或依赖变了,仍然要看一眼成本。把 0 当成“没有变化所以什么都不做”,会让评审退化成签字。
第二,找不到对应行时返回错误,而不是静默通过。Link 对容量变化要求至少有一行 capacity,对合规变化要求至少有一行 compliance 覆盖该服务。拼错服务名、用了旧服务名、服务刚拆分但表没更新,都会在这里暴露。
第三,故障域判断遍历该服务的所有层。一个服务可能在 CPU 层很宽裕,但在连接数层已经超过故障域余量;只看 CPU 会漏掉真正的单点。FailureUtil 的分母是 capacity × (1 - loss),损失为 0 时退化成普通利用率。
合规分支会为一个服务返回多条原因:账号删除、支付留存、遥测跨境可能同时覆盖它。Reasons 是给人看的审计文本,不要拿它做程序判断;程序判断应该用 ArchitectureReview 布尔值和结构化字段。
失败模式与兜底
只写百分比。 容量表写“CPU 剩余 30%”,无法算故障域,也无法和成本对齐。兜底是强制绝对量,百分比只作为派生列。
单位混用。 成本表同一列里既有 room-hour 又有 MAU,单位成本没有可比性。兜底是每行只允许一个 unit_name,跨服务比较前先换算到统一单位。
合规表写团队名。 services 填“数据平台组”,代码找不到持有者。兜底是服务目录校验,填写的标识必须存在。
删除时限短于备份保留。 主库删了,备份还在,承诺不成立。兜底是把备份保留纳入合规表的证据字段,删除演练必须覆盖备份恢复点。
三张表更新不同步。 容量表每月更新,成本表每季度更新,联动就断了。兜底是把“容量变化 ≥10%”做成成本表的强制触发器,而不是靠人记得。
没有 owner。 行存在,但没人对数字负责。兜底是 Missing() 把空 owner 列为缺项,评审会不通过。
把所有服务塞进一张表。 行数太多,没人看得完。兜底是按产品域拆表,但保持服务标识全局唯一。
常见错误与边界
第一,把容量表当监控大盘。表是决策记录,大盘是原始数据;表要保留峰值窗口和判断,不需要秒级曲线。
第二,用容量利用率推算成本。成本由单位数和单价驱动,不由利用率驱动;闲置容量也会花钱。
第三,合规表只列法规名。法规名不能告诉工程师改哪个字段、删哪条链路。
第四,把 retention_days 和 deletion_days 当成一个数。一个是上限,一个是承诺时限。
第五,成本表不含共享平台分摊。登录、匹配、观测这些共享组件不摊到产品,单位经济会被低估。
第六,三张表用不同的服务命名。没有稳定主键,联动只能靠人肉匹配。
第七,联动规则写在文档里但不执行。规则必须进代码或进发布检查单,否则下一次扩容仍然会漏掉成本表。
小结
三张表的价值不在“汇总”,而在“互相触发”。容量表给出绝对量和故障域余量,成本表给出单位经济、配额和拐点,合规表给出数据类、区域和两个时限。三者用同一个服务标识连接,容量变化触发成本重算,合规变化触发架构复审。
这一节把表结构和联动规则做成了可执行代码。下一节换一个视角:当多个架构选项同时摆在桌上,如何用一张带权重的矩阵把争论变成可复现的评分,并识别哪些分数其实是不能妥协的硬约束。
阅读导航:上一节:12.3 《泡泡工坊》的轻量出海 · 下一节:13.2 全球化架构决策矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。