《移动游戏产品的架构·网络、资源与热更新》3.3 重连后的状态校准

《潮汐消消》一局 90 秒,提交 14–22 次,每次 600B–1.2KB,8% 的重连需要补状态。重连后最危险的不是丢包,而是把已结算的提交再交一次。本节用纪元、序号、增量窗口与已确认集合,把无需校准、增量、快照、重置四种决策写成可测试的校准器,并给出待提交的重放与去重规则。

场景:重连成功之后,奖励被结算了两次

《潮汐消消》一局 90 秒,回合制提交,每次提交 600B–1.2KB,单局 14–22 次。断线率是 2.1%,30 秒内重连成功率是 97.2%,重连后需要补状态的比例是 8%。这 8% 看起来不大,但它对应的是最敏感的一类问题:玩家在断线前发出的提交,客户端不知道服务端有没有收到。

一种常见实现是“重连后把本地未确认的提交全部重发”。如果服务端已经处理过其中一条,而提交没有幂等键,玩家就会看到同一局奖励结算两次;如果服务端只处理了一半,客户端又可能把本地状态覆盖成更旧的版本。重连成功只解决了“通道回来了”,没有解决“两边看到的是不是同一段历史”。

状态校准要回答三个问题:本地序号与服务端序号差多少、这段差距能不能用增量补齐、本地待提交里哪些已经生效。三个问题分别对应序号、增量窗口和已确认集合,缺一个都会留下重复结算或状态回退的口子。

四种决策:noop、delta、snapshot、reset

校准不是“一律拉全量”,也不是“一律拉增量”。按本地最后确认序号与服务端当前序号的关系,只有四种合理决策。

决策触发条件客户端动作适用场景
noop本地序号等于服务端序号不拉数据,直接恢复短暂断线且没有新进展
delta缺口在增量窗口内,且纪元一致拉取缺失操作并顺序应用大多数 8% 的补状态会话
snapshot缺口超过窗口,或纪元不一致用服务端快照整体替换长时间离线、版本切换、会话迁移
reset本地序号反而超前清空本地增量,按服务端为准服务端回滚或本地记录损坏

noop 是最容易被忽略的决策。很多实现一检测到“重连”就无条件请求全量,哪怕断线只有两秒、服务端序号根本没变。全量请求不但浪费流量,还会把客户端本地已经预测出的状态覆盖掉,造成不必要的回退。

snapshot 与 reset 的区别在于信任方向。快照仍然承认“本地历史存在,只是追不上”;重置则明确“本地这段历史不可信”,必须丢弃。把两者混成一个“全量覆盖”,日志里就分不清是网络原因还是本地损坏。

增量窗口是实验参数,不是案例事实。模型默认 128 条,含义是“最多补 128 条操作,超过就换快照”。窗口太小会让短暂离线也触发全量,窗口太大则会让一次校准携带过多操作,增加失败重试的成本。上线值应按单局提交频率和可接受的全量比例标定。

纪元、序号与增量窗口

序号解决“缺多少”,纪元解决“还是不是同一个会话”。只有序号没有纪元,会遇到一个经典问题:服务端重启后序号从 0 重新计数,客户端本地序号却是 500,按差值判断会得出“客户端超前”,但如果只看差值就重置,可能丢掉本可以恢复的状态。

纪元是一个单调变化的标识,服务端每次创建新的会话空间时更新。客户端在建立连接时拿到纪元,断线重连后先比较纪元:一致才允许增量,不一致直接快照。这样服务端回滚、合服、跨区迁移都不会被误判成普通断线。

序号必须连续。增量下发时,客户端期望收到 本地序号 + 1,收到更大的序号说明中间缺条,收到更小的序号说明是重复下发。前者不能跳过,否则本地状态会永久缺一段;后者可以安全跳过,因为操作已经应用过。

情况判定处理
操作序号等于本地序号 + 1连续应用并推进序号
操作序号小于等于本地序号重复跳过,计入 skipped
操作序号大于本地序号 + 1断号立即失败,转快照或重拉
应用完仍未到达目标序号不完整立即失败,不能半套用

最后一条尤其重要。增量响应可能因为服务端分页、网络截断或客户端解析错误而少几条;如果客户端“能应用多少应用多少”并把序号推进到实际收到的位置,下一次校准会以为缺口已经补上,错误被永久固化。

待提交的幂等:重放与丢弃

状态校准只解决“服务端历史”,不解决“本地待提交”。断线时客户端可能已经生成了提交,但不确定服务端是否收到。重连后服务端会在校准响应里带回已确认的提交 ID 集合,客户端据此把待提交拆成两类。

类别判据动作
已确认提交 ID 在服务端已确认集合中丢弃,不再重发
未确认提交 ID 不在集合中按原顺序重放

提交 ID 必须在生成时固定,不能重发时重新生成。每生成一次新 ID,服务端就无法判断这是同一次提交还是新提交,幂等也就失效了。对《潮汐消消》这种回合制提交,ID 可以绑定“对局 ID + 回合号 + 客户端单调计数”,在本地持久化后再发送。

重放顺序也要保持。同一局内后一个回合可能依赖前一个回合的结算结果;如果并发重放,服务端看到的顺序可能与客户端生成顺序不同。模型保留原数组顺序,调用方应按顺序串行发送,或者在协议里携带客户端序号让服务端排序。

已确认集合本身可能很大。实际实现不必返回全部历史 ID,只需返回“自客户端最后确认序号以来的已确认提交”,或者返回一个可比较的高水位。模型为了可测试,使用显式集合;工程实现应把集合大小纳入协议预算。

可运行模型:resync.ts

下面的模型把四种决策、增量应用、断号检测和待提交拆分封装在一起。reconcile 是入口:它先决定校准方式,再应用数据,最后拆分待提交;每一步都返回可断言的计数。

// file: src/net/resync.ts
// tier: L1
// verify: node --test test/net/resync.test.ts
/**
 * 重连后的状态校准模型(mga-net-lab)。
 * 增量窗口与提交结构是实验设定,不是案例事实。
 */

export const DEFAULT_DELTA_WINDOW = 128;

export type OpKind = "set" | "add";

export interface ServerOp {
  readonly seq: number;
  readonly key: string;
  readonly op: OpKind;
  readonly value: number;
}

export interface ClientState {
  readonly epoch: string;
  readonly seq: number;
  readonly values: Readonly<Record<string, number>>;
}

export interface PendingSubmit {
  readonly submitId: string;
  readonly roundId: number;
  readonly score: number;
}

export interface ResyncRequest {
  readonly clientEpoch: string;
  readonly serverEpoch: string;
  readonly lastAckedSeq: number;
  readonly serverSeq: number;
  readonly deltaWindow?: number;
}

export type ResyncKind = "noop" | "delta" | "snapshot" | "reset";

export interface ResyncPlan {
  readonly kind: ResyncKind;
  readonly fromSeq: number;
  readonly toSeq: number;
  readonly missing: number;
  readonly reason: string;
}

export interface ServerResyncResponse {
  readonly epoch: string;
  readonly seq: number;
  readonly ops: readonly ServerOp[];
  readonly snapshot?: Readonly<Record<string, number>>;
  readonly ackedSubmitIds: readonly string[];
}

export interface ReconcileResult {
  readonly plan: ResyncPlan;
  readonly state: ClientState;
  readonly replay: readonly PendingSubmit[];
  readonly dropped: readonly PendingSubmit[];
  readonly applied: number;
  readonly skipped: number;
}

function assertSeq(value: number, label: string): void {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${label} 必须是非负整数,收到 ${value}`);
  }
}

export function planResync(request: ResyncRequest): ResyncPlan {
  assertSeq(request.lastAckedSeq, "lastAckedSeq");
  assertSeq(request.serverSeq, "serverSeq");
  const window = request.deltaWindow ?? DEFAULT_DELTA_WINDOW;
  assertSeq(window, "deltaWindow");
  const { lastAckedSeq: from, serverSeq: to } = request;

  if (request.clientEpoch !== request.serverEpoch) {
    return {
      kind: "snapshot",
      fromSeq: from,
      toSeq: to,
      missing: Math.max(0, to - from),
      reason: "会话纪元不一致,本地增量不可复用",
    };
  }
  if (from > to) {
    return {
      kind: "reset",
      fromSeq: from,
      toSeq: to,
      missing: 0,
      reason: "客户端序号超前,服务端可能已回滚",
    };
  }
  if (from === to) {
    return {
      kind: "noop",
      fromSeq: from,
      toSeq: to,
      missing: 0,
      reason: "序号一致,无需校准",
    };
  }
  const missing = to - from;
  if (missing <= window) {
    return {
      kind: "delta",
      fromSeq: from,
      toSeq: to,
      missing,
      reason: `缺失 ${missing} 条,在增量窗口 ${window} 内`,
    };
  }
  return {
    kind: "snapshot",
    fromSeq: from,
    toSeq: to,
    missing,
    reason: `缺失 ${missing} 条,超过增量窗口 ${window}`,
  };
}

export function applyDelta(
  state: ClientState,
  ops: readonly ServerOp[],
  toSeq: number,
): { state: ClientState; applied: number; skipped: number } {
  assertSeq(toSeq, "toSeq");
  const values: Record<string, number> = { ...state.values };
  let cursor = state.seq;
  let applied = 0;
  let skipped = 0;
  for (const op of ops) {
    if (op.seq <= cursor) {
      skipped += 1;
      continue;
    }
    if (op.seq !== cursor + 1) {
      throw new Error(`增量不连续:期望 ${cursor + 1},收到 ${op.seq}`);
    }
    if (op.op === "set") {
      values[op.key] = op.value;
    } else {
      values[op.key] = (values[op.key] ?? 0) + op.value;
    }
    cursor = op.seq;
    applied += 1;
  }
  if (cursor !== toSeq) {
    throw new Error(`增量未到达目标序号:游标 ${cursor},目标 ${toSeq}`);
  }
  return { state: { ...state, seq: cursor, values }, applied, skipped };
}

export function classifyPending(
  pending: readonly PendingSubmit[],
  ackedSubmitIds: readonly string[],
): { replay: readonly PendingSubmit[]; dropped: readonly PendingSubmit[] } {
  const acked = new Set(ackedSubmitIds);
  const replay: PendingSubmit[] = [];
  const dropped: PendingSubmit[] = [];
  for (const submit of pending) {
    if (acked.has(submit.submitId)) {
      dropped.push(submit);
    } else {
      replay.push(submit);
    }
  }
  return { replay, dropped };
}

export function reconcile(
  local: ClientState,
  pending: readonly PendingSubmit[],
  response: ServerResyncResponse,
  deltaWindow = DEFAULT_DELTA_WINDOW,
): ReconcileResult {
  const plan = planResync({
    clientEpoch: local.epoch,
    serverEpoch: response.epoch,
    lastAckedSeq: local.seq,
    serverSeq: response.seq,
    deltaWindow,
  });

  let state: ClientState;
  let applied = 0;
  let skipped = 0;

  if (plan.kind === "noop") {
    state = local;
  } else if (plan.kind === "delta") {
    const result = applyDelta(local, response.ops, response.seq);
    state = result.state;
    applied = result.applied;
    skipped = result.skipped;
  } else if (plan.kind === "snapshot") {
    if (response.snapshot === undefined) {
      throw new Error("快照校准缺少 snapshot 数据");
    }
    state = { epoch: response.epoch, seq: response.seq, values: { ...response.snapshot } };
  } else {
    state = {
      epoch: response.epoch,
      seq: response.seq,
      values: response.snapshot === undefined ? {} : { ...response.snapshot },
    };
  }

  const classified = classifyPending(pending, response.ackedSubmitIds);
  return {
    plan,
    state,
    replay: classified.replay,
    dropped: classified.dropped,
    applied,
    skipped,
  };
}

export function formatResync(result: ReconcileResult): string {
  const labels: Record<ResyncKind, string> = {
    noop: "无需校准",
    delta: "增量校准",
    snapshot: "快照替换",
    reset: "重置校准",
  };
  return `${labels[result.plan.kind]}:seq ${result.plan.fromSeq} → ${result.plan.toSeq},缺失 ${result.plan.missing} 条,应用 ${result.applied} 条,跳过 ${result.skipped} 条,重放 ${result.replay.length} 条提交,丢弃 ${result.dropped.length} 条重复提交`;
}

模型把“决策”和“应用”分开,是为了让调用方能在拉数据之前就知道要拉什么。planResync 只依赖本地序号、服务端序号、两个纪元和窗口大小,不依赖网络;客户端可以在发送校准请求前先算出预期结果,服务端返回后再用 applyDelta 校验实际数据是否符合预期。预期与实际不一致时,应当转快照,而不是相信任何一方。

applyDelta 对重复条目的处理是“跳过并计数”,对断号的处理是“抛错”。这两种错误性质不同:重复下发是网络重试的正常现象,可以容忍;断号意味着数据不完整,继续应用只会把错误固化。把两者都当成“跳过”或都当成“失败”,都会在某一侧出问题。

输出怎么读:formatResync

一次典型的增量校准输出是:

增量校准:seq 10 → 22,缺失 12 条,应用 12 条,跳过 0 条,重放 1 条提交,丢弃 1 条重复提交

这行文本同时回答四个问题:走了哪条决策、补了多少条、有没有重复、待提交怎么处理。排查“奖励重复”时,先看 丢弃 是否为 0;如果是 0 而玩家仍看到重复奖励,说明重复发生在服务端幂等之前,问题不在客户端校准。排查“状态回退”时,先看 应用 是否等于 缺失;不相等说明增量没有完整应用。

格式化输出不是给玩家看的,而是给日志和测试看的。测试直接断言整行字符串,可以防止后续重构悄悄改变计数口径;日志按固定字段解析,也比自由文本更容易聚合。

与服务端权威的边界

校准的最终权威在服务端。客户端可以决定“请求增量还是快照”,但不能决定“哪条提交算数”。已确认集合、序号和快照都来自服务端;客户端本地记录只用于减少传输量和改善体验,不能用来覆盖服务端结论。

这条边界对《潮汐消消》尤其重要。它虽然以弱联网为主,但奖励、体力和付费相关的结果仍然由服务端裁决;客户端校准错了,最多是多请求一次快照,不能变成“本地认为已经结算”。对《潮汐方舟》这种强联网战斗,客户端甚至连“战斗是否继续”都不能自行判断,校准后必须等服务端确认战斗令牌。

另一个边界是版本。客户端版本与服务端协议版本不一致时,增量操作的含义可能已经变化;此时即使序号连续,也应该走快照或强制更新。纪元只能表达“会话空间变了”,不能表达“协议语义变了”,两者要分别校验。

与相邻章节的关系

3.1 的 syncing 阶段是本节校准发生的时机,状态机只负责“进入同步”,不负责“怎么同步”。3.2 决定重连何时发生,本节决定重连成功后补什么。4.1 会讨论离线期间哪些提交可以进入队列;本节假设待提交已经存在,只处理它们的重放与去重。

如果进程在断线期间被杀,待提交和最后确认序号都必须从本地持久化记录恢复,否则校准会从错误起点开始;相关兜底可以参考杀进程与异常退出的兜底 。

小结

  • 重连成功不等于状态一致,校准要单独建模。
  • 四种决策按序号关系与纪元判定:noop、delta、snapshot、reset。
  • 纪元表达会话空间,序号表达缺口,窗口决定增量是否还划算。
  • 重复操作可以跳过,断号必须失败,二者不能混为一谈。
  • 待提交用固定 ID 做幂等,按已确认集合拆成重放与丢弃。
  • 客户端决定校准方式,服务端决定哪条历史算数。

阅读导航:上一节:3.2 指数退避与抖动:重连节奏怎么定 · 下一节:4.1 离线可做与不可做:队列边界 。

常见错误与边界

  1. 一检测到重连就拉全量,短暂断线也覆盖本地状态。
  2. 只看序号差值,不比较纪元,服务端重启后被误判为客户端超前。
  3. 增量断号时“能应用多少应用多少”,把缺失段永久固化。
  4. 把重复下发当成断号直接失败,网络重试导致校准永远不成功。
  5. 重放提交时重新生成 ID,服务端无法识别为同一次提交。
  6. 并发重放同一局的多个回合,服务端看到的顺序与生成顺序不同。
  7. 已确认集合无限增长,校准响应本身成为新的流量负担。
  8. 校准失败后直接重置本地状态,玩家看到进度无故归零。
  9. 用客户端本地记录覆盖服务端快照,权威边界被打破。
  10. 协议版本变化时仍按序号连续走增量,字段语义已经不同。
  11. 校准成功后不清理待提交,下一次断线又重放一遍。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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