场景:一场战斗里,连接不止“开”和“关”
《潮汐方舟》的一场战斗是 8–12 分钟。战斗内的心跳间隔是 5 秒,断线率是 1.4%,30 秒内重连成功率是 98.1%,而重连之后仍有 31% 的会话需要服务端下发增量状态。玩家在屏幕上看到的却只有一个词:重连中。
这个词底下至少藏着四种不同的处境。第一种是传输层还没建立,客户端连 TCP/TLS 都没握完;第二种是传输通了但登录态没被服务端接受;第三种是身份确认了,但战斗快照、回合计时和技能冷却还没对齐;第四种才是真正可以接收输入、渲染快照的“就绪”。把这四种处境压成一个布尔量,UI 只能猜,日志只能猜,重连策略也只能猜。
更麻烦的是,猜错的代价并不对称。在“传输已通、状态未同步”时放开输入,玩家的移动指令会基于旧快照发出,服务端要么拒绝,要么按错误位置结算;在“已经失败、等待退避”时继续显示“重连中”,玩家会以为再等两秒就好,实际客户端已经放弃。状态机的价值不是把代码写复杂,而是让每一个可观察行为都能对应到一个明确的阶段。
为什么布尔量不够:连接不是一个开关
布尔量只回答“当前有没有一条可用的连接”,但重连过程中真正需要回答的是四个不同的问题。
| 问题 | 对应阶段 | 答错的后果 |
|---|---|---|
| 传输层是否已经建立 | connecting | 过早发业务包,全部落到未认证连接 |
| 身份是否已被服务端接受 | handshaking | 用游客态或过期令牌继续,结算归属错误 |
| 本地状态是否与服务端对齐 | syncing | 旧快照上做预测,回滚时画面跳变 |
| 是否可以安全收发业务消息 | ready | 输入被丢弃或重复提交 |
因此状态机的第一件事不是画箭头,而是定义“就绪”的判据。对《潮汐方舟》来说,就绪意味着四件事同时成立:传输层可写、登录态有效、战斗快照已对齐、输入序号基线已确定。只满足前两件就叫“连上了”,但不能叫“可玩”。把这两个概念分开,重连后的 31% 补状态比例才有地方安放。
状态机还要能被观测。客户端埋点如果只上报“连接成功/失败”,就无法区分是握手慢、同步慢,还是服务端在排空阶段关闭了连接。阶段名本身就是最小的观测维度,它让“重连耗时”可以被拆成建连、握手、同步三段,而不是一个无法归因的总数。
八个阶段:每个阶段只回答一个问题
下面的阶段划分刻意把“传输”和“业务”分开。阶段预算是实验参数,不是案例数据;真正上线时应按机型、网络类型和服务端超时配置重新标定。
| 阶段 | 进入条件 | 玩家可见文案 | 阶段预算(实验参数) |
|---|---|---|---|
| idle | 初始状态或主动关闭后 | 无 | 无期限 |
| connecting | 发起 socket 连接 | 正在连接 | 5000ms |
| handshaking | 传输层已通,发送登录凭据 | 正在验证 | 3000ms |
| syncing | 登录态已被接受,拉取或对齐状态 | 正在同步 | 2000ms |
| ready | 状态对齐完成,允许业务消息 | 无(隐藏) | 无期限 |
| draining | 主动排空,停止接收新业务 | 正在结束 | 1500ms |
| failed | 任一活动阶段失败或超时 | 连接失败,可重试 | 无期限 |
| closed | 连接对象生命周期结束 | 无 | 终态 |
idle 与 failed 的区别很关键。idle 表示“还没有开始”,可以直接发起第一次连接;failed 表示“刚刚失败过”,下一次连接必须由退避策略决定时机,而不是立刻重试。把两者合并,调用方就会在失败回调里直接重连,退避形同虚设。
draining 也不是装饰。战斗结算、切换场景、收到服务端踢下线通知时,客户端需要停止接收新的业务消息,把已经在途的提交发完,再关闭连接。直接 close 会让最后的结算包丢失,玩家看到的是“打完这一局没有奖励”。
迁移表:事件、目标与拒绝理由
状态机的核心是一张显式的迁移表。表里没有的迁移一律拒绝,并返回可读理由;不要用 if/else 在业务代码里补洞。
| 当前阶段 | 事件 | 目标阶段 | 说明 |
|---|---|---|---|
| idle | socket-open | connecting | 只有第一次建连或显式重置后允许 |
| connecting | auth-ok | handshaking | 传输层建立,开始验证身份 |
| handshaking | session-ok | syncing | 服务端接受登录态 |
| syncing | state-synced | ready | 快照与输入基线对齐 |
| ready | drain | draining | 主动排空 |
| ready | close | closed | 直接关闭,仅用于不再需要连接 |
| connecting / handshaking / syncing | fail | failed | 任何活动阶段都可失败 |
| connecting / handshaking / syncing | timeout | failed | 仅当阶段预算耗尽 |
| draining | close / fail / timeout | closed | 排空结束即关闭 |
| failed | retry | connecting | 由退避策略触发 |
| failed | close | closed | 放弃重连 |
| closed | 任意事件 | 拒绝 | 终态不可复活 |
有两类拒绝必须单独建模。第一类是非法事件:例如在 idle 收到 auth-ok,说明调用方顺序错了,必须拒绝而不是“宽容地跳到下一步”。第二类是未到期超时:在 connecting 阶段预算还剩 1ms 时收到 timeout,说明定时器精度或时钟有问题,拒绝并记录理由比直接失败更有利于定位。
ready 阶段的超时是例外。就绪后没有阶段预算,但心跳可能被判定超时;这时 timeout 应该被接受并进入 failed,因为心跳判定的依据是服务端消息间隔,而不是本地阶段预算。状态机不能把“阶段超时”和“心跳超时”混成一个概念。
可运行模型:connection-fsm.ts
下面的模型把阶段、事件、预算、拒绝理由和迁移轨迹封装在一起。dispatch 返回新的状态与是否接受,不修改入参;失败事件可以携带错误细节,就绪时自动清空上一次错误。
// file: src/net/connection-fsm.ts
// tier: L1
// verify: node --test test/net/connection-fsm.test.ts
/**
* 连接生命周期状态机(mga-net-lab)。
* 阶段预算与重试口径都是实验设定,不是案例数据。
*/
export type ConnectionPhase =
| "idle"
| "connecting"
| "handshaking"
| "syncing"
| "ready"
| "draining"
| "failed"
| "closed";
export type ConnectionEvent =
| "socket-open"
| "auth-ok"
| "session-ok"
| "state-synced"
| "fail"
| "timeout"
| "drain"
| "retry"
| "close";
export interface PhaseBudget {
readonly connectMs: number;
readonly handshakeMs: number;
readonly syncMs: number;
readonly drainMs: number;
}
export const DEFAULT_BUDGET: PhaseBudget = {
connectMs: 5000,
handshakeMs: 3000,
syncMs: 2000,
drainMs: 1500,
};
export interface TraceEntry {
readonly seq: number;
readonly at: number;
readonly from: ConnectionPhase;
readonly to: ConnectionPhase;
readonly event: ConnectionEvent | "start";
}
export interface ConnectionState {
readonly phase: ConnectionPhase;
readonly seq: number;
readonly enteredAt: number;
readonly deadlineAt: number | null;
readonly lastError: string | null;
readonly trace: readonly TraceEntry[];
}
export interface DispatchResult {
readonly state: ConnectionState;
readonly accepted: boolean;
readonly reason: string;
}
const TABLE: Record<ConnectionPhase, Partial<Record<ConnectionEvent, ConnectionPhase>>> = {
idle: { "socket-open": "connecting" },
connecting: { "auth-ok": "handshaking", fail: "failed", timeout: "failed" },
handshaking: { "session-ok": "syncing", fail: "failed", timeout: "failed" },
syncing: { "state-synced": "ready", fail: "failed", timeout: "failed" },
ready: { drain: "draining", fail: "failed", timeout: "failed", close: "closed" },
draining: { close: "closed", fail: "closed", timeout: "closed" },
failed: { retry: "connecting", close: "closed" },
closed: {},
};
const LABELS: Record<ConnectionPhase, string> = {
idle: "空闲",
connecting: "建连中",
handshaking: "握手中",
syncing: "状态同步中",
ready: "就绪",
draining: "排空中",
failed: "失败待重试",
closed: "已关闭",
};
function budgetFor(phase: ConnectionPhase, budget: PhaseBudget): number | null {
switch (phase) {
case "connecting":
return budget.connectMs;
case "handshaking":
return budget.handshakeMs;
case "syncing":
return budget.syncMs;
case "draining":
return budget.drainMs;
default:
return null;
}
}
function reject(state: ConnectionState, reason: string): DispatchResult {
return { state, accepted: false, reason };
}
export function createConnection(now = 0): ConnectionState {
return {
phase: "idle",
seq: 0,
enteredAt: now,
deadlineAt: null,
lastError: null,
trace: [],
};
}
export function dispatch(
state: ConnectionState,
event: ConnectionEvent,
at: number,
detail = "",
budget: PhaseBudget = DEFAULT_BUDGET,
): DispatchResult {
if (at < state.enteredAt) {
return reject(state, "时钟回退:事件时间早于阶段进入时间");
}
const next = TABLE[state.phase][event];
if (next === undefined) {
return reject(state, `阶段 ${state.phase} 不接受事件 ${event}`);
}
if (
event === "timeout" &&
state.phase !== "ready" &&
(state.deadlineAt === null || at < state.deadlineAt)
) {
const left = state.deadlineAt === null ? 0 : state.deadlineAt - at;
return reject(state, `未到阶段期限,剩余 ${left}ms`);
}
const seq = state.seq + 1;
const budgetMs = budgetFor(next, budget);
const deadlineAt = budgetMs === null ? null : at + budgetMs;
const failed = event === "fail" || event === "timeout";
const lastError = failed
? detail.length > 0
? detail
: event
: next === "ready" || event === "retry"
? null
: state.lastError;
const trace: TraceEntry = {
seq,
at,
from: state.phase,
to: next,
event,
};
return {
state: {
phase: next,
seq,
enteredAt: at,
deadlineAt,
lastError,
trace: [...state.trace, trace],
},
accepted: true,
reason: `${state.phase} -> ${next}`,
};
}
export function isReady(state: ConnectionState): boolean {
return state.phase === "ready";
}
export function describeState(state: ConnectionState): string {
const label = LABELS[state.phase];
const budget = state.deadlineAt === null ? "无阶段期限" : `阶段期限 ${state.deadlineAt}ms`;
const error = state.lastError === null ? "" : `,最近错误:${state.lastError}`;
return `${label}(seq=${state.seq},${budget}${error})`;
}
export function formatTrace(state: ConnectionState): string {
return state.trace
.map((entry) => {
const seq = String(entry.seq).padStart(2, "0");
return `#${seq} ${entry.at}ms ${entry.from} -> ${entry.to} (${entry.event})`;
})
.join("\n");
}
模型刻意不包含定时器。定时器属于运行时,状态机只接收“现在几点”和“发生了什么”。这样做的好处是测试不需要等待真实时间:把 at 依次设为 1000、1500、2000、2500,就能在微秒内验证一条完整路径;把 at 设在期限前 1ms,就能验证超时守卫。把时间作为输入,是状态机可测试的前提。
输出怎么读:describeState 与 formatTrace
初始状态经 describeState 输出为 空闲(seq=0,无阶段期限)。完成建连与握手后,输出会变成 握手中(seq=2,阶段期限 2100ms) 这类带序号和期限的文本;失败后还会追加最近错误。它适合直接写进日志,而不是让排查者去猜数字的含义。
formatTrace 输出的是逐条迁移:
#01 100ms idle -> connecting (socket-open)
#02 200ms connecting -> handshaking (auth-ok)
轨迹的价值在于顺序。重连问题里最常见的争议是“到底是握手失败还是同步失败”,只要轨迹里出现 handshaking -> failed,责任就落在登录态;如果出现的是 syncing -> failed,才需要去看快照对齐。没有轨迹,两个阶段都只能被笼统地记成“连接失败”。
轨迹还会暴露重复事件。比如同一秒内出现两次 socket-open,说明调用方在失败回调里重复发起了连接;状态机可以拒绝第二次,但只有轨迹能让这个问题被看见。
与相邻章节的关系
2.3 的协议契约测试保证消息格式兼容,但格式正确不代表连接阶段正确;本节解决的是“什么时候可以发这条消息”。3.2 会把 failed 之后的等待节奏单独建模,退避只决定何时 retry,不改变阶段本身。3.3 处理 syncing 阶段内部的状态校准,尤其是重连后需要增量还是快照。4.1 则讨论 failed 与离线期间哪些操作可以排队、哪些必须拒绝。
进程被系统杀死后的本地恢复可以参考杀进程与异常退出的兜底 。两者都属于生命周期问题,但一个处理网络连接,一个处理本地记录,不能互相替代。
小结
- 连接至少有“传输已通、身份已认、状态已对齐、业务可用”四层,布尔量无法表达。
idle与failed必须分开,否则调用方会绕过退避立即重连。draining是结算与切场景的安全出口,直接关闭会丢在途消息。- 迁移表之外的组合一律拒绝,并返回可读理由。
- 阶段预算与心跳超时是两种超时,不能共用一条判定。
- 时间作为输入传入,状态机才能在不等待真实时间的情况下被测试。
阅读导航:上一节:2.3 协议契约测试:把兼容规则写成断言 · 下一节:3.2 指数退避与抖动:重连节奏怎么定 。
常见错误与边界
- 用
isConnected一个布尔量同时表示传输与业务可用,导致同步阶段就放开输入。 - 把
idle和failed合并,失败回调直接重连,退避策略被绕过。 - 在
connecting收到业务消息时“先缓存着”,却不校验最终是否进入ready,缓存永久滞留。 - 超时只看定时器回调,不检查事件时间是否真的到达阶段期限,时钟回拨时误判。
draining阶段仍接收新业务,排空永远结束不了。- 关闭连接时直接
close,不等在途提交完成,结算奖励丢失。 - 迁移表之外的组合用默认分支“尽力继续”,把协议错误变成随机行为。
- 轨迹只记录成功迁移,不记录拒绝理由,排查时看不到调用方顺序错误。
- 就绪后仍保留上一次失败的错误文本,日志把旧错误当成当前故障。
- 把阶段预算写死在客户端,服务端超时更短时客户端永远等不到自己的超时。
- 重连成功后不重置轨迹和错误,后续问题被历史记录淹没。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。