场景:重连成功之后,奖励被结算了两次
《潮汐消消》一局 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 离线可做与不可做:队列边界 。
常见错误与边界
- 一检测到重连就拉全量,短暂断线也覆盖本地状态。
- 只看序号差值,不比较纪元,服务端重启后被误判为客户端超前。
- 增量断号时“能应用多少应用多少”,把缺失段永久固化。
- 把重复下发当成断号直接失败,网络重试导致校准永远不成功。
- 重放提交时重新生成 ID,服务端无法识别为同一次提交。
- 并发重放同一局的多个回合,服务端看到的顺序与生成顺序不同。
- 已确认集合无限增长,校准响应本身成为新的流量负担。
- 校准失败后直接重置本地状态,玩家看到进度无故归零。
- 用客户端本地记录覆盖服务端快照,权威边界被打破。
- 协议版本变化时仍按序号连续走增量,字段语义已经不同。
- 校准成功后不清理待提交,下一次断线又重放一遍。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。