《移动游戏产品的架构·网络、资源与热更新》3.1 连接状态机:从连接到就绪

《潮汐方舟》一场战斗 8–12 分钟,5 秒一次心跳,断线率 1.4%,重连后仍有 31% 的会话需要服务端补状态。本节把连接拆成空闲、建连、握手、同步、就绪、排空、失败与关闭八个阶段,用事件表、阶段预算与拒绝理由写成可测试的状态机,说明为什么布尔量无法表达重连中的真实处境。

场景:一场战斗里,连接不止“开”和“关”

《潮汐方舟》的一场战斗是 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 在业务代码里补洞。

当前阶段事件目标阶段说明
idlesocket-openconnecting只有第一次建连或显式重置后允许
connectingauth-okhandshaking传输层建立,开始验证身份
handshakingsession-oksyncing服务端接受登录态
syncingstate-syncedready快照与输入基线对齐
readydraindraining主动排空
readycloseclosed直接关闭,仅用于不再需要连接
connecting / handshaking / syncingfailfailed任何活动阶段都可失败
connecting / handshaking / syncingtimeoutfailed仅当阶段预算耗尽
drainingclose / fail / timeoutclosed排空结束即关闭
failedretryconnecting由退避策略触发
failedcloseclosed放弃重连
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 指数退避与抖动:重连节奏怎么定 。

常见错误与边界

  1. 用 isConnected 一个布尔量同时表示传输与业务可用,导致同步阶段就放开输入。
  2. 把 idle 和 failed 合并,失败回调直接重连,退避策略被绕过。
  3. 在 connecting 收到业务消息时“先缓存着”,却不校验最终是否进入 ready,缓存永久滞留。
  4. 超时只看定时器回调,不检查事件时间是否真的到达阶段期限,时钟回拨时误判。
  5. draining 阶段仍接收新业务,排空永远结束不了。
  6. 关闭连接时直接 close,不等在途提交完成,结算奖励丢失。
  7. 迁移表之外的组合用默认分支“尽力继续”,把协议错误变成随机行为。
  8. 轨迹只记录成功迁移,不记录拒绝理由,排查时看不到调用方顺序错误。
  9. 就绪后仍保留上一次失败的错误文本,日志把旧错误当成当前故障。
  10. 把阶段预算写死在客户端,服务端超时更短时客户端永远等不到自己的超时。
  11. 重连成功后不重置轨迹和错误,后续问题被历史记录淹没。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查