《游戏服务端架构·品类、状态与拓扑》13.3 进入卷二:把系统装进边界

卷一的最后一节不是总结,而是交接。把基线、风险、未决决策和待压测假设做成带阻塞判定的检查清单,列出卷二必须回答的五个问题及其卷一来源,并给出可运行的 Go 交接报告生成器,说明哪些未决项可以带条件放行、哪些必须留在一卷内解决,避免把延后误写成已完成。

交接不是把目录复制给下一卷

卷二启动会只开了四十分钟,其中三十分钟在处理卷一的遗留问题:跨区匹配的容量模型没压测,连接层重连风暴只写了方向,竞技房间的观战回放还没确定是否复用对局时间轴。三个问题都没有 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 品类与拓扑决策矩阵 · 下一节:目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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