《游戏服务端架构·核心系统与契约》13.3 进入卷三:规模与全球化

卷二收口不是把所有问题都回答完,而是把没回答的问题标出来。本节整理容量、跨区、成本、多地域四类未决项,给出进入卷三前必须有基线的检查点、measured 与 estimated 的判定规则,并附一个拒绝在无测量情况下放行全球化的可运行 Go 包。

卷二收口,但问题没有收完

卷二用十二章回答了“核心系统怎么划分、契约怎么定、故障怎么兜”。这些问题在三款产品当前的体量下都有可执行的答案。但架构书最容易骗人的地方,是把“当前够用”写成“以后也行”。

北辰的下一阶段很明确:《裂隙战场》要开更多赛区,《群岛纪元》要进更多国家和地区,《泡泡工坊》要压单位成本。三件事都指向同一组问题:容量、跨区、成本、多地域部署。卷二没有回答它们,也不应该假装回答。

本节不引入新系统,只做交接:把未决问题列清楚,把进入卷三前必须拿到的测量列成检查点,并明确一条规则——没有测量,不做全球化。

四个未决问题

问题卷二已经给出的结论卷三必须回答需要的测量
容量单区拓扑、登录排队、实例池、AOI 分线都有边界单区能扛多少 CCU,扩容是加进程还是换分片压测的 CCU 曲线与 p99
跨区全球同服按逻辑分区,跨区战斗靠路由跨区延迟、丢包、故障切换是否可接受真实链路 RTT 与切换演练
成本经济与榜单有读写模型,平台有多租户隔离每 DAU、每峰值小时的基础设施成本账单分摊与资源利用率
多地域合规数据最小化、删除可传导数据驻留、写入冲突、运维半径部署验证与法务确认

四类问题不能混在一起做。容量是单区纵向问题,跨区是链路问题,成本是资源效率问题,多地域是合规与运维问题。把它们打包成“全球化项目”,通常会导致每一项都做了一半。

检查点清单:先有基线,再谈扩容

检查点不是待办清单,而是“没有这个数就不能做决定”的门槛。每一项都要有明确的指标、单位和证据来源。

ID区域必需要回答的问题指标与证据(示例值)
ccu-peakcapacity是单区峰值能承载多少 CCU压测报告,示例值 12000 CCU
tick-budgetcapacity是战斗 tick p99 与超时率压测报告,示例值 p99 18 ms
settlement-p99capacity是结算落库 p99 与重试率压测报告,示例值 p99 220 ms
login-draincapacity否开服排队多久排空演练报告,示例值 8 min
rtt-cross-regioncross-region是跨区 RTT p99真实链路探测,示例值 120 ms
failover-timecross-region是单区故障切换需要多久演练报告,示例值 45 s
cost-per-daucost是每 DAU 每日基础设施成本账单分摊,示例值 0.08 元
cost-peak-hourcost否峰值小时成本账单,示例值 620 元/小时
data-residencymulti-region是数据驻留是否满足当地要求法务确认 + 部署验证
write-conflictmulti-region否多地域写入冲突率实验结果,示例值 0.02%

表里的数字全部是示例值,不是实测。真正进入卷三前,每一行都要替换成带命令、日期和报告路径的真实结果;替换不了的行,就是还没准备好。

为什么有些项标“否”仍然重要?因为它们不阻塞当前决策,但会影响下一轮优化。登录排空时间不影响“能不能开区”,却决定开区当天要不要限流;峰值小时成本不影响“要不要出海”,却决定出海后的定价模型。

就绪判定:状态标签不算测量

一个检查点只有三种状态:

  • missing:没有数据;
  • estimated:有数字,但来自经验、同类产品、厂商报价或纸面推算;
  • measured:在目标拓扑上跑过,有单位、有证据、可复现。

只有 measured 才算满足。更严格地说,measured 还必须带单位和证据:一个写着“p99 18”但没有单位、没有报告路径的数字,仍然按未测量处理。这不是形式主义,而是防止“拍脑袋数字”在评审会上被当成事实。

判定规则有两条:

  1. 所有 required 检查点必须满足;
  2. 所有 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 排序,保证同一份输入每次得到同样的阻塞顺序,方便对比两次评审的差异。

进入卷三的交接清单

交接不是把文档打包发过去,而是让对方能复现你的结论。进入卷三前至少要有:

  1. 一份冻结日期的基线报告,包含命令、退出码、报告路径和环境说明;
  2. 失败实验的记录,特别是那些“以为能行、结果不行”的方案;
  3. 每个检查点的负责人,以及下一次复测的时间;
  4. 回滚方案:扩容失败、跨区切换失败、成本超预算时分别退到哪里;
  5. 未决问题清单,明确哪些问题卷三回答、哪些问题继续挂起。

清单里最容易被省略的是第二项。失败的实验比成功的实验更有信息量:它告诉你边界在哪里,而成功的实验只告诉你某条路能走通。

失败模式与兜底

失败表现兜底
用估算代替测量上线后容量或延迟不达标检查点必须有单位和证据
跨区假设未验证战斗同步整体重做跨区检查点一律阻塞
成本只算单价账单远超预算用真实账单分摊
数据驻留未确认某区域无法合规上线法务确认 + 部署验证
没有回滚方案扩容失败后无法退回每个检查点附回滚动作
缺口被隐藏评审会上才发现没准备Gaps 输出全部未满足项

常见错误

第一,把“做过压测”当成“有基线”。压测环境、数据规模、并发模型不同,结果不能直接复用;基线必须写清目标拓扑。

第二,只测平均值。容量和延迟的决策看 p99 和超时率,平均值会掩盖长尾。

第三,把跨区当成网络配置问题。跨区影响的是状态归属、写入顺序和故障切换,不只是加一条专线。

第四,成本只在上线后看。成本检查点应该在选型阶段就存在,否则架构已经锁定,只能被动优化。

第五,缺口报告只列必需项。可选项长期不测,会在下一轮变成必需项;Gaps 应该展示全貌。

小结

卷二收口的三张表各有分工:13.1 的契约矩阵回答产品与平台怎么合作,13.2 的所有权总表回答数据归谁,本节的就绪检查回答“什么时候可以进入下一阶段”。三张表都不产生新系统,它们的作用是让决定可检查、可复现、可交接。

卷三会进入容量、跨区、成本和全球化的具体设计。带着基线进去,讨论的是“怎么扩”;没有基线,讨论的只是“感觉能行”。

阅读导航:上一节:13.2 系统边界与数据所有权总表 · 下一节:目录与阅读路径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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