交接不是把目录复制给下一卷
卷二启动会只开了四十分钟,其中三十分钟在处理卷一的遗留问题:跨区匹配的容量模型没压测,连接层重连风暴只写了方向,竞技房间的观战回放还没确定是否复用对局时间轴。三个问题都没有 owner,也没有判据,只有一句“卷一再展开”。
结果是卷二第一周就在补卷一的课。更糟的是,每个人对“卷一已经完成”的理解不同:有人以为未决项已经默认接受,有人以为只是暂时搁置。交接如果没有显式状态,沉默就会被解读成同意。
这一节把交接做成一份可判定的检查清单:每一项有状态、有 owner、有来源、有阻塞位;报告只回答一个问题——卷一能不能签字关闭。它不阻止你开始写卷二,但阻止你把未决项当成已完成。
卷一回答了什么,没回答什么
| 卷一说清楚的 | 卷一留给卷二的 |
|---|---|
| 在给定品类、规模和预算下选哪种拓扑 | 连接层在弱网、重连、顶号下怎么落地 |
| 每类状态的权威副本和持久化方式 | 权威边界按状态域怎么切,预测与回滚阈值怎么定 |
| tick、世界时钟与离线推进的时间模型 | 竞技、SLG、休闲三条场景链路的完整实现 |
| 部署单元、故障域与爆炸半径 | 容量模型、压测方案与成本上限 |
| ADR、评审和风险台账的记录方式 | 平台化、多产品复用与架构治理(卷四) |
卷一不写网络库、AOI、战斗结算和技能系统的实现细节,这些在《MMO 服务器开发》四卷里有完整展开。卷二的边界不同:它不重复“怎么实现一个房间”,而是回答“这套拓扑在连接、权威、竞技、SLG、休闲场景里怎么装进可运行的边界”。
检查清单:三种状态,一个阻塞位
每一项只允许三种状态,外加一个独立的阻塞位:
| 状态 | 含义 | 对交接的影响 |
|---|---|---|
done | 有结论、有证据、有链接 | 不阻塞,不再重复讨论 |
open | 还没有结论 | 阻塞位为真时阻止签字;为假时可带 owner 和截止时间进入卷二 |
waived | 明确决定不做或延后 | 必须写理由和批准人;单独列出,不能混进 done |
open 和 waived 的区别是交接里最容易被抹掉的。把“延后”写成“已完成”,下一卷会以为问题已经解决;把“已有结论但没证据”写成 open,又会重复劳动。状态必须和证据绑定,而不是和会议纪要绑定。
阻塞位也不是越多越好。只有“不做会导致卷二返工或线上事故”的项才标阻塞;把所有项都标成阻塞,交接永远签不了字。判断标准可以写成一句话:这项如果留到卷二,会不会改变卷二的接口或数据模型? 会,就阻塞;只是补充细节,就不阻塞。
进入卷二前的五个问题
卷二的目录不需要在这里重写,但五个问题必须从卷一的未决项里长出来,而不是凭空另起炉灶。
| # | 卷二要回答的问题 | 卷一来源 | 为什么现在必须回答 |
|---|---|---|---|
| 1 | 连接层如何在弱网与重连风暴下保持会话 | 5.1 协议选型、5.3 会话与重连 | 连接模型决定网关、房间和重连令牌的边界 |
| 2 | 权威边界如何按状态域切开,预测与回滚阈值怎么定 | 6.1 客户端预测与服务端裁决 | 权威模型直接决定作弊面、手感和回拉策略 |
| 3 | 竞技房间的 tick、输入窗口与观战回放如何共用一条时间轴 | 7.1 房间 tick、7.3 观战与回放 | 三者共享房间时间轴,分开做会互相打架 |
| 4 | 全球结算如何做到幂等、可补偿、可审计 | 8.1 分片、8.3 异步结算 | 结算错误是道具和资金级事故,不能留到卷三 |
| 5 | 轻量后端在什么阈值下拆分,拆分后如何保持发布速度 | 9.3 小游戏轻量后端 | 拆分阈值决定团队结构和运维成本 |
这五个问题对应卷二的五个章节群:连接与接入、权威与信任、竞技场景、SLG 场景、休闲场景。它们不是卷一的“未完成作业”,而是卷一主动划出的边界:卷一证明了拓扑选择,卷二要把选择装进协议、状态机和发布流程。
代码:检查清单与交接报告
internal/handoff 把上面的规则写成结构:Checklist 是输入,Build 先做结构校验,再生成报告。fixtures.go 放卷一的真实交接项,handoff.go 是核心实现。
// file: internal/handoff/handoff.go
// tier: L1
// verify: go test ./internal/handoff/
package handoff
import (
"errors"
"fmt"
"sort"
"strings"
)
var (
ErrEmptyChecklist = errors.New("handoff: checklist needs a volume and at least one item")
ErrMissingField = errors.New("handoff: item needs id, topic, source and owner")
ErrDuplicateID = errors.New("handoff: duplicate item id")
ErrBadState = errors.New("handoff: state must be done, open or waived")
ErrBadSource = errors.New("handoff: source must look like chapter.section")
ErrWaiverRequired = errors.New("handoff: waived item needs a reason and an approver")
)
type State string
const (
Done State = "done"
Open State = "open"
Waived State = "waived"
)
// Item is one volume-one promise or unknown that volume two must consume. A
// blocking open item stops the handoff; a waiver is an explicit, owned choice.
type Item struct {
ID string
Topic string
Source string
Owner string
State State
Blocking bool
Waiver string
Approver string
}
type Checklist struct {
Volume string
Items []Item
}
type Finding struct {
ID string
Kind string
Message string
}
type Report struct {
Ready bool
Blocking []Finding
Open []Finding
Waived []Finding
Questions []string
}
func validSource(s string) bool {
parts := strings.Split(s, ".")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return false
}
for _, p := range parts {
for _, r := range p {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
// Build validates the checklist, then produces the handoff report. Structural
// errors are returned as errors; unresolved risk is reported, not hidden.
func Build(c Checklist) (Report, error) {
if strings.TrimSpace(c.Volume) == "" || len(c.Items) == 0 {
return Report{}, ErrEmptyChecklist
}
seen := map[string]bool{}
items := append([]Item(nil), c.Items...)
for _, it := range items {
if strings.TrimSpace(it.ID) == "" || strings.TrimSpace(it.Topic) == "" ||
strings.TrimSpace(it.Source) == "" || strings.TrimSpace(it.Owner) == "" {
return Report{}, fmt.Errorf("%w: %q", ErrMissingField, it.ID)
}
if !validSource(it.Source) {
return Report{}, fmt.Errorf("%w: %s=%q", ErrBadSource, it.ID, it.Source)
}
if seen[it.ID] {
return Report{}, fmt.Errorf("%w: %s", ErrDuplicateID, it.ID)
}
seen[it.ID] = true
switch it.State {
case Done, Open, Waived:
default:
return Report{}, fmt.Errorf("%w: %s=%q", ErrBadState, it.ID, it.State)
}
if it.State == Waived &&
(strings.TrimSpace(it.Waiver) == "" || strings.TrimSpace(it.Approver) == "") {
return Report{}, fmt.Errorf("%w: %s", ErrWaiverRequired, it.ID)
}
}
sort.SliceStable(items, func(i, j int) bool { return items[i].ID < items[j].ID })
report := Report{Ready: true}
questions := map[string]bool{}
for _, it := range items {
finding := Finding{
ID: it.ID,
Kind: string(it.State),
Message: fmt.Sprintf("%s (from %s, owner %s)",
it.Topic, it.Source, it.Owner),
}
switch it.State {
case Done:
continue
case Waived:
report.Waived = append(report.Waived, finding)
case Open:
questions[it.Topic] = true
if it.Blocking {
report.Blocking = append(report.Blocking, finding)
} else {
report.Open = append(report.Open, finding)
}
}
}
for q := range questions {
report.Questions = append(report.Questions, q)
}
sort.Strings(report.Questions)
report.Ready = len(report.Blocking) == 0
return report, nil
}
// CanEnter is the one-question form used by the release gate.
func CanEnter(c Checklist) (bool, error) {
report, err := Build(c)
if err != nil {
return false, err
}
return report.Ready, nil
}
几个设计点值得说明:
- 结构错误和未决风险分开。 缺 owner、来源格式不对、重复 ID、
waived没有批准人,这些是输入错误,直接返回 error;未决项本身不报错,而是进入报告。否则一个正常的open会让整个交接无法生成。 - 三态加一个阻塞位,而不是四种状态。
open + blocking和open + nonblocking的差别在阻塞位,不在状态里;这样状态机保持简单,阻塞策略可以单独调整。 waived必须带理由和批准人。 豁免不是删除,而是一条可见的决定。报告把waived单独列出来,评审时能追问“谁批准、为什么”。- 问题从
open项生成。 报告里的Questions只来自未决项,done和waived不会混进去;同一问题被多个项引用时去重,避免卷二目录重复。 - 输出排序稳定。 项按 ID 排序,问题按字典序排序,同一份清单每次生成同样的报告。
报告长什么样
按 fixtures.go 的输入运行 ExampleBuild,本机实际输出:
ready=false blocking=3 open=2 waived=1 questions=5
H-01
H-02
H-03
ready=false 的意思是卷一还不能签字关闭:H-01 连接会话、H-02 权威边界、H-03 竞技时间轴三项仍是阻塞的未决项。H-04 全球结算和 H-05 轻量后端拆分不阻塞交接,可以带 owner 进入卷二。H-07 跨赛区匹配容量模型被显式豁免,理由和批准人都在清单里,所以它出现在 waived,不出现在 blocking。
这个结果不阻止卷二开工。它阻止的是把“还没结论”说成“已经完成”。卷二的第 1、2、3 章可以直接从 H-01 到 H-03 开始,因为这三项本来就是它们的第一批输入。
本机验证:
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/handoff
$ go vet ./internal/handoff/
$ go test -count=1 ./internal/handoff/
ok gsarchlab/internal/handoff 0.595s
internal/handoff 有 5 个测试函数、1 个示例、27 个运行项(含子测试),覆盖就绪判定、结构错误、问题来源、排序和入口判断。gofmt -l 与 go vet 均无输出,退出码为 0。
什么可以带条件放行
不是所有未决项都必须在一卷内关闭。可以带条件进入卷二的有三类:
第一,不改变接口或数据模型的补充细节。例如某个监控指标的阈值还没定,但它不影响协议和表结构,可以带着 owner 进入卷二,在对应章节落地时补。
第二,依赖外部输入的假设。例如跨区容量模型依赖发行地区的实际人口,卷一拿不到数据,只能先写假设和验证计划。这类项要标 open + nonblocking,并在卷二对应章节设置验证点。
第三,明确延后且有批准人的项。例如 H-07 的跨赛区匹配容量模型,卷一明确覆盖赛区内匹配,跨赛区留给卷三。它必须带理由和批准人,出现在 waived 列表,而不是从清单里消失。
不能带条件放行的是:会改变接口契约的、会改变状态归属的、会改变故障域边界的。这三类问题如果留到卷二,卷二会在错误的边界上写代码,返工成本远高于现在补一页 ADR。
失败模式与边界
| 失败 | 表现 | 兜底 |
|---|---|---|
把 waived 当 done | 未决项从视野消失,卷二重复踩坑 | 报告单独列 waived,要求理由和批准人 |
| 所有项都标阻塞 | 交接永远签不了字,团队绕过流程 | 阻塞只给“会改变接口或数据模型”的项 |
| 没有 owner | 问题没人推进,评审时互相观望 | 结构校验直接拒绝空 owner |
| 问题与来源脱节 | 卷二不知道从哪一节读起 | source 必须是 chapter.section 格式 |
| 报告只生成一次 | 交接后失联,状态过期 | 每次评审重跑 Build,把报告纳入版本库 |
| 用会议纪要代替清单 | 结论散落在聊天记录里 | 清单是唯一事实来源,纪要只做补充 |
还有一个边界:检查清单不评价架构好坏。它只回答“未决项是否被显式处理”。架构好坏由 ADR、评审和风险台账负责,清单负责的是交接的完整性。把两者混在一起,会让清单变成第二套评审,最后没人维护。
常见错误与边界
第一,把交接写成总结。总结回顾“我们做了什么”,交接回答“下一卷从哪里开始、什么还没定”。前者可以很漂亮,后者必须很具体。
第二,用“已完成”掩盖 waived。豁免是合法决定,但必须可见;把它混进完成项,等于把风险藏起来。
第三,所有项都标阻塞。阻塞位是稀缺资源,标得太多就失去意义;判断标准是“是否改变接口、状态归属或故障域”。
第四,只列问题,不列来源。卷二读到问题时不知道对应卷一的哪一节,只能重新翻全书。
第五,没有 owner。没有具体的人,未决项就不会有截止时间;owner 是清单里最便宜、也最容易被省略的字段。
第六,报告只生成一次。交接不是一次性文档,每次评审、每个阻塞项关闭后都应该重跑,否则报告很快变成历史文件。
小结
卷一的收口不是再讲一遍拓扑,而是把“选了什么、为什么、还欠什么”变成一份可判定的交接清单。三张基线卡固定当前承诺,决策矩阵解释选型逻辑,检查清单把未决项分成可关闭、可带条件和必须现在解决三类。卷二从这里接过去,回答连接、权威、竞技、SLG 和休闲场景怎么把系统装进边界。
回到本卷目录,可以按三条阅读路径重读,也可以带着自己的产品直接从基线卡和矩阵开始。
阅读导航:上一节:13.2 品类与拓扑决策矩阵 · 下一节:目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。