《游戏服务端架构·规模化、全球化与成本》13.2 全球化架构决策矩阵

全球化不是一道是非题,而是同一组选项在不同产品权重下的排序。本文给出延迟、合规、成本、运维复杂度、发布速度五个维度的评分锚点,说明三款产品的权重差异与硬约束,演示矩阵在新项目立项、区域扩张、成本压缩三种场景下的用法,并用 Go 实现加权评分、硬约束过滤与确定性结论生成。

同一张矩阵,三种读法

第 12 章的三款产品给出了三个不同答案:《裂隙战场》按赛区部署,房间留在玩家附近;《群岛纪元》全球同服但按区域分片,跨区走异步结算;《泡泡工坊》只用一个托管区域,靠 CDN 和就近入口解决大部分问题。

如果只看结论,会以为这是三个品味问题。放到同一张矩阵上,它们是同一组选项在不同权重下的排序结果。矩阵的价值不是替人做决定,而是把“为什么选它”变成可复现的输入:维度、权重、分数、硬约束、结论。

这一节不讨论某个具体地域怎么部署,那些在第 3 章和第 12 章。这里讨论的是决策方法本身,以及它在三种常见场景下怎么用。

五个维度,统一方向

评分最怕方向不一致:延迟 5 分是最好,成本 5 分却成了最贵。矩阵统一约定:所有维度都是 5 分最好。因此成本维度叫“成本友好度”,运维维度叫“运维简单度”。

维度回答的问题5 分锚点1 分锚点证据
延迟目标玩家能否在预算内到达80% 玩家 p99 ≤ 80ms跨洋,p99 > 250ms路由探测、压测
合规数据能否合法落地与删除全区域本地驻留,删除链可演练关键区域无合法路径法务意见、演练
成本单位经济是否可接受单位成本低于预算 30%超预算 2 倍账单、容量模型
运维复杂度团队能否长期值守单区域、单套发布多区域手工切换on-call 记录
发布速度变更能否快速且安全上线每日可发,回滚 <10 分钟每周窗口,回滚靠人发布记录

锚点必须写进评分表。没有锚点,评分会退化成“我觉得这个方案更稳”,不同人打出的 4 分含义完全不同。

延迟维度的主体是玩家,不是机房。一个方案在法兰克福到阿姆斯特丹之间 p99 只有 12ms,但如果 60% 玩家在东南亚,它的延迟分数不应该高。

合规维度的主体是数据类,不是公司注册地。账号数据、支付数据、遥测数据的跨境规则可能不同,一个方案对账号合规、对遥测不合规,应该按最严的那类打分,并在备注里写清。

成本维度比较的是单位成本,不是月账单。一个月花 20 万美元但服务 100 万 MAU,可能比一个月花 8 万美元但只服务 20 万 MAU 更便宜。

运维复杂度要算长期值守,不是上线当天的工程量。多区域双活的上线可能只多两周,但它会永久增加值班面、故障定位路径和发布协调成本。

发布速度要算“安全变更”的速度。一个每天都能发但回滚要手工切 DNS 的方案,不应该拿满分。

权重是产品策略,不是平均值

权重不是把五个维度平均分。它表达的是:这个产品愿意在哪个维度上多付代价。

产品/场景延迟合规成本运维发布
《裂隙战场》0.350.100.150.150.25
《群岛纪元》0.150.300.250.200.10
《泡泡工坊》0.100.150.300.200.25
新项目立项0.250.200.200.200.15
区域扩张0.250.300.200.150.10
成本压缩0.200.150.350.150.15

《裂隙战场》把 35% 给延迟,因为 5v5 对局的技能命中和走位反馈直接受 RTT 影响;发布速度给 25%,因为赛季平衡补丁需要快速上线。合规只给 10%,不是不重要,而是它的底线由硬约束保证,不需要在权重里重复计价。

《群岛纪元》把 30% 给合规,因为全球同服的数据驻留是最难改的约束;成本 25%,因为大地图长期占用内存和存储;发布只给 10%,因为 SLG 的版本节奏本来就慢。

《泡泡工坊》把 30% 给成本、25% 给发布速度。它的竞争力来自买量转化和快速试错,架构越轻越好;延迟只给 10%,因为休闲玩法对 100ms 级别的差异不敏感。

权重之和必须是 1。缺失的维度不能当成 0,因为 0 权重和“没填”在语义上完全不同:前者是“明确不关心”,后者是“还没想清楚”。

硬约束:不能妥协的分数

权重可以折中,硬约束不能。硬约束的含义是:低于这条线,选项不是“差一点”,而是“不可选”。

三款产品各有一条:

产品硬约束原因谁有权改
《裂隙战场》延迟 ≥4低于 4 分的方案 p99 超过对局预算技术负责人 + 产品
《群岛纪元》合规 ≥4关键区域无合法数据路径法务 + 平台
《泡泡工坊》成本 ≥4单位成本击穿买量回收模型产品 + 财务

硬约束和“高权重”是两件事。把合规权重调到 0.5,仍然可能被一个合规 2 分、其他维度满分的方案用加权总分压过去;硬约束则在加权之前就把它剔除。

硬约束也不能太多。五条维度全设成 ≥4,通常会得到“没有可选方案”的结果。这时候正确的动作是改架构或改范围,而不是悄悄把门槛降到 3。降门槛必须留下书面例外,写清谁批准、有效期多久、补偿措施是什么。

硬约束要有证据和所有者。没有证据的硬约束只是偏好;没有所有者的硬约束没人敢改,最后会变成永久教条。

三款产品在同一张矩阵上的得分

下面是四个候选方案。分数是评审示例值,不是实测值;真实评分要用本节后面的方法重新打。

方案延迟合规成本运维发布
A 单地域托管23555
B 多地域双活54223
C 区域集群 + 异步跨区45334
D 全球单服32434

按产品权重计算:

产品ABCD硬约束淘汰结论
《裂隙战场》3.753.503.803.30A、D(延迟 <4)C
《群岛纪元》3.953.153.853.05A、D(合规 <4)C
《泡泡工坊》4.402.853.653.40B、C(成本 <4)A

三个结论值得注意。

第一,《群岛纪元》的 A 方案加权分 3.95,其实高于 C 的 3.85,但 A 的合规只有 3,被硬约束淘汰。如果没有硬约束,矩阵会推荐一个不合规的方案;这正是硬约束存在的意义。

第二,《裂隙战场》的 B 方案延迟满分,但成本、运维、发布三项太低,加权后输给 C。多地域双活不是“更全球”,它对竞技产品意味着更高的发布协调成本和更复杂的故障定位。

第三,《泡泡工坊》的 A 方案赢得不惊险:4.40 对 3.40。它的产品阶段不需要为全球低延迟付双活的钱,单地域托管加 CDN 已经够用。

这些分数会随阶段变化。《泡泡工坊》如果进入东南亚买量期,延迟权重可能从 0.10 升到 0.20,A 的领先会缩小;如果监管要求账号本地化,合规硬约束会直接把 A 淘汰。矩阵要跟着阶段重算,而不是一次定终身。

矩阵怎么用:三种场景

新项目立项

用 new-project 预设:五个维度都保留权重,暂时不设硬约束,除了法律明确要求的区域合规。

第一步,列出 3–5 个候选,不要只列“做”和“不做”。单地域、区域集群、全球单服、托管服务,都可以进矩阵。

第二步,先定评分锚点,再打分。锚点可以粗,但必须一致;不同方案用不同锚点,分数没有可比性。

第三步,看排序,也看分差。分差小于 0.2 时,不要假装矩阵给出了明确结论;这时候应该补证据,或者承认两个方案等价,用组织能力做 tie-breaker。

第四步,记录权重、分数、证据和日期。新项目两个月后就会变,没有记录就无法解释“为什么当时选了这个”。

区域扩张

用 region-expansion 预设:在原有产品权重上,把合规权重提到 0.30,并加两条硬约束——合规 ≥4、延迟 ≥3。

区域扩张不是重新选一遍全球架构,而是在现有架构上增加一个区域。矩阵要回答的是:新区域用现有拓扑的延伸,还是独立一套?

这时要把“现有架构改造成本”作为一个候选方案纳入评分,而不是只评绿地方案。改造成本包括数据迁移、契约兼容、值班培训,它们会拉低运维和发布分数。

区域扩张还必须单独评目标区域,不能拿全球平均分代替。一个方案在欧洲合规 5 分,在东南亚可能只有 2 分;按最严区域打分,或者按区域分别建矩阵。

成本压缩

用 cost-squeeze 预设:成本权重 0.35,硬约束是成本 ≥3、合规 ≥3。

成本压缩最容易出现的错误,是把合规和容灾当成可削减项。矩阵的硬约束在这里起护栏作用:合规低于 3 的方案直接淘汰,不管它多便宜。

第二容易出现的错误,是只看单价不看单位经济。把专用节点换成按量实例,单价可能更低,但冷启动和运维复杂度会把发布速度拉下来;矩阵会把这项代价显式化。

成本压缩的结论通常不是“换一个全新架构”,而是“在现有架构上关掉低价值能力”:降低非核心日志保留、合并低负载区域、把离线计算移到spot。矩阵帮你看清每次削减在哪一维上付代价。

数据模型与接口契约

代码把矩阵拆成四个概念:

  • Dimension:五个固定维度,方向统一为“5 分最好”;
  • Weights:每个维度一个权重,必须齐全且和为 1;
  • Option:候选方案,每个维度一个 0–5 分;
  • Constraint:硬约束,低于 Min 直接淘汰。

关键契约有三条。第一,权重缺失返回错误,不默认 0;第二,选项缺维度返回错误,不默认 3;第三,评分范围固定 0–5,超出范围直接拒绝。三条都是为了防“悄悄补一个中间值”。

可运行片段

下面的 matrix.go 实现加权评分、硬约束过滤和确定性排序。同包的 presets.go 提供三款产品和三种场景的权重预设。

// file: internal/globalmatrix/matrix.go
// tier: L1
// verify: go test ./internal/globalmatrix/
package globalmatrix

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

var (
	ErrMatrix   = errors.New("globalmatrix: invalid matrix")
	ErrNoOption = errors.New("globalmatrix: no option satisfies hard constraints")
)

// Dimension is scored 0..5, always in the same direction: 5 is better. Cost is
// therefore "cheapness", and ops is "simplicity", not raw spend or headcount.
type Dimension string

const (
	Latency    Dimension = "latency"
	Compliance Dimension = "compliance"
	Cost       Dimension = "cost"
	Ops        Dimension = "ops-complexity"
	Release    Dimension = "release-speed"
)

var Dimensions = []Dimension{Latency, Compliance, Cost, Ops, Release}

type Weights map[Dimension]float64

func (w Weights) Validate() error {
	total := 0.0
	for _, d := range Dimensions {
		v, ok := w[d]
		if !ok {
			return fmt.Errorf("%w: missing weight for %s", ErrMatrix, d)
		}
		if v < 0 || v > 1 {
			return fmt.Errorf("%w: weight for %s is %g, want [0,1]", ErrMatrix, d, v)
		}
		total += v
	}
	if total < 0.999 || total > 1.001 {
		return fmt.Errorf("%w: weights sum to %g, want 1", ErrMatrix, total)
	}
	return nil
}

type Option struct {
	Name   string
	Scores map[Dimension]float64
}

func (o Option) Validate() error {
	if o.Name == "" {
		return fmt.Errorf("%w: option needs a name", ErrMatrix)
	}
	for _, d := range Dimensions {
		v, ok := o.Scores[d]
		if !ok {
			return fmt.Errorf("%w: option %s has no score for %s", ErrMatrix, o.Name, d)
		}
		if v < 0 || v > 5 {
			return fmt.Errorf("%w: option %s score for %s is %g, want [0,5]", ErrMatrix, o.Name, d, v)
		}
	}
	return nil
}

// Constraint removes an option before weighting. It exists because some
// dimensions are not tradeable: a region without a lawful data path is not a
// slightly worse region, it is not an option.
type Constraint struct {
	Dimension Dimension
	Min       float64
}

func (c Constraint) Validate() error {
	valid := false
	for _, d := range Dimensions {
		if c.Dimension == d {
			valid = true
			break
		}
	}
	if !valid {
		return fmt.Errorf("%w: unknown constraint dimension %q", ErrMatrix, c.Dimension)
	}
	if c.Min < 0 || c.Min > 5 {
		return fmt.Errorf("%w: constraint %s min is %g, want [0,5]", ErrMatrix, c.Dimension, c.Min)
	}
	return nil
}

type Result struct {
	Name   string
	Score  float64
	Pass   bool
	Failed []Dimension
}

// Evaluate returns every option, sorted with passing options first, then by
// weighted score, then by name so the output is stable across runs.
func Evaluate(w Weights, cs []Constraint, opts []Option) ([]Result, error) {
	if err := w.Validate(); err != nil {
		return nil, err
	}
	for _, c := range cs {
		if err := c.Validate(); err != nil {
			return nil, err
		}
	}
	if len(opts) == 0 {
		return nil, fmt.Errorf("%w: no options", ErrMatrix)
	}
	results := make([]Result, 0, len(opts))
	for _, o := range opts {
		if err := o.Validate(); err != nil {
			return nil, err
		}
		r := Result{Name: o.Name, Pass: true}
		for _, c := range cs {
			if o.Scores[c.Dimension] < c.Min {
				r.Pass = false
				r.Failed = append(r.Failed, c.Dimension)
			}
		}
		for _, d := range Dimensions {
			r.Score += w[d] * o.Scores[d]
		}
		results = append(results, r)
	}
	sort.SliceStable(results, func(i, j int) bool {
		if results[i].Pass != results[j].Pass {
			return results[i].Pass
		}
		if results[i].Score != results[j].Score {
			return results[i].Score > results[j].Score
		}
		return results[i].Name < results[j].Name
	})
	return results, nil
}

// Choose returns the best passing option. A tied score is broken by name in
// Evaluate, so the caller never has to invent a second tie-breaker.
func Choose(w Weights, cs []Constraint, opts []Option) (Result, error) {
	results, err := Evaluate(w, cs, opts)
	if err != nil {
		return Result{}, err
	}
	if !results[0].Pass {
		return Result{}, ErrNoOption
	}
	return results[0], nil
}

Evaluate 返回所有选项,而不只是赢家。这个设计是有意的:评审时需要看到落选方案差在哪一维,而不是只看到一个名字。

读代码:排序与淘汰的细节

第一,排序先看 Pass,再看 Score。一个被硬约束淘汰的方案,即使加权分最高,也排在所有通过方案之后。这保证了“高分但不合规”不会出现在推荐位。

第二,同分按名称排序。矩阵经常出现两个方案同分的情况,尤其是新项目阶段;用名称做确定性 tie-breaker,至少保证同样的输入每次得到同样的输出。真正的 tie-breaker 应该由人补,但不能让程序随机。

第三,Failed 记录被哪条硬约束淘汰,而不是只返回布尔值。评审时最有用的信息往往是“它差在合规,不在成本”。

第四,Choose 在没有方案通过时返回 ErrNoOption,而不是返回分数最高的失败方案。后者会让调用方误以为还有选择,实际上需要改架构或改范围。

第五,权重允许 0,但必须显式写 0。这和缺失不同:显式 0 表示“这一维不参与本次决策”,缺失表示“矩阵不完整”。

失败模式与兜底

评分锚点不一致。 同一个 4 分,有人理解为“行业平均”,有人理解为“接近最好”。兜底是把锚点写进表,评审前先对齐。

看完结果再调权重。 这是最常见的作弊。兜底是权重和硬约束在打分前冻结,变更要记录理由和批准人。

硬约束过多导致无解。 五维全设高门槛,结果没有方案通过。兜底是接受 ErrNoOption,回到架构或范围讨论,不静默降门槛。

合规分由工程单独打。 工程师可以评估技术可行性,但不能替法务判断合法性。兜底是合规分需要法务签字。

运维分只看上线工程量。 忽略长期值班、故障定位和发布协调。兜底是用过去 6 个月的 on-call 记录做证据。

成本分混用月账单和单位成本。 规模不同的方案不可比。兜底是统一换算到单位成本,再映射到 0–5。

用全球平均分掩盖区域差异。 一个方案在欧洲 5 分、东南亚 2 分,平均 3.5 分看起来还行。兜底是按区域分别评分,取最差区域进入矩阵。

把软偏好写成硬约束。 “我们希望运维简单”不是硬约束,“团队没有 24 小时值班能力”才是。兜底是每条硬约束都要有证据和所有者。

常见错误与边界

第一,把矩阵当成客观真理。分数是评审输入,不是测量结果;它的价值在于让分歧可见。

第二,维度太多。超过七个维度,评审会失去焦点;五个维度覆盖了全球化决策的主要矛盾。

第三,权重平均分配。平均权重等于没有策略,最后总是选到“哪一维都不突出”的方案。

第四,忽略阶段变化。新项目、扩张期、成熟期的权重不同,矩阵要重算。

第五,只评绿地方案。改造现有架构往往才是真实选项,必须纳入。

第六,用硬约束淘汰所有创新方案。硬约束应该少而硬,通常一到两条。

第七,不记录落选原因。半年后复盘时,只会记得“当时选了 C”,不记得为什么。

小结

全球化架构决策矩阵把“选哪个”拆成五个统一方向的维度、一组显式权重、若干条硬约束和一次确定性排序。三款产品在同一张矩阵上得到不同结论,不是因为品味不同,而是因为权重和硬约束不同。

矩阵的输出不是答案,而是一份可复现的决策记录:谁在什么阶段、用什么证据、把哪一维看得很重、哪条线不能碰。下一节收口全卷,把这些决策和三张表交给平台团队,并说明哪些责任必须留在产品团队。

阅读导航:上一节:13.1 容量表、成本表与合规表 · 下一节:13.3 进入卷四:平台化与治理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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