《游戏服务端架构·品类、状态与拓扑》12.2 时序图、状态机与契约表

《裂隙战场》的一次断线重连在时序图上只有三条箭头,线上却出现房间已满、票据过期、重复重连三个异常。本节把时序图限制在跨边界交互,用状态机覆盖异常路径,用契约表固定请求、响应、错误与幂等键,并给出三者同源的 Go 校验:不可达状态、未覆盖错误和缺失幂等键都会被测试拒绝。

一张时序图解释不了的断线重连

2026 年 7 月,《裂隙战场》上线第三个赛季。客服收到一类投诉:玩家在选人阶段断线,重连后有时回到房间,有时被提示“房间不存在”,偶尔还会看到两份结算邮件。

值班同学翻出设计文档里的时序图。图上只有三条箭头:客户端连网关、网关转发房间服、房间服返回状态。没有断线,没有重连,没有房间满员,也没有票据过期。

问题不在图画得少,而在于三类信息被混在了一张图里:谁和谁交互、系统处于什么状态、每个请求的契约是什么。本节把它们拆成三件套,并让三者用同一个操作名对齐。

时序图只画跨边界的交互

时序图最容易犯的错,是把函数调用也画进去。判断标准很简单:这条箭头是否跨进程、跨网络或跨信任边界。如果是,画;如果只是同一个进程里的方法调用,不画。

第二条规则是必须画失败路径。只画成功路径的时序图,会让读者以为异常不存在。断线、超时、房间满员、票据过期,至少各有一条返回箭头。

第三条规则是参与者名称必须和 12.1 的部署/容器视图一致。图上写 room-svc,部署视图写 room,读者就要多做一次映射。

状态机必须覆盖异常路径

房间的生命周期用状态机表达。正常路径只是其中一部分:

idle --create_room--> creating --room_ready--> waiting
waiting --start_match--> playing --match_end--> settling --settle_done--> closed
playing --disconnect--> reconnecting --reconnect--> playing
waiting --cancel_room--> closed

状态机要回答三个问题:

问题检查方法
有没有走不到的状态从初始状态做可达性遍历
有没有走不出去的状态非终态必须有出边
异常后能否回到主流程为每个错误码找到对应迁移

reconnecting 是最容易被漏掉的状态。很多设计只写“断线后重连”,没有定义重连失败、票据过期和重复重连分别停在哪个状态。

契约表:请求、响应、错误、幂等

契约表按操作列出边界条件。它与状态机的连接点是操作名。

操作请求响应错误幂等
create_roomclient_id, moderoom_id, ticketinvalid_config否
room_readyroom_idready_at无是
start_matchroom_idmatch_idnot_enough_players否
disconnectsession_idgrace_untilsession_expired否
reconnectroom_id, session_id, generationstate_snapshottoken_invalid是,键为三者组合
match_endmatch_idresult_id无是
settle_donematch_idsettled_at无是
cancel_roomroom_idcancelled_atnot_owner否

幂等列不是装饰。重连请求会因为客户端重试、网关重发和玩家手速而重复到达;没有幂等键,房间服可能为同一会话创建两份快照。

三件套怎么同源

三件套的同源靠一个约定:状态迁移的 trigger、契约表的 operation、时序图的箭头,使用同一个字符串。

契约表 operation ──> 状态机 trigger ──> 时序图 step
        │                    │
        └──── 错误码覆盖 ────┘

这条约定带来四个可直接执行的检查:

  1. 每个非终态至少有一条出边,且出边的 trigger 在契约表中有定义。
  2. 契约表里的每个 operation 至少被一条迁移使用,避免“写了但没人调用”的死契约。
  3. 每个契约错误码必须能在该 operation 的迁移中出现;反过来,迁移里出现的错误码也必须在契约表中声明。
  4. 声明为幂等的契约必须有非空幂等键。

这四条都不需要读自然语言,直接读表即可。

房间创建与断线重连:三件套示例

时序图只保留跨边界箭头,房间服内部写库不画:

client          gateway              room
  |--create_room-->|                  |
  |                |--create_room---->|
  |                |<--room_ready-----|
  |<--room_ready---|                  |
  |                |                  |
  |   (网络断开)    |                  |
  |--reconnect---->|--reconnect------>|
  |                |<--state_snapshot-|
  |<--state_snapshot|                 |

对应的状态迁移与契约已经在上面两张表里。三者的 operation 都是 create_room、room_ready、reconnect,没有别名,也没有“差不多”的同义词。

代码:状态机与契约校验

// file: internal/contractspec/contractspec.go
// tier: L1
// verify: go test ./internal/contractspec/
package contractspec

import (
	"fmt"
	"strings"
)

// Contract describes one operation that crosses a process boundary.
type Contract struct {
	Operation      string
	Request        []string
	Response       []string
	Errors         []string
	Idempotent     bool
	IdempotencyKey string
}

// Step is one arrow in a sequence diagram.
type Step struct {
	From      string
	To        string
	Operation string
}

// Transition is one edge in the room state machine.
type Transition struct {
	From    string
	Trigger string
	To      string
	Errors  []string
}

// Machine is the lifecycle shared by documents, code, and tests.
type Machine struct {
	Initial     string
	States      []string
	Terminal    []string
	Transitions []Transition
}

// ValidateMachine checks structure only. Reachability is a separate check
// because an invalid machine should fail before graph traversal.
func ValidateMachine(m Machine) []error {
	var errs []error
	if len(m.States) == 0 {
		return []error{fmt.Errorf("machine has no states")}
	}
	states := make(map[string]bool, len(m.States))
	for _, s := range m.States {
		if strings.TrimSpace(s) == "" {
			errs = append(errs, fmt.Errorf("machine has an empty state"))
			continue
		}
		if states[s] {
			errs = append(errs, fmt.Errorf("state %q is declared twice", s))
		}
		states[s] = true
	}
	if !states[m.Initial] {
		errs = append(errs, fmt.Errorf("initial state %q is not declared", m.Initial))
	}
	terminal := make(map[string]bool, len(m.Terminal))
	for _, s := range m.Terminal {
		if !states[s] {
			errs = append(errs, fmt.Errorf("terminal state %q is not declared", s))
		}
		terminal[s] = true
	}
	outgoing := make(map[string]bool, len(m.Transitions))
	for _, t := range m.Transitions {
		if !states[t.From] || !states[t.To] {
			errs = append(errs, fmt.Errorf("transition %q->%q uses an unknown state", t.From, t.To))
		}
		if strings.TrimSpace(t.Trigger) == "" {
			errs = append(errs, fmt.Errorf("transition %q->%q has no trigger", t.From, t.To))
		}
		if terminal[t.From] {
			errs = append(errs, fmt.Errorf("terminal state %q has an outgoing transition", t.From))
		}
		outgoing[t.From] = true
	}
	for _, s := range m.States {
		if !terminal[s] && !outgoing[s] {
			errs = append(errs, fmt.Errorf("non-terminal state %q has no outgoing transition", s))
		}
	}
	return errs
}

// CheckReachability returns every state that cannot be entered from Initial.
func CheckReachability(m Machine) []error {
	seen := map[string]bool{m.Initial: true}
	queue := []string{m.Initial}
	for len(queue) > 0 {
		cur := queue[0]
		queue = queue[1:]
		for _, t := range m.Transitions {
			if t.From == cur && !seen[t.To] {
				seen[t.To] = true
				queue = append(queue, t.To)
			}
		}
	}
	var errs []error
	for _, s := range m.States {
		if !seen[s] {
			errs = append(errs, fmt.Errorf("state %q is unreachable from %q", s, m.Initial))
		}
	}
	return errs
}

// CheckCoverage enforces a two-way match between the machine and the table:
// every trigger has a contract, every contract is used, and every declared
// error is reachable through a transition of that operation.
func CheckCoverage(m Machine, contracts []Contract) []error {
	var errs []error
	byOp := make(map[string]Contract, len(contracts))
	for _, c := range contracts {
		if _, ok := byOp[c.Operation]; ok {
			errs = append(errs, fmt.Errorf("contract %q: duplicate operation", c.Operation))
		}
		byOp[c.Operation] = c
		if strings.TrimSpace(c.Operation) == "" {
			errs = append(errs, fmt.Errorf("contract with empty operation"))
		}
		if len(c.Request) == 0 || len(c.Response) == 0 {
			errs = append(errs, fmt.Errorf("contract %q: request or response is empty", c.Operation))
		}
		if c.Idempotent && strings.TrimSpace(c.IdempotencyKey) == "" {
			errs = append(errs, fmt.Errorf("contract %q: idempotent without key", c.Operation))
		}
	}
	used := make(map[string]bool, len(m.Transitions))
	for _, t := range m.Transitions {
		used[t.Trigger] = true
		c, ok := byOp[t.Trigger]
		if !ok {
			errs = append(errs, fmt.Errorf("transition %s->%s: no contract for %q", t.From, t.To, t.Trigger))
			continue
		}
		declared := make(map[string]bool, len(c.Errors))
		for _, e := range c.Errors {
			declared[e] = true
		}
		for _, e := range t.Errors {
			if !declared[e] {
				errs = append(errs, fmt.Errorf("transition %s->%s: error %q not in contract %q", t.From, t.To, e, t.Trigger))
			}
		}
	}
	for _, c := range contracts {
		if !used[c.Operation] {
			errs = append(errs, fmt.Errorf("contract %q: no transition uses it", c.Operation))
			continue
		}
		reached := make(map[string]bool)
		for _, t := range m.Transitions {
			if t.Trigger == c.Operation {
				for _, e := range t.Errors {
					reached[e] = true
				}
			}
		}
		for _, e := range c.Errors {
			if !reached[e] {
				errs = append(errs, fmt.Errorf("contract %q: error %q never reached", c.Operation, e))
			}
		}
	}
	return errs
}

// CheckSequence verifies that every arrow uses an operation from the table.
// Ordering belongs to the diagram; existence belongs to the contract table.
func CheckSequence(steps []Step, contracts []Contract) []error {
	var errs []error
	known := make(map[string]bool, len(contracts))
	for _, c := range contracts {
		known[c.Operation] = true
	}
	for i, s := range steps {
		if strings.TrimSpace(s.From) == "" || strings.TrimSpace(s.To) == "" {
			errs = append(errs, fmt.Errorf("step %d: empty actor", i+1))
		}
		if !known[s.Operation] {
			errs = append(errs, fmt.Errorf("step %d: operation %q not in contract table", i+1, s.Operation))
		}
	}
	return errs
}

ValidateMachine 只做结构检查,CheckReachability 单独做图遍历,CheckCoverage 负责状态机与契约表的双向覆盖,CheckSequence 保证时序图里的每条箭头都能在契约表中找到定义。拆成四个函数,是为了让失败信息指向具体的一层。

测试:不可达状态、未覆盖错误、缺幂等键

表驱动测试分成四组:七组结构用例、三组可达性用例、六组覆盖用例和三组时序用例。关键用例包括:

用例期望
非终态没有出边报错
状态从初始状态不可达报错
迁移 trigger 没有契约报错
契约 operation 没有迁移报错
契约错误码从未出现在迁移中报错
幂等契约没有幂等键报错
时序图使用未知 operation报错

本机只跑本包:

$ cd .local/checks/gsarch-lab
$ go test -count=1 ./internal/contractspec/
ok  	gsarchlab/internal/contractspec	0.485s

同时跑了 gofmt -l internal/contractspec(无输出)和 go vet ./internal/contractspec/(退出码 0)。

常见错误与边界

第一,时序图只画成功路径。失败路径才是重连、超时和补偿逻辑的来源。

第二,状态机只画主流程。reconnecting、settling 这类中间态不画,异常时就没有明确归属。

第三,契约表只写请求和响应,不写错误码。客户端无法区分“重试有用”和“重试有害”。

第四,幂等只写在文档里,没有键。实现者只能靠请求 ID 猜,重复请求仍然可能生效。

第五,三张表各用一套命名。createRoom、create_room、room.create 同时存在,同源检查立刻失效。

第六,把内部方法调用画进时序图。图会变得很长,但跨边界的契约反而被淹没。

小结

时序图负责“谁和谁在什么时候交互”,状态机负责“系统允许停在哪些状态”,契约表负责“一次交互的输入、输出、错误和幂等”。三者用同一个 operation 字符串对齐,就能从表生成检查、从状态机生成异常测试、从契约表生成校验。

internal/contractspec 把结构、可达性、覆盖和时序检查拆开,让“图画了但代码没实现”变成构建失败。下一节讨论决策本身:一份 ADR 被采纳之后,谁负责复审,什么时候失效,失效后怎么替代。

阅读导航:上一节:12.1 C4、部署视图与数据流 · 下一节:12.3 架构决策的记录、评审与复盘 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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