回顾不是把目录再念一遍
一本书写到最后一章,最容易犯的错误是把前面十二卷的结论重新排一遍,读起来像一份加长目录。真正的回顾只回答一个问题:今天做决策时,哪些历史判断仍然约束着我,哪些已经失效。
约束还在,就应该能说出它来自哪一卷、当时付出了什么代价、后来用什么证据确认它仍然成立。约束已经失效,就应该能说出触发失效的条件是什么,而不是含糊地说“情况变了”。
所以这一节不复述知识点,而是把四卷压成一条决策链。链条上的每一环都要有四个字段:问题、决策、代价、验证。缺一个字段,这一环就只是观点,不是架构史。
四卷的一条主线
四卷不是四个并列的主题,而是四次问题升级。每一次升级,都是上一卷的答案在新约束下不够用了。
| 卷 | 回答的问题 | 核心决策 | 主要代价 | 验证方式 |
|---|---|---|---|---|
| 卷一 品类与拓扑 | 这类游戏需要什么样的服务端形状 | 分区、房间、世界与混合拓扑的选择 | 拓扑一旦定型,迁移成本高 | 品类压力模型与拓扑对照 |
| 卷二 核心系统与契约 | 战斗、匹配、经济、社交怎么协作 | 契约边界、状态归属、一致性级别 | 契约越多,变更越慢 | 契约测试与故障注入 |
| 卷三 规模、全球化与成本 | 人多了、区多了、钱紧了怎么办 | 容量表、区域策略、成本模型 | 冗余与合规成本上升 | 容量演练、账单对账、合规清单 |
| 卷四 平台与治理 | 多产品、多团队怎么复用与协作 | 平台契约、发布治理、SLO、组织边界 | 平台建设期拖慢产品 | 接入时间、缺口报告、联合演练 |
这张表的关键不是“四卷各讲了什么”,而是最后一列。没有验证方式的决策,在回顾时无法判断它是否仍然成立;它会变成一种信仰,而不是一条可复查的记录。
四卷之间是单向依赖:卷三的容量决策依赖卷二的状态归属,卷二的契约依赖卷一的拓扑假设。反过来不成立。回顾时如果发现某条上游假设已经改了,下游所有依赖它的决策都要重新检查,而不是只改出问题的那一处。
每一卷留下了什么债
卷一:拓扑债
卷一给出的是形状,形状的代价是难改。分区方案、房间生命周期、世界服与战斗服的边界,一旦上线就会被客户端协议、运营工具和玩家预期同时锁定。
卷一留给后面的债有两条。第一,拓扑假设没有写进契约,只存在于架构图里;第二,品类扩展时,团队习惯“再加一个区”,而不是重新检查拓扑是否仍然匹配。前者让卷二不得不从代码反推边界,后者让卷三的容量模型一开始就带着错误的粒度。
交接给卷二的东西应该是一句话:哪些状态属于哪个进程,以及为什么。 不是架构图,是状态归属表。
卷二:契约债
卷二把协作写成了契约,代价是变更速度。战斗结算、匹配队列、经济账本、社交关系之间一旦有了明确契约,任何一处改字段都要评估上下游。
卷二留下的债主要是契约的“隐性部分”:错误码没有写全,超时语义靠口头约定,幂等边界只在实现里体现。这些内容在单产品阶段可以靠人记住,到卷四多产品复用时就会变成事故来源。
交接给卷三的东西是可测量的契约:哪些接口有 SLO,哪些操作必须幂等,哪些失败可以重试。没有这些,容量规划只能拍脑袋。
卷三:规模债
卷三把容量、成本、合规做成了表,代价是冗余和流程。多区域部署、跨区容灾、合规留存都会增加固定成本,而固定成本在低峰期不会自动消失。
卷三留下的债是“表的局部性”:每张表都为自己的产品服务,口径不完全一致。A 产品的“活跃用户”和 B 产品的“活跃用户”可能差一个去重窗口;两边的成本分摊也可能一个含 CDN、一个不含。
交接给卷四的东西是统一口径的清单,而不是四张互相打架的报表。卷四的平台化如果不能先统一口径,就只是在复制分歧。
卷四:治理债
卷四把复用和协作写成了平台契约、发布机制和治理规则,代价是建设期与协调成本。平台在头两个季度通常只花钱不产出,产品团队会怀疑它是否值得。
卷四留下的债是“治理的维护成本”:SLO 要持续度量,错误预算要真的影响发布,平台契约要真的废弃旧版本,成本分摊要真的能对账。任何一条只写在文档里,治理就会退化成仪式。
这一卷没有下一卷可以交接,所以它必须自己完成闭环:每条治理规则都要有负责人、有度量、有后果。 这是全书最后一章还要单独讲成熟度和取舍的原因。
三款产品在四卷中的演进
同一套方法用在三款产品上,得到的答案并不相同。回顾时要能看到差异,而不是把三款产品讲成同一个故事。
| 产品 | 卷一形状 | 卷二重点 | 卷三压力 | 卷四治理重点 |
|---|---|---|---|---|
| 《裂隙战场》 | 分区 + 战斗房间 | 帧同步契约与结算一致性 | 赛事峰值与跨区匹配 | 匹配平台化与发布列车 |
| 《群岛纪元》 | 世界服 + 场景分线 | 经济账本与社交关系 | 长线留存与全球同服 | 活动编排与成本分摊 |
| 《泡泡工坊》 | 轻量房间 + 快速匹配 | 关卡状态与掉落幂等 | 买量波峰与弹性成本 | 热更新灰度与风控运营 |
差异本身就是结论:平台不能只按一款产品抽象。匹配核心可以共享,匹配分段必须留给产品;账本机制可以共享,经济参数必须留给产品。卷四反复强调的“机制归平台、策略归产品”,在这张表里能直接看到依据。
回顾要产出三样东西
一次合格的回顾,不是开一天会,而是留下三样可复查的产物。
第一,主题索引:每个跨卷存活的判断,有唯一 ID、所属卷、问题、决策、代价、验证方式。索引不是知识点列表,它只收录“今天仍然约束决策”的内容。
第二,决策追溯:每条主题能沿着上游指回更早的假设,形成一条从卷一走到卷四的链。追溯必须是单向的:卷四的主题可以指向卷三,卷三可以指向卷二,反过来不行。方向错了,说明这条“历史”是后来编的。
第三,覆盖检查:按卷统计主题数量,和回顾目标比较。某个卷一条主题都没有,通常不是那个卷不重要,而是回顾者只记得最近做的事。
可运行片段
下面的 volumeretro.go 把主题索引做成可校验结构。Validate 检查字段完整性和追溯方向,Trace 还原一条链,Coverage 与 Gaps 做覆盖检查。
// file: internal/volumeretro/volumeretro.go
// tier: L1
// verify: go test ./internal/volumeretro/
package volumeretro
import (
"errors"
"fmt"
"sort"
)
// ErrIndex marks structural problems in a cross-volume theme index.
var ErrIndex = errors.New("volumeretro: invalid index")
// Volume identifies one book in the four-volume series.
type Volume int
const (
VolumeCategory Volume = 1 // category and topology
VolumeCore Volume = 2 // core systems and contracts
VolumeScale Volume = 3 // scale, globalization and cost
VolumePlatform Volume = 4 // platform and governance
)
// Name returns the stable label used in reports. Labels are English so that
// they can be compared in tests and pasted into tickets without encoding
// surprises; the article keeps the Chinese explanation.
func (v Volume) Name() string {
switch v {
case VolumeCategory:
return "category-and-topology"
case VolumeCore:
return "core-systems-and-contracts"
case VolumeScale:
return "scale-global-and-cost"
case VolumePlatform:
return "platform-and-governance"
}
return "unknown"
}
func (v Volume) valid() bool { return v >= VolumeCategory && v <= VolumePlatform }
// Theme is one durable idea that survives a volume boundary. Decision and Cost
// are mandatory: a theme without a cost is an advertisement, not a decision.
// Upstream points at themes in earlier volumes, which is what makes the whole
// index traceable instead of a flat list.
type Theme struct {
ID string
Title string
Volume Volume
Question string
Decision string
Cost string
Verification string
Upstream []string
}
// Index is the review artifact produced at the end of the series.
type Index struct {
Themes []Theme
}
// Validate enforces structure and the direction of history: a theme may only
// point upstream, never sideways or forward. That single rule makes cycles
// impossible for a validated index, and it prevents "traceability" that was
// invented after the fact.
func (ix Index) Validate() error {
byID := make(map[string]Theme, len(ix.Themes))
for i, t := range ix.Themes {
switch {
case t.ID == "":
return fmt.Errorf("%w: theme[%d] has no id", ErrIndex, i)
case t.Title == "":
return fmt.Errorf("%w: theme %s has no title", ErrIndex, t.ID)
case !t.Volume.valid():
return fmt.Errorf("%w: theme %s has unknown volume %d", ErrIndex, t.ID, t.Volume)
case t.Question == "":
return fmt.Errorf("%w: theme %s has no question", ErrIndex, t.ID)
case t.Decision == "":
return fmt.Errorf("%w: theme %s has no decision", ErrIndex, t.ID)
case t.Cost == "":
return fmt.Errorf("%w: theme %s has no cost", ErrIndex, t.ID)
case t.Verification == "":
return fmt.Errorf("%w: theme %s has no verification", ErrIndex, t.ID)
}
if _, dup := byID[t.ID]; dup {
return fmt.Errorf("%w: duplicate theme id %s", ErrIndex, t.ID)
}
byID[t.ID] = t
}
for _, t := range ix.Themes {
for _, up := range t.Upstream {
if up == t.ID {
return fmt.Errorf("%w: theme %s points at itself", ErrIndex, t.ID)
}
u, ok := byID[up]
if !ok {
return fmt.Errorf("%w: theme %s points at unknown %s", ErrIndex, t.ID, up)
}
if u.Volume >= t.Volume {
return fmt.Errorf("%w: theme %s (%s) points at %s (%s); upstream must be earlier",
ErrIndex, t.ID, t.Volume.Name(), up, u.Volume.Name())
}
}
}
return nil
}
// Trace returns the root-to-target chain, sorted at each node so the output is
// stable across map iteration and can be diffed in review. A shared ancestor
// appears once, not once per branch.
func (ix Index) Trace(id string) ([]string, error) {
byID := make(map[string]Theme, len(ix.Themes))
for _, t := range ix.Themes {
byID[t.ID] = t
}
done := make(map[string]bool, len(byID))
active := make(map[string]bool, len(byID))
var path []string
var walk func(string) error
walk = func(cur string) error {
if done[cur] {
return nil
}
if active[cur] {
return fmt.Errorf("%w: cycle at %s", ErrIndex, cur)
}
t, ok := byID[cur]
if !ok {
return fmt.Errorf("%w: unknown theme %s", ErrIndex, cur)
}
active[cur] = true
ups := append([]string(nil), t.Upstream...)
sort.Strings(ups)
for _, up := range ups {
if err := walk(up); err != nil {
return err
}
}
delete(active, cur)
done[cur] = true
path = append(path, cur)
return nil
}
if err := walk(id); err != nil {
return nil, err
}
return path, nil
}
// Coverage always contains all four volumes, including zeros. A missing key
// reads as "not applicable" in reports, which is exactly the wrong conclusion.
func (ix Index) Coverage() map[Volume]int {
out := make(map[Volume]int, 4)
for v := VolumeCategory; v <= VolumePlatform; v++ {
out[v] = 0
}
for _, t := range ix.Themes {
if t.Volume.valid() {
out[t.Volume]++
}
}
return out
}
// Gaps compares coverage with the review contract. Required counts live in the
// caller, not in this package, because a short review and a full retrospective
// need different depth.
func (ix Index) Gaps(required map[Volume]int) []string {
cov := ix.Coverage()
var out []string
for v := VolumeCategory; v <= VolumePlatform; v++ {
need := required[v]
if need <= 0 || cov[v] >= need {
continue
}
out = append(out, fmt.Sprintf("volume %d %s: %d theme(s), need %d", v, v.Name(), cov[v], need))
}
return out
}
三个方法的分工是刻意的。Validate 只判断“这份索引能不能被引用”,不判断主题是否重要;Trace 只还原路径,不替人做取舍;Gaps 只报告数量缺口,不评价质量。回顾的质量仍然要靠人,但结构错误可以交给代码。
读代码:方向比内容更容易出错
Validate 里最值得看的是这一条:上游主题的卷号必须小于当前主题的卷号。它把“历史只能向前追溯”写成了结构约束,因此一份通过校验的索引不可能出现环,也不可能出现“卷二的决策依赖卷四的平台能力”这种时间倒错。
这条规则会逼出两类真实问题。第一,有些主题其实是同一卷内的重复表述,硬要给它找一个上游,就会指向同卷主题,校验失败。第二,有些“平台能力”被当成了早期决策的原因,实际上是后来补的,校验也会失败。两类问题都值得在回顾会上讨论。
Trace 仍然保留了环检测。原因是它可能被用在未校验的索引上,例如评审过程中临时拼出来的草稿。只返回布尔值不够,返回路径才能让人看到是哪几个主题互相指认。
Coverage 会把四个卷都初始化为 0,再累加。这样“某一卷完全没有主题”会以 0 出现,而不是从 map 里消失。报表里消失的维度最危险,因为没人会讨论一个不存在的键。
Gaps 的必需数量由调用方传入,不写死在包里。一次季度回顾和一次全书回顾的深度不同,把目标写死会让工具只能服务一种场景。
一次回顾的检查表
把索引跑通只是起点。下面八个问题,能回答六个以上,回顾才算可用。
- 每条主题是否写清了它约束的是哪一个具体决策,而不是一个抽象领域?
- 每条主题的代价是否具体到可以比较,例如“多三周”(示例值)而不是“成本较高”?
- 每条主题的验证方式是否可复现,例如一次演练、一张对账单,而不是“大家认可”?
- 是否存在只有决策、没有代价的主题?有,就说明当时没人反对,或者反对意见没被记录。
- 是否存在指向同卷或未来卷的上游?有,就说明时间线需要重修。
- 四个卷是否都有主题?某一卷为零时,是确实没有留存判断,还是回顾者遗忘了?
- 每条卷四主题能否指回至少一条卷三主题?指不回去,平台化就缺少依据。
- 索引里是否有已经被证伪、但没有标记失效的主题?有,就要补失效条件和替代决策。
失败路径
把索引做成知识图谱。 主题越加越多,每条都有十几个关联,最后没人读。兜底是限制索引只收录“仍然约束决策”的判断,其余内容留在各卷正文。
用主题数量当成绩。 卷四主题最多,因为刚写完;卷一主题最少,因为记不清。数量只用来发现遗漏,不用来排名。
追溯链只画成功路径。 只记录被采纳的决策,不记录被否掉的备选,链条会看起来异常顺滑。真正有价值的信息往往在“当时为什么没选另一条路”。
回顾会变成追责会。 一旦开始问“这是谁拍的板”,大家就会开始修饰记录。回顾的对象是决策与约束,不是个人。
把失效当成失败。 一条决策在当初的约束下是对的,后来约束变了才失效,这不是错误。拒绝标记失效,才会让索引失去可信度。
小结
四卷的主线是问题升级:从“服务端长什么样”,到“系统之间怎么协作”,到“规模与成本怎么承受”,最后到“多产品多团队怎么复用与治理”。每一卷的答案都会变成下一卷的约束,也会留下下一卷必须处理的债。
回顾的产物不是一篇总结,而是一份能追溯、能检查覆盖、能标记失效的索引。索引通过校验只说明结构没坏;它是否真的解释了今天的决策,仍然要在评审会上被质询。
下一节把这个思路变成可操作的评估工具:不再问“我们处于第几级”,而是问“每个维度有什么证据”。
阅读导航:上一节:12.3 架构师的沟通与写作 · 下一节:13.2 架构成熟度模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。