场景:补发三次,分数涨了两次
《潮汐消消》的一次弱网回归里,玩家在地铁里打完一局,客户端把结果放进队列。出站后第一次补发超时,客户端重试;第二次补发其实已经到达服务端,但确认包在回程丢了,客户端又重试一次。服务端把两次都当成新提交,星星和分数各加了一遍。
日志里能看到三个请求长得几乎一样:同一个玩家、同一局、同一份结果,只有请求时间不同。客户端认为自己只是「重发」,服务端却认为这是「三次独立提交」。问题不在网络,而在客户端没有给这条操作一个跨重试稳定的身份。
消消一局有 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 冲突解决与可见性延迟 。
常见错误与边界
- 重试时重新生成 opId。这是最常见的重复提交来源;opId 必须在入队时生成一次,之后所有重试复用它。
- 只按 opId 去重,不校验负载。同一个 id 被复用于不同操作时,静默去重会丢掉一次真实变更;应报 payload-mismatch。
- 确认到达就立刻删除条目。确认丢失后的重发会被当成新操作;已确认 id 要保留到剪枝窗口结束。
- 发送线程与确认回调各改一份队列。两边持有旧快照会互相覆盖;状态迁移应串行,或使用不可变状态加单写者。
- 只做退避,不做批量上限。恢复网络的瞬间会把整条队列打出去,形成小高峰;一次只发一批。
- 只做批量上限,不做退避。弱网下会以固定频率持续撞击服务端,放大丢包与耗电。
- 失败无限重试。坏条目会永久占位并阻塞后续操作;达到最大次数后应丢弃并上报。
- 把传输层请求 id 当成业务幂等键。网络库的 id 每次请求都变,服务端无法据此识别重发。
- 已确认表无界增长。只存 id 与签名并按时间或水位剪枝,不要保存完整操作。
- 边界:客户端去重不能替代服务端幂等。客户端崩溃、换机或剪枝之后,仍可能有重复请求到达;服务端必须按键去重。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。