《移动游戏产品的架构·网络、资源与热更新》4.2 操作队列的幂等与去重

离线队列的难点不是排队,而是同一条操作被提交两次。本节以消消的单局提交为线索,拆开客户端 opId、服务端幂等键与确认丢失三件事,给出可重放的队列状态机、指数退避、确认剪枝与重复负载识别,并用测试覆盖重复提交、确认丢失与重试耗尽,并说明边界。

场景:补发三次,分数涨了两次

《潮汐消消》的一次弱网回归里,玩家在地铁里打完一局,客户端把结果放进队列。出站后第一次补发超时,客户端重试;第二次补发其实已经到达服务端,但确认包在回程丢了,客户端又重试一次。服务端把两次都当成新提交,星星和分数各加了一遍。

日志里能看到三个请求长得几乎一样:同一个玩家、同一局、同一份结果,只有请求时间不同。客户端认为自己只是「重发」,服务端却认为这是「三次独立提交」。问题不在网络,而在客户端没有给这条操作一个跨重试稳定的身份。

消消一局有 14–22 次提交,每次 600B–1.2KB;断线率 2.1%,重连成功率 97.2%,重连后需要补状态的比例 8%。这些数字说明:重试是常态,确认丢失是常态,队列必须假设「同一条操作会被送很多次」。

三个身份:opId、seq 与负载签名

去重不能只靠一个字段。opId 负责「这是同一条操作」,seq 负责「它在本地队列里的顺序」,负载签名负责「同一个 id 有没有被复用于不同内容」。三者缺一,都会留下静默错误。

字段作用生成时机变化规则
opId跨重试稳定的幂等键操作第一次进入队列时重试不得重新生成
seq本地顺序与断点续传入队时单调递增同一条操作保持不变
负载签名识别 id 复用与内容改写入队时由类型与负载计算负载变则签名变

最常见的错误是「重试时重新生成 opId」。很多网络库默认每次请求生成新 id,如果队列直接复用这个 id,服务端就永远看不到重复。正确做法是:opId 在入队时生成一次,之后所有重试、补发、跨进程恢复都复用它;网络层可以有自己的请求 id,但那是传输层的事,不能替代业务幂等键。

负载签名解决另一类问题:同一个 opId 被复用于不同操作。比如玩家先提交「通关」,后来本地状态被改写,又用同一个 id 提交「失败」。如果服务端只按 id 去重,第二条会被当成重复丢掉;如果客户端只按 id 去重,第二条会覆盖第一条。两种都不对,应该显式报 payload-mismatch,把问题暴露出来。

队列状态机:待发、在途、已确认、失败

队列里的条目不是「有」或「没有」,而是有状态的。最小状态机包含待发、在途、已确认与失败四种状态;已经确认的条目不能立刻删除,还要在本地留一段时间,用来识别「确认丢失后的重发」。

状态含义允许的动作下一步
待发已入队,尚未发送取批、撤回取批后进入在途
在途已发送,等待确认等待、超时重试确认后进入已确认
已确认服务端已接受记录签名、等待剪枝超过保留窗口后清理
失败发送失败或服务端拒绝退避、重试、耗尽丢弃回到待发或丢弃

状态迁移必须由同一个队列对象串行驱动。如果发送线程和确认回调各自修改队列,容易出现「已经确认的条目又被重发」:确认回调删掉了条目,发送线程手里的旧快照又把它发了出去。模型里所有操作都返回新的不可变状态,调用方用返回值替换旧状态,就是为了让这种竞态在类型层面无处藏身。

「已确认」保留多久,取决于服务端可能重发确认的窗口,也取决于客户端可能重试的窗口。保留太久会占内存,保留太短会让确认丢失后的重发穿透到服务端。工程上通常用服务端下发的确认水位或一个保守的时间窗来剪枝,而不是按条目数猜。

重试不是重发:指数退避与批量上限

重试策略要同时限制「多久试一次」和「一次试多少」。只做指数退避、不做批量上限,恢复网络的一瞬间会把整条队列一次性打出去;只做批量上限、不做退避,弱网下会以固定频率持续撞击服务端。

策略作用缺失后的后果
指数退避拉开重试间隔,避开拥塞弱网下持续重试,放大丢包
最大延迟防止退避到几乎不再重试队列长期不推进
批量上限控制单次在途条目数恢复瞬间形成小高峰
最大尝试次数防止坏条目永久占位队列被一条坏操作堵死

退避的倍数与上限是策略参数,不是事实。真正重要的是「同一条操作的重试间隔随失败次数增长,且有一个明确的上限」;以及「一次只发一批,确认一批再发下一批」。这样即使玩家在隧道里反复进出,客户端也不会把队列变成对服务端的持续压力。

失败到最大次数后,条目应该被丢弃并上报,而不是无限重试。丢弃不是「当没发生过」:调用方要拿到明确的 dropped 结果,决定是提示玩家、落一条本地日志,还是把该操作转成只读展示。

确认剪枝:为什么已确认的 id 还要留

服务端确认到达客户端之前,客户端不知道对方是否已经处理。如果确认丢了,客户端会重发;如果客户端在收到确认的瞬间就把 id 忘了,下一次重发就会被当成新操作。保留已确认 id 的目的,是让「确认丢失后的重发」在客户端就被识别为 duplicate-acked。

情况客户端手里有什么正确处理
确认正常到达条目在待发/在途移入已确认,记录签名
确认丢失,客户端重发条目仍在队列重发同一 opId,服务端幂等
重发前收到迟到确认条目已移入已确认重发被识别为 duplicate-acked
已确认 id 被复用于新负载已确认表里有旧签名报 payload-mismatch,不静默覆盖

已确认表也要有边界。它只保存「id 到负载签名」的映射,不保存完整操作;按时间或服务端水位剪枝后,旧 id 才允许被回收。回收之后如果还有极端迟到的重发,就只能依赖服务端幂等键兜底。客户端去重与服务端幂等是两层防线,不能只留一层。

可运行模型:offline-queue.ts

下面的 offline-queue.ts 把入队去重、批量取件、确认剪枝、失败退避与摘要放在同一个文件里。它不访问网络,也不持久化;时间由调用方注入,退避是确定性的,便于测试复现。

// file: src/net/offline-queue.ts
// tier: L1
// verify: node --test test/net/offline-queue.test.ts

export interface QueuedOperation {
  readonly opId: string;
  readonly seq: number;
  readonly kind: string;
  readonly payload: string;
  readonly createdAt: number;
  readonly attempts: number;
  readonly nextRetryAt: number;
}

export interface QueueState {
  readonly items: readonly QueuedOperation[];
  readonly acked: ReadonlyMap<string, string>;
  readonly nextSeq: number;
}

export interface EnqueueInput {
  readonly opId: string;
  readonly kind: string;
  readonly payload: string;
  readonly createdAt: number;
}

export type EnqueueCode = "appended" | "duplicate-pending" | "duplicate-acked" | "payload-mismatch";

export interface EnqueueResult {
  readonly state: QueueState;
  readonly code: EnqueueCode;
}

export interface RetryPolicy {
  readonly baseDelayMs: number;
  readonly maxDelayMs: number;
  readonly maxAttempts: number;
}

const DEFAULT_RETRY_POLICY: RetryPolicy = {
  baseDelayMs: 1_000,
  maxDelayMs: 60_000,
  maxAttempts: 5,
};

function signature(kind: string, payload: string): string {
  return `${kind}\u0000${payload}`;
}

export function createQueue(): QueueState {
  return { items: [], acked: new Map(), nextSeq: 1 };
}

export function enqueue(state: QueueState, input: EnqueueInput): EnqueueResult {
  const pending = state.items.find((item) => item.opId === input.opId);
  if (pending !== undefined) {
    const same = pending.kind === input.kind && pending.payload === input.payload;
    return { state, code: same ? "duplicate-pending" : "payload-mismatch" };
  }

  const ackedSignature = state.acked.get(input.opId);
  if (ackedSignature !== undefined) {
    const same = ackedSignature === signature(input.kind, input.payload);
    return { state, code: same ? "duplicate-acked" : "payload-mismatch" };
  }

  const item: QueuedOperation = {
    opId: input.opId,
    seq: state.nextSeq,
    kind: input.kind,
    payload: input.payload,
    createdAt: input.createdAt,
    attempts: 0,
    nextRetryAt: input.createdAt,
  };
  return {
    state: { items: [...state.items, item], acked: state.acked, nextSeq: state.nextSeq + 1 },
    code: "appended",
  };
}

export interface BatchPlan {
  readonly batch: readonly QueuedOperation[];
  readonly deferred: number;
}

export function nextBatch(state: QueueState, now: number, limit: number): BatchPlan {
  if (!Number.isInteger(limit) || limit <= 0) {
    throw new RangeError(`batch limit must be a positive integer: ${limit}`);
  }
  const ordered = [...state.items].sort((a, b) => a.seq - b.seq);
  const batch = ordered.filter((item) => item.nextRetryAt <= now).slice(0, limit);
  return { batch, deferred: ordered.length - batch.length };
}

export interface AckResult {
  readonly state: QueueState;
  readonly acked: readonly string[];
  readonly alreadyAcked: readonly string[];
  readonly unknown: readonly string[];
}

export function acknowledge(state: QueueState, opIds: readonly string[]): AckResult {
  const pendingIds = new Set(opIds);
  const ackedNow: string[] = [];
  const remaining: QueuedOperation[] = [];
  const ackedMap = new Map(state.acked);

  for (const item of state.items) {
    if (pendingIds.has(item.opId)) {
      ackedMap.set(item.opId, signature(item.kind, item.payload));
      ackedNow.push(item.opId);
      pendingIds.delete(item.opId);
    } else {
      remaining.push(item);
    }
  }

  const alreadyAcked: string[] = [];
  const unknown: string[] = [];
  for (const opId of pendingIds) {
    if (ackedMap.has(opId)) {
      alreadyAcked.push(opId);
    } else {
      unknown.push(opId);
    }
  }

  return {
    state: { items: remaining, acked: ackedMap, nextSeq: state.nextSeq },
    acked: ackedNow,
    alreadyAcked,
    unknown,
  };
}

export type FailureCode = "retry-scheduled" | "dropped" | "unknown";

export interface FailureResult {
  readonly state: QueueState;
  readonly code: FailureCode;
  readonly retryAt?: number;
}

export function markFailed(
  state: QueueState,
  opId: string,
  now: number,
  policy: RetryPolicy = DEFAULT_RETRY_POLICY,
): FailureResult {
  const target = state.items.find((item) => item.opId === opId);
  if (target === undefined) {
    return { state, code: "unknown" };
  }

  const attempts = target.attempts + 1;
  if (attempts >= policy.maxAttempts) {
    return {
      state: { items: state.items.filter((item) => item.opId !== opId), acked: state.acked, nextSeq: state.nextSeq },
      code: "dropped",
    };
  }

  const delay = Math.min(policy.baseDelayMs * 2 ** target.attempts, policy.maxDelayMs);
  const retryAt = now + delay;
  const items = state.items.map((item) =>
    item.opId === opId ? { ...item, attempts, nextRetryAt: retryAt } : item,
  );
  return {
    state: { items, acked: state.acked, nextSeq: state.nextSeq },
    code: "retry-scheduled",
    retryAt,
  };
}

export function describeQueue(state: QueueState, now: number): string {
  const ready = state.items.filter((item) => item.nextRetryAt <= now).length;
  const waiting = state.items.length - ready;
  const next = state.items.length === 0 ? "无" : String(state.items[0].seq);
  return `队列 ${state.items.length} 条(就绪 ${ready},等待重试 ${waiting}),已确认 ${state.acked.size} 条,下一条 seq=${next}`;
}

模型导出六个入口:建队、入队、取批、确认、标记失败与摘要。入队先查待发条目,再查已确认表,分别返回 duplicate-pending、duplicate-acked 或 payload-mismatch;确认把条目移出队列并记录负载签名;失败按 2 的幂次退避,达到最大次数后丢弃。配套测试覆盖十条路径:正常入队、待发重复、负载不一致、已确认重复、批量与到期过滤、确认、重复确认与未知确认、退避、耗尽丢弃,以及摘要输出。

模型里的退避没有抖动,是为了让测试可断言;生产环境应在退避上叠加抖动,避免大量客户端在同一时刻恢复后同步重试。抖动不改变状态机,只改变下一次重试的时间点。

双案例:消消的回合提交与方舟的输入流

消消的队列以「单局提交」为单位:一局 14–22 次提交,每次 600B–1.2KB,条目不多但必须跨重试稳定。它适合「客户端生成 opId、服务端按 opId 去重」的模型;确认丢失后重发同一条,服务端只应用一次,客户端把已确认 id 留到剪枝窗口结束。

方舟的输入流密度高得多:战斗内上行输入 20Hz,每条 48B,服务端 10Hz 下发 350B 快照。这类输入通常不进离线队列,而是走「带序号的状态同步 + 丢包重传」;真正需要离线队列的是战斗外的赛季配置、外观与文本。两者的队列形态不同,但幂等原则相同:同一条操作必须有一个跨重试稳定的身份。

与相邻章节的关系

4.1 决定哪些操作可以进入队列,本节决定进入队列后如何安全地重发与确认。4.3 会处理重发之后仍然存在的状态冲突:即使每条操作只应用一次,两条不同操作也可能改同一个字段。重连后的整体状态校准见 3.3;本节只关心单条操作的生命周期,不负责整份状态的对齐。

小结

  • 幂等需要三个身份:跨重试稳定的 opId、本地顺序 seq、识别 id 复用的负载签名。
  • 队列条目有四种状态:待发、在途、已确认、失败;状态迁移必须串行,避免确认与重发互相踩踏。
  • 重试要同时有指数退避、最大延迟、批量上限与最大尝试次数;缺一项都会留下拥堵或永久占位。
  • 已确认 id 要保留到剪枝窗口结束,用来识别确认丢失后的重发;客户端去重与服务端幂等是两层防线。
  • 消消按单局提交做幂等队列,方舟把高频战斗输入交给状态同步;形态不同,原则相同。

阅读导航:上一节:4.1 离线可做与不可做:队列边界 · 下一节:4.3 冲突解决与可见性延迟 。

常见错误与边界

  1. 重试时重新生成 opId。这是最常见的重复提交来源;opId 必须在入队时生成一次,之后所有重试复用它。
  2. 只按 opId 去重,不校验负载。同一个 id 被复用于不同操作时,静默去重会丢掉一次真实变更;应报 payload-mismatch。
  3. 确认到达就立刻删除条目。确认丢失后的重发会被当成新操作;已确认 id 要保留到剪枝窗口结束。
  4. 发送线程与确认回调各改一份队列。两边持有旧快照会互相覆盖;状态迁移应串行,或使用不可变状态加单写者。
  5. 只做退避,不做批量上限。恢复网络的瞬间会把整条队列打出去,形成小高峰;一次只发一批。
  6. 只做批量上限,不做退避。弱网下会以固定频率持续撞击服务端,放大丢包与耗电。
  7. 失败无限重试。坏条目会永久占位并阻塞后续操作;达到最大次数后应丢弃并上报。
  8. 把传输层请求 id 当成业务幂等键。网络库的 id 每次请求都变,服务端无法据此识别重发。
  9. 已确认表无界增长。只存 id 与签名并按时间或水位剪枝,不要保存完整操作。
  10. 边界:客户端去重不能替代服务端幂等。客户端崩溃、换机或剪枝之后,仍可能有重复请求到达;服务端必须按键去重。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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