卷二收口,但问题没有收完
卷二用十二章回答了“核心系统怎么划分、契约怎么定、故障怎么兜”。这些问题在三款产品当前的体量下都有可执行的答案。但架构书最容易骗人的地方,是把“当前够用”写成“以后也行”。
北辰的下一阶段很明确:《裂隙战场》要开更多赛区,《群岛纪元》要进更多国家和地区,《泡泡工坊》要压单位成本。三件事都指向同一组问题:容量、跨区、成本、多地域部署。卷二没有回答它们,也不应该假装回答。
本节不引入新系统,只做交接:把未决问题列清楚,把进入卷三前必须拿到的测量列成检查点,并明确一条规则——没有测量,不做全球化。
四个未决问题
| 问题 | 卷二已经给出的结论 | 卷三必须回答 | 需要的测量 |
|---|---|---|---|
| 容量 | 单区拓扑、登录排队、实例池、AOI 分线都有边界 | 单区能扛多少 CCU,扩容是加进程还是换分片 | 压测的 CCU 曲线与 p99 |
| 跨区 | 全球同服按逻辑分区,跨区战斗靠路由 | 跨区延迟、丢包、故障切换是否可接受 | 真实链路 RTT 与切换演练 |
| 成本 | 经济与榜单有读写模型,平台有多租户隔离 | 每 DAU、每峰值小时的基础设施成本 | 账单分摊与资源利用率 |
| 多地域 | 合规数据最小化、删除可传导 | 数据驻留、写入冲突、运维半径 | 部署验证与法务确认 |
四类问题不能混在一起做。容量是单区纵向问题,跨区是链路问题,成本是资源效率问题,多地域是合规与运维问题。把它们打包成“全球化项目”,通常会导致每一项都做了一半。
检查点清单:先有基线,再谈扩容
检查点不是待办清单,而是“没有这个数就不能做决定”的门槛。每一项都要有明确的指标、单位和证据来源。
| ID | 区域 | 必需 | 要回答的问题 | 指标与证据(示例值) |
|---|---|---|---|---|
| ccu-peak | capacity | 是 | 单区峰值能承载多少 CCU | 压测报告,示例值 12000 CCU |
| tick-budget | capacity | 是 | 战斗 tick p99 与超时率 | 压测报告,示例值 p99 18 ms |
| settlement-p99 | capacity | 是 | 结算落库 p99 与重试率 | 压测报告,示例值 p99 220 ms |
| login-drain | capacity | 否 | 开服排队多久排空 | 演练报告,示例值 8 min |
| rtt-cross-region | cross-region | 是 | 跨区 RTT p99 | 真实链路探测,示例值 120 ms |
| failover-time | cross-region | 是 | 单区故障切换需要多久 | 演练报告,示例值 45 s |
| cost-per-dau | cost | 是 | 每 DAU 每日基础设施成本 | 账单分摊,示例值 0.08 元 |
| cost-peak-hour | cost | 否 | 峰值小时成本 | 账单,示例值 620 元/小时 |
| data-residency | multi-region | 是 | 数据驻留是否满足当地要求 | 法务确认 + 部署验证 |
| write-conflict | multi-region | 否 | 多地域写入冲突率 | 实验结果,示例值 0.02% |
表里的数字全部是示例值,不是实测。真正进入卷三前,每一行都要替换成带命令、日期和报告路径的真实结果;替换不了的行,就是还没准备好。
为什么有些项标“否”仍然重要?因为它们不阻塞当前决策,但会影响下一轮优化。登录排空时间不影响“能不能开区”,却决定开区当天要不要限流;峰值小时成本不影响“要不要出海”,却决定出海后的定价模型。
就绪判定:状态标签不算测量
一个检查点只有三种状态:
missing:没有数据;estimated:有数字,但来自经验、同类产品、厂商报价或纸面推算;measured:在目标拓扑上跑过,有单位、有证据、可复现。
只有 measured 才算满足。更严格地说,measured 还必须带单位和证据:一个写着“p99 18”但没有单位、没有报告路径的数字,仍然按未测量处理。这不是形式主义,而是防止“拍脑袋数字”在评审会上被当成事实。
判定规则有两条:
- 所有
required检查点必须满足; - 所有
cross-region与multi-region检查点必须满足,无论是否标记为必需。
第二条是本节最重要的约束。跨区和多地域的假设最容易错,也最贵:一个 estimated 的 RTT 可能让战斗同步方案整体失效,一个 estimated 的数据驻留结论可能让整个区域无法上线。所以这类检查点没有“可选”一说。
不要在没有测量的情况下做全球化
三个常见的错误估算:
第一,用“同类游戏差不多”估容量。同类游戏的战斗模型、AOI 密度、结算频率都不同,CCU 相同不代表负载相同。12.2 讲过多租户隔离,共用平台不等于共用容量曲线。
第二,用机房之间的理论延迟估跨区。理论光纤延迟只是下界,真实链路要经过运营商、边界网关和 TLS 握手。9.3 的读缓存能减少跨区读,但不能消除写路径上的往返。
第三,用云厂商报价估成本。报价不含跨区流量、对象存储请求费、日志和备份。成本检查点必须来自真实账单分摊,而不是单价乘数量。
没有测量的全球化,结果通常不是“慢一点”,而是“上线后才发现要重做同步模型”。重做的时间成本远高于提前做一次压测和一次故障演练。
可运行片段:就绪检查与缺口报告
下面的 internal/releaseready 把检查点、三种状态、两条阻塞规则和按区域统计的覆盖率写成一个包。Evaluate 返回是否就绪和阻塞原因,Gaps 返回所有未满足项,Coverage 给出各区域的测量进度。
// file: internal/releaseready/releaseready.go
// tier: L1
// verify: go test ./internal/releaseready/
package releaseready
import (
"fmt"
"sort"
"strings"
)
// Area groups checkpoints by the question they answer.
type Area string
const (
AreaCapacity Area = "capacity"
AreaCrossRegion Area = "cross-region"
AreaCost Area = "cost"
AreaMultiRegion Area = "multi-region"
)
// Status is deliberately about evidence, not optimism. Estimated means the
// number exists but was not produced by a run on the real topology.
type Status string
const (
StatusMissing Status = "missing"
StatusEstimated Status = "estimated"
StatusMeasured Status = "measured"
)
// Checkpoint is one question that must be answered before volume three.
// Evidence is not decoration: a measured value without a command, report path
// or run id is treated as a claim.
type Checkpoint struct {
ID string
Area Area
Question string
Required bool
Status Status
Value float64
Unit string
Evidence string
}
func areaValid(a Area) bool {
switch a {
case AreaCapacity, AreaCrossRegion, AreaCost, AreaMultiRegion:
return true
}
return false
}
func statusValid(s Status) bool {
switch s {
case StatusMissing, StatusEstimated, StatusMeasured:
return true
}
return false
}
func areaRank(a Area) int {
switch a {
case AreaCapacity:
return 0
case AreaCrossRegion:
return 1
case AreaCost:
return 2
case AreaMultiRegion:
return 3
}
return 4
}
// Gate is the ordered checklist for entering volume three.
type Gate struct {
checkpoints []Checkpoint
}
// New rejects malformed rows. Duplicate ids are rejected because a duplicated
// checkpoint silently weakens the readiness rule.
func New(checkpoints []Checkpoint) (*Gate, error) {
seen := make(map[string]bool, len(checkpoints))
for _, c := range checkpoints {
if strings.TrimSpace(c.ID) == "" {
return nil, fmt.Errorf("releaseready: empty checkpoint id")
}
if seen[c.ID] {
return nil, fmt.Errorf("releaseready: duplicate checkpoint %q", c.ID)
}
seen[c.ID] = true
if strings.TrimSpace(c.Question) == "" {
return nil, fmt.Errorf("releaseready: checkpoint %q has no question", c.ID)
}
if !areaValid(c.Area) {
return nil, fmt.Errorf("releaseready: checkpoint %q has unknown area %q", c.ID, c.Area)
}
if !statusValid(c.Status) {
return nil, fmt.Errorf("releaseready: checkpoint %q has unknown status %q", c.ID, c.Status)
}
}
return &Gate{checkpoints: append([]Checkpoint(nil), checkpoints...)}, nil
}
// satisfied is stricter than "the status says measured". A measured number
// without a unit or evidence is an estimate wearing a measured label.
func satisfied(c Checkpoint) bool {
return c.Status == StatusMeasured && strings.TrimSpace(c.Unit) != "" && strings.TrimSpace(c.Evidence) != ""
}
func reason(c Checkpoint) string {
if c.Status != StatusMeasured {
return string(c.Status)
}
return "measured without unit or evidence"
}
func (g *Gate) sorted() []Checkpoint {
out := append([]Checkpoint(nil), g.checkpoints...)
sort.Slice(out, func(i, j int) bool {
ri, rj := areaRank(out[i].Area), areaRank(out[j].Area)
if ri != rj {
return ri < rj
}
return out[i].ID < out[j].ID
})
return out
}
// Verdict is the launch decision. Ready is true only when every required
// checkpoint is satisfied and every cross-region or multi-region checkpoint
// has been measured. The second rule is the "no globalisation without
// measurement" guard.
type Verdict struct {
Ready bool
Blockers []string
}
// Evaluate applies the two blocking rules and returns deterministic output.
func (g *Gate) Evaluate() Verdict {
var blockers []string
for _, c := range g.sorted() {
if !c.Required || satisfied(c) {
continue
}
blockers = append(blockers, fmt.Sprintf("%s (%s): %s", c.ID, c.Area, reason(c)))
}
for _, c := range g.sorted() {
if c.Area != AreaCrossRegion && c.Area != AreaMultiRegion {
continue
}
if c.Required && !satisfied(c) {
continue // already reported by the required rule
}
if !satisfied(c) {
blockers = append(blockers, fmt.Sprintf("%s (%s): global launch requires measurement", c.ID, c.Area))
}
}
return Verdict{Ready: len(blockers) == 0, Blockers: blockers}
}
// Gaps returns every unsatisfied checkpoint, including optional ones, in the
// same order as Evaluate. Optional gaps are reported but do not block.
func (g *Gate) Gaps() []Checkpoint {
var out []Checkpoint
for _, c := range g.sorted() {
if !satisfied(c) {
out = append(out, c)
}
}
return out
}
// Coverage is the measured/total count for one area.
type Coverage struct {
Total int
Measured int
}
// Coverage reports progress per area. It is a planning view, not a gate.
func (g *Gate) Coverage() map[Area]Coverage {
out := make(map[Area]Coverage)
for _, c := range g.checkpoints {
cov := out[c.Area]
cov.Total++
if satisfied(c) {
cov.Measured++
}
out[c.Area] = cov
}
return out
}
几个实现取舍值得说明:
satisfied不只看状态,还要求单位和证据。这样“标成 measured 但没跑过”的检查点无法蒙混过关。- 跨区与多地域的检查点即使标记为可选,也会阻塞
Evaluate。这是把“不要在没有测量的情况下做全球化”写成代码,而不是写成口号。 Gaps包含可选缺口,Evaluate只阻塞必需项和跨区项。两个视图分开,评审时既能看全貌,也能看硬门槛。- 输出按区域和 ID 排序,保证同一份输入每次得到同样的阻塞顺序,方便对比两次评审的差异。
进入卷三的交接清单
交接不是把文档打包发过去,而是让对方能复现你的结论。进入卷三前至少要有:
- 一份冻结日期的基线报告,包含命令、退出码、报告路径和环境说明;
- 失败实验的记录,特别是那些“以为能行、结果不行”的方案;
- 每个检查点的负责人,以及下一次复测的时间;
- 回滚方案:扩容失败、跨区切换失败、成本超预算时分别退到哪里;
- 未决问题清单,明确哪些问题卷三回答、哪些问题继续挂起。
清单里最容易被省略的是第二项。失败的实验比成功的实验更有信息量:它告诉你边界在哪里,而成功的实验只告诉你某条路能走通。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 用估算代替测量 | 上线后容量或延迟不达标 | 检查点必须有单位和证据 |
| 跨区假设未验证 | 战斗同步整体重做 | 跨区检查点一律阻塞 |
| 成本只算单价 | 账单远超预算 | 用真实账单分摊 |
| 数据驻留未确认 | 某区域无法合规上线 | 法务确认 + 部署验证 |
| 没有回滚方案 | 扩容失败后无法退回 | 每个检查点附回滚动作 |
| 缺口被隐藏 | 评审会上才发现没准备 | Gaps 输出全部未满足项 |
常见错误
第一,把“做过压测”当成“有基线”。压测环境、数据规模、并发模型不同,结果不能直接复用;基线必须写清目标拓扑。
第二,只测平均值。容量和延迟的决策看 p99 和超时率,平均值会掩盖长尾。
第三,把跨区当成网络配置问题。跨区影响的是状态归属、写入顺序和故障切换,不只是加一条专线。
第四,成本只在上线后看。成本检查点应该在选型阶段就存在,否则架构已经锁定,只能被动优化。
第五,缺口报告只列必需项。可选项长期不测,会在下一轮变成必需项;Gaps 应该展示全貌。
小结
卷二收口的三张表各有分工:13.1 的契约矩阵回答产品与平台怎么合作,13.2 的所有权总表回答数据归谁,本节的就绪检查回答“什么时候可以进入下一阶段”。三张表都不产生新系统,它们的作用是让决定可检查、可复现、可交接。
卷三会进入容量、跨区、成本和全球化的具体设计。带着基线进去,讨论的是“怎么扩”;没有基线,讨论的只是“感觉能行”。
阅读导航:上一节:13.2 系统边界与数据所有权总表 · 下一节:目录与阅读路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。