一张时序图解释不了的断线重连
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_room | client_id, mode | room_id, ticket | invalid_config | 否 |
| room_ready | room_id | ready_at | 无 | 是 |
| start_match | room_id | match_id | not_enough_players | 否 |
| disconnect | session_id | grace_until | session_expired | 否 |
| reconnect | room_id, session_id, generation | state_snapshot | token_invalid | 是,键为三者组合 |
| match_end | match_id | result_id | 无 | 是 |
| settle_done | match_id | settled_at | 无 | 是 |
| cancel_room | room_id | cancelled_at | not_owner | 否 |
幂等列不是装饰。重连请求会因为客户端重试、网关重发和玩家手速而重复到达;没有幂等键,房间服可能为同一会话创建两份快照。
三件套怎么同源
三件套的同源靠一个约定:状态迁移的 trigger、契约表的 operation、时序图的箭头,使用同一个字符串。
契约表 operation ──> 状态机 trigger ──> 时序图 step
│ │
└──── 错误码覆盖 ────┘
这条约定带来四个可直接执行的检查:
- 每个非终态至少有一条出边,且出边的 trigger 在契约表中有定义。
- 契约表里的每个 operation 至少被一条迁移使用,避免“写了但没人调用”的死契约。
- 每个契约错误码必须能在该 operation 的迁移中出现;反过来,迁移里出现的错误码也必须在契约表中声明。
- 声明为幂等的契约必须有非空幂等键。
这四条都不需要读自然语言,直接读表即可。
房间创建与断线重连:三件套示例
时序图只保留跨边界箭头,房间服内部写库不画:
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 架构决策的记录、评审与复盘 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。