没有最好的架构,只有可解释的取舍
全书讲了四卷:从品类与拓扑,到核心系统与契约,到规模、全球化与成本,最后到平台与治理。每一卷都给出了一套判断方法,但没有任何一卷给出“标准答案”。
这不是留白,而是结论本身。游戏服务端架构没有全局最优解:同样的分区方案,在《裂隙战场》的赛事峰值下可能合理,在《群岛纪元》的长线世界服里可能昂贵;同样的平台契约,在三个产品需求接近时收益很高,在玩法差异巨大时可能拖慢上线。
所以架构师的核心工作不是从清单里勾选项,而是在给定约束下做出可解释的取舍,并在约束变化时重新检查。 可解释意味着三件事:说清放弃了什么、为什么可以放弃、什么条件下要改回来。
三类常见错误
把清单当架构
清单给的是动作,不是理由。“服务要拆分”“要有 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 检查四类问题:复盘了不存在的决策、同一决策复盘两次、结果枚举非法、推翻了决策却没写原因。最后一条最重要:推翻不是问题,不解释才是。
第一次架构评审怎么开
如果你只从全书带走一个动作,建议是这个:在自己的项目里组织一次架构评审。不要等架构完美,评审的对象就是当前的不完美。
会前准备三样材料:
- 一张拓扑图,标出状态归属和故障域,不追求好看;
- 一张决策清单,列出最近三个月影响最大的五个决策,每个写约束、代价、验证方式;
- 一份风险清单,列出“如果明天出故障,最可能从哪里开始”。
会议议程控制在九十分钟:
| 时段 | 内容 | 产出 |
|---|---|---|
| 0–15 分钟 | 讲约束:产品阶段、预算、合规、团队规模 | 确认哪些是硬约束 |
| 15–45 分钟 | 过五个决策,重点问代价与验证 | 标出无代价、无验证的决策 |
| 45–70 分钟 | 过风险清单,选一个做故障推演 | 一条可执行的改进项 |
| 70–90 分钟 | 定负责人、复查日期、下一次评审范围 | 一页行动清单 |
评审还要允许“什么都不改”。如果五个决策的约束仍然成立、代价可接受、验证有效,结论就可以是维持现状。架构评审不是必须产出变更的会议。
第一张容量表
容量表不需要一开始就很精细。最小可用版本只有五列:
| 列 | 示例值 | 说明 |
|---|---|---|
| 指标 | 同时在线房间数 | 选一个能代表负载的指标 |
| 当前峰值 | 12,000 | 来自监控,不是估算 |
| 单实例容量 | 800 | 注明测试条件与版本 |
| 安全水位 | 70% | 留出故障切换与突发余量 |
| 扩容触发 | 峰值持续 10 分钟超过 8,400 | 明确时间窗口与动作 |
五列填完,就能回答三个问题:现在够不够、什么时候要扩、扩多少。后续再补成本列、区域列、降级预案,不要一开始就追求完整。
第一条 SLO
第一条 SLO 也不要追求覆盖所有服务。选一个用户能感知、团队能控制、数据能采集的指标,先跑一个季度。
一个可用的起点包含五项(下列数值均为示例值):
- SLI:例如“匹配请求在 2 秒内返回成功的比例”;
- 目标:例如 99.5%,先不要定 99.99%;
- 窗口:例如 28 天滚动窗口;
- 数据源:哪个指标、哪张表、谁负责采集;
- 后果:预算耗尽时,哪类发布必须暂停,谁有权批准例外。
决策什么时候该复盘
复盘不是定期把旧文档读一遍。它由触发条件启动,触发条件在记录决策时就该写好。常见触发有三类:
| 触发类型 | 例子 | 复盘重点 |
|---|---|---|
| 约束变化 | 预算削减、合规新规、帧预算调整 | 原决策的前提是否还成立 |
| 规模变化 | 峰值翻倍、区域增加、产品合并 | 容量与拓扑假设是否还成立 |
| 事故暴露 | 同一类故障重复出现 | 原决策的失效边界是否被低估 |
复盘只问三个问题:当时的约束是什么、现在的约束变了吗、如果重做会选什么。不要问“当时谁拍的板”。前三个问题产生新记录,后一个问题产生防御性写作。
复盘结论有三种:维持、调整、推翻。三种都要写理由。维持需要说明约束未变;调整需要说明改了哪一部分;推翻需要说明新方案接受了什么新代价。没有理由的推翻,只是换了一个偏好。
给不同角色的最后一页
| 角色 | 最该带走的一件事 | 第一个动作 |
|---|---|---|
| 架构师 | 决策要写清代价与失效条件 | 给最近五个决策补“代价”字段 |
| 平台团队 | 平台价值从产品侧度量 | 统计一次真实接入耗时 |
| SRE | SLO 必须有后果 | 把错误预算接进一次发布门禁 |
| 产品负责人 | 规模不是唯一目标 | 在容量表里补一列常态成本 |
失败路径
把结语当口号。 读完觉得“有道理”,回到项目继续按清单执行。兜底是选一个具体决策,当场补上约束、代价、失效条件。
记录写成辩护材料。 只写成功、不写代价,复盘时无法学习。兜底是强制每个方案写代价,包括被选中的方案。
硬约束通胀。 把所有偏好都写成硬约束,导致没有方案可选。兜底是评审时逐条确认:违反它真的会出局吗?
复盘变成追责。 一旦开始追究个人,记录就会失真。兜底是复盘对象固定为决策与约束,输出固定为维持、调整、推翻三种。
全书收口
四卷的方法可以压成一句话:先写约束,再列备选,再选一个并写下代价,最后约定什么时候重看。 拓扑、契约、容量、平台、治理,都是这句话在不同层面的展开。
没有最好的架构。只有在给定约束下,能被解释、被验证、被复查的取舍。约束会变,所以架构也应当变;但只要取舍记录还在,变化就不是漂移,而是有依据的演进。
阅读导航:上一节:13.2 架构成熟度模型 · 下一节:目录与阅读路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。