《游戏服务端架构·平台化与架构治理》13.3 结语:架构是取舍,不是清单

全书结语:没有最好的架构,只有在给定约束下可解释的取舍。文章拆解把清单当架构、把平台当目的、把规模当唯一目标三类常见错误,给出取舍记录的结构、硬约束与软约束的区分和决策复盘方法,并说明第一次架构评审、第一张容量表和第一条 SLO 怎么落地。

没有最好的架构,只有可解释的取舍

全书讲了四卷:从品类与拓扑,到核心系统与契约,到规模、全球化与成本,最后到平台与治理。每一卷都给出了一套判断方法,但没有任何一卷给出“标准答案”。

这不是留白,而是结论本身。游戏服务端架构没有全局最优解:同样的分区方案,在《裂隙战场》的赛事峰值下可能合理,在《群岛纪元》的长线世界服里可能昂贵;同样的平台契约,在三个产品需求接近时收益很高,在玩法差异巨大时可能拖慢上线。

所以架构师的核心工作不是从清单里勾选项,而是在给定约束下做出可解释的取舍,并在约束变化时重新检查。 可解释意味着三件事:说清放弃了什么、为什么可以放弃、什么条件下要改回来。

三类常见错误

把清单当架构

清单给的是动作,不是理由。“服务要拆分”“要有 SLO”“要上平台”“要做多区域”,每一条都能在某种约束下成立,也都能在另一种约束下造成浪费。

把清单当架构的典型症状是:评审时只能回答“业界都这么做”,不能回答“我们的约束是什么、这条动作解决了哪个具体风险、代价由谁承担”。一旦出现两个都正确的清单项互相冲突,团队就无法裁决。

纠正方法很朴素:每条清单项后面补三个字段——约束、代价、失效条件。补不出来的,先不要做。

把平台当目的

平台是机制,不是目标。平台化的理由是降低重复实现、统一风险控制、缩短接入时间;如果平台建成后接入时间没有下降,重复实现没有减少,那它只是换了一个地方产生协调成本。

把平台当目的的典型症状是:平台团队用功能数量、复用率、接入产品数证明价值,却说不清产品因此少做了什么、上线因此快了多少。更糟的是,平台为了“通用”不断加配置,最后每个产品都在写自己的配置分支。

纠正方法:平台的价值指标必须从产品侧度量,例如接入耗时、变更失败率、共享组件的故障影响面。平台内部指标只能用于诊断,不能用于证明价值。

把规模当唯一目标

规模是约束之一,不是唯一约束。为了扛更大峰值而增加冗余,会推高固定成本;为了全球同服而统一区域,会带来合规与延迟问题;为了弹性而拆分服务,会增加契约和运维复杂度。

把规模当唯一目标的典型症状是:容量表只算峰值、不算成本;架构评审只问“能不能扛住”,不问“低谷期谁来付钱”;所有优化都指向吞吐,没有一条指向单位经济。

纠正方法:容量决策必须同时给出峰值能力、常态成本和失效边界。三者缺一,容量表就只是压力测试报告,不是架构决策记录。

取舍记录长什么样

一条可复查的取舍记录,至少包含五部分:背景、约束、备选、选择、复盘触发。

部分要写什么不写会怎样
背景当时面对的具体问题与时间压力后人无法判断前提是否还成立
约束硬约束与软约束,分别是什么把偏好当成不可谈判的条件
备选至少两个方案,每个都写代价决策看起来像唯一解
选择选了什么,接受了哪条软约束的违反取舍变成口号
复盘触发什么事件发生时必须重看决策永远不会被复查

约束要分硬软。硬约束是违反就会出局的边界,例如合规要求、帧预算、预算上限;软约束是可以违反、但必须明说代价的偏好,例如上线时间、运维 simplicity、供应商锁定。

违反软约束不是错误,它就是取舍本身。 一份记录如果显示“所有约束都满足”,通常说明约束没写全,或者记录在事后美化。真正有价值的记录会明确写出:我们选择了单区域首发,代价是放弃区域级故障切换,触发复查的条件是第二个区域上线。

可运行片段

下面的 tradeoff.go 把取舍记录做成可校验结构。Validate 检查引用完整性和硬约束,Tradeoffs 列出被接受的软约束违反,Revisit 按触发条件找决策,CheckReviews 检查复盘记录。

// file: internal/closingtradeoff/tradeoff.go
// tier: L1
// verify: go test ./internal/closingtradeoff/
package closingtradeoff

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

// ErrRecord marks a tradeoff record that would not survive review.
var ErrRecord = errors.New("closingtradeoff: invalid record")

// Constraint is either a hard boundary or a soft preference. Hard constraints
// cannot be violated by the chosen option; violating a soft constraint is
// allowed, but it must be named as the price of the decision.
type Constraint struct {
	ID        string
	Statement string
	Hard      bool
}

// Alternative is an option considered and either chosen or rejected. Cost is
// mandatory for every alternative: an option with no downside is not an
// option, it is marketing.
type Alternative struct {
	Name     string
	Cost     string
	Violates []string
}

// Decision records one architectural choice. Context and RevisitTrigger are
// mandatory because a decision without context cannot be re-evaluated, and a
// decision without a trigger is never revisited.
type Decision struct {
	ID             string
	Title          string
	Context        string
	Chosen         string
	Alternatives   []Alternative
	Constraints    []string
	RevisitTrigger string
}

// Record is the reviewable set of constraints and decisions.
type Record struct {
	Constraints []Constraint
	Decisions   []Decision
}

// Outcome is the result of a later review.
type Outcome string

const (
	Held     Outcome = "held"
	Reversed Outcome = "reversed"
	Pending  Outcome = "pending"
)

func (o Outcome) valid() bool {
	switch o {
	case Held, Reversed, Pending:
		return true
	}
	return false
}

// Review is a retrospective entry for one decision.
type Review struct {
	DecisionID string
	Outcome    Outcome
	Reason     string
}

// Validate checks references and the hard-constraint rule. It deliberately
// allows soft violations: those are the tradeoffs the record exists to expose.
func (r Record) Validate() error {
	cons := make(map[string]Constraint, len(r.Constraints))
	for i, c := range r.Constraints {
		if c.ID == "" {
			return fmt.Errorf("%w: constraint[%d] has no id", ErrRecord, i)
		}
		if c.Statement == "" {
			return fmt.Errorf("%w: constraint %s has no statement", ErrRecord, c.ID)
		}
		if _, dup := cons[c.ID]; dup {
			return fmt.Errorf("%w: duplicate constraint id %s", ErrRecord, c.ID)
		}
		cons[c.ID] = c
	}
	seen := make(map[string]bool, len(r.Decisions))
	for i, d := range r.Decisions {
		switch {
		case d.ID == "":
			return fmt.Errorf("%w: decision[%d] has no id", ErrRecord, i)
		case d.Title == "":
			return fmt.Errorf("%w: decision %s has no title", ErrRecord, d.ID)
		case d.Context == "":
			return fmt.Errorf("%w: decision %s has no context", ErrRecord, d.ID)
		case d.Chosen == "":
			return fmt.Errorf("%w: decision %s has no chosen option", ErrRecord, d.ID)
		case d.RevisitTrigger == "":
			return fmt.Errorf("%w: decision %s has no revisit trigger", ErrRecord, d.ID)
		case len(d.Alternatives) < 2:
			return fmt.Errorf("%w: decision %s has fewer than two alternatives", ErrRecord, d.ID)
		}
		if seen[d.ID] {
			return fmt.Errorf("%w: duplicate decision id %s", ErrRecord, d.ID)
		}
		seen[d.ID] = true
		names := make(map[string]bool, len(d.Alternatives))
		for j, alt := range d.Alternatives {
			switch {
			case alt.Name == "":
				return fmt.Errorf("%w: decision %s alternative[%d] has no name", ErrRecord, d.ID, j)
			case alt.Cost == "":
				return fmt.Errorf("%w: decision %s alternative %s has no cost", ErrRecord, d.ID, alt.Name)
			case names[alt.Name]:
				return fmt.Errorf("%w: decision %s has duplicate alternative %s", ErrRecord, d.ID, alt.Name)
			}
			names[alt.Name] = true
			for _, ref := range alt.Violates {
				if _, ok := cons[ref]; !ok {
					return fmt.Errorf("%w: decision %s alternative %s violates unknown constraint %s",
						ErrRecord, d.ID, alt.Name, ref)
				}
			}
		}
		if !names[d.Chosen] {
			return fmt.Errorf("%w: decision %s chose unknown option %s", ErrRecord, d.ID, d.Chosen)
		}
		for _, ref := range d.Constraints {
			if _, ok := cons[ref]; !ok {
				return fmt.Errorf("%w: decision %s references unknown constraint %s", ErrRecord, d.ID, ref)
			}
		}
		chosen := chosenAlternative(d)
		for _, ref := range chosen.Violates {
			if cons[ref].Hard {
				return fmt.Errorf("%w: decision %s chose %s, which violates hard constraint %s",
					ErrRecord, d.ID, d.Chosen, ref)
			}
		}
	}
	return nil
}

func chosenAlternative(d Decision) Alternative {
	for _, alt := range d.Alternatives {
		if alt.Name == d.Chosen {
			return alt
		}
	}
	return Alternative{}
}

// Tradeoffs returns the soft constraints the chosen option accepts. These are
// the lines a reviewer should argue about; everything else is bookkeeping.
func (r Record) Tradeoffs() []string {
	cons := make(map[string]Constraint, len(r.Constraints))
	for _, c := range r.Constraints {
		cons[c.ID] = c
	}
	var out []string
	for _, d := range r.Decisions {
		for _, ref := range chosenAlternative(d).Violates {
			c, ok := cons[ref]
			if !ok || c.Hard {
				continue
			}
			out = append(out, fmt.Sprintf("decision %s chose %s, accepts %s: %s",
				d.ID, d.Chosen, c.ID, c.Statement))
		}
	}
	sort.Strings(out)
	return out
}

// Revisit lists decisions whose trigger matches, in id order. Triggers are
// plain strings so they can be produced by incidents, roadmap reviews or a
// scheduled capacity check without coupling this package to a calendar.
func (r Record) Revisit(trigger string) []string {
	var out []string
	for _, d := range r.Decisions {
		if d.RevisitTrigger == trigger {
			out = append(out, d.ID)
		}
	}
	sort.Strings(out)
	return out
}

// CheckReviews validates retrospective entries: unknown decisions, duplicate
// reviews, unknown outcomes, and reversals with no stated reason.
func (r Record) CheckReviews(reviews []Review) []string {
	known := make(map[string]bool, len(r.Decisions))
	for _, d := range r.Decisions {
		known[d.ID] = true
	}
	seen := make(map[string]bool, len(reviews))
	var out []string
	for _, rv := range reviews {
		switch {
		case !known[rv.DecisionID]:
			out = append(out, fmt.Sprintf("review for unknown decision %s", rv.DecisionID))
		case seen[rv.DecisionID]:
			out = append(out, fmt.Sprintf("decision %s reviewed twice", rv.DecisionID))
		case !rv.Outcome.valid():
			out = append(out, fmt.Sprintf("decision %s has unknown outcome %q", rv.DecisionID, rv.Outcome))
		case rv.Outcome == Reversed && rv.Reason == "":
			out = append(out, fmt.Sprintf("decision %s reversed without reason", rv.DecisionID))
		}
		seen[rv.DecisionID] = true
	}
	sort.Strings(out)
	return out
}

这个包不能替你判断取舍好坏。它能做的是防止三种记录错误:选了不存在的方案、方案没有代价、选择违反了硬约束。三种错误都会让记录在复盘时失去价值。

读代码:硬约束与软约束必须分开

Validate 里最关键的一段是:遍历被选方案的 Violates,如果引用的是硬约束,直接报错;软约束则放过。这个分支把“取舍”与“违规”区分开了。

没有这条区分,团队会把所有约束都写成硬约束,于是任何方案都不可行;或者把所有约束都写成软约束,于是合规和帧预算也可以“权衡”。两种极端都会让记录失去约束力。

Alternative.Cost 是必填的。一个没有代价的备选方案,通常意味着记录者没有认真考虑它,或者它只是一个用来陪衬的稻草人。强制写代价,会逼出真实的比较。

Tradeoffs 只报告被选方案违反的软约束,不报告被否方案。原因是被否方案的代价已经通过“没选它”体现了,复盘时真正要盯的是“我们主动接受了什么”。

Revisit 用普通字符串匹配触发条件,不绑定日历。触发条件可以来自事故、路线图评审、容量检查或合规变更;把它写成日期,反而会在日期到了但条件没变时产生无效复盘。

CheckReviews 检查四类问题:复盘了不存在的决策、同一决策复盘两次、结果枚举非法、推翻了决策却没写原因。最后一条最重要:推翻不是问题,不解释才是。

第一次架构评审怎么开

如果你只从全书带走一个动作,建议是这个:在自己的项目里组织一次架构评审。不要等架构完美,评审的对象就是当前的不完美。

会前准备三样材料:

  1. 一张拓扑图,标出状态归属和故障域,不追求好看;
  2. 一张决策清单,列出最近三个月影响最大的五个决策,每个写约束、代价、验证方式;
  3. 一份风险清单,列出“如果明天出故障,最可能从哪里开始”。

会议议程控制在九十分钟:

时段内容产出
0–15 分钟讲约束:产品阶段、预算、合规、团队规模确认哪些是硬约束
15–45 分钟过五个决策,重点问代价与验证标出无代价、无验证的决策
45–70 分钟过风险清单,选一个做故障推演一条可执行的改进项
70–90 分钟定负责人、复查日期、下一次评审范围一页行动清单

评审还要允许“什么都不改”。如果五个决策的约束仍然成立、代价可接受、验证有效,结论就可以是维持现状。架构评审不是必须产出变更的会议。

第一张容量表

容量表不需要一开始就很精细。最小可用版本只有五列:

列示例值说明
指标同时在线房间数选一个能代表负载的指标
当前峰值12,000来自监控,不是估算
单实例容量800注明测试条件与版本
安全水位70%留出故障切换与突发余量
扩容触发峰值持续 10 分钟超过 8,400明确时间窗口与动作

五列填完,就能回答三个问题:现在够不够、什么时候要扩、扩多少。后续再补成本列、区域列、降级预案,不要一开始就追求完整。

第一条 SLO

第一条 SLO 也不要追求覆盖所有服务。选一个用户能感知、团队能控制、数据能采集的指标,先跑一个季度。

一个可用的起点包含五项(下列数值均为示例值):

  • SLI:例如“匹配请求在 2 秒内返回成功的比例”;
  • 目标:例如 99.5%,先不要定 99.99%;
  • 窗口:例如 28 天滚动窗口;
  • 数据源:哪个指标、哪张表、谁负责采集;
  • 后果:预算耗尽时,哪类发布必须暂停,谁有权批准例外。

决策什么时候该复盘

复盘不是定期把旧文档读一遍。它由触发条件启动,触发条件在记录决策时就该写好。常见触发有三类:

触发类型例子复盘重点
约束变化预算削减、合规新规、帧预算调整原决策的前提是否还成立
规模变化峰值翻倍、区域增加、产品合并容量与拓扑假设是否还成立
事故暴露同一类故障重复出现原决策的失效边界是否被低估

复盘只问三个问题:当时的约束是什么、现在的约束变了吗、如果重做会选什么。不要问“当时谁拍的板”。前三个问题产生新记录,后一个问题产生防御性写作。

复盘结论有三种:维持、调整、推翻。三种都要写理由。维持需要说明约束未变;调整需要说明改了哪一部分;推翻需要说明新方案接受了什么新代价。没有理由的推翻,只是换了一个偏好。

给不同角色的最后一页

角色最该带走的一件事第一个动作
架构师决策要写清代价与失效条件给最近五个决策补“代价”字段
平台团队平台价值从产品侧度量统计一次真实接入耗时
SRESLO 必须有后果把错误预算接进一次发布门禁
产品负责人规模不是唯一目标在容量表里补一列常态成本

失败路径

把结语当口号。 读完觉得“有道理”,回到项目继续按清单执行。兜底是选一个具体决策,当场补上约束、代价、失效条件。

记录写成辩护材料。 只写成功、不写代价,复盘时无法学习。兜底是强制每个方案写代价,包括被选中的方案。

硬约束通胀。 把所有偏好都写成硬约束,导致没有方案可选。兜底是评审时逐条确认:违反它真的会出局吗?

复盘变成追责。 一旦开始追究个人,记录就会失真。兜底是复盘对象固定为决策与约束,输出固定为维持、调整、推翻三种。

全书收口

四卷的方法可以压成一句话:先写约束,再列备选,再选一个并写下代价,最后约定什么时候重看。 拓扑、契约、容量、平台、治理,都是这句话在不同层面的展开。

没有最好的架构。只有在给定约束下,能被解释、被验证、被复查的取舍。约束会变,所以架构也应当变;但只要取舍记录还在,变化就不是漂移,而是有依据的演进。

阅读导航:上一节:13.2 架构成熟度模型 · 下一节:目录与阅读路径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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