5.2 超时、重传与幂等
上一节的重连握手其实藏了两个没展开的细节:resume 这条消息如果丢了怎么办?服务端因为丢包重发了一条你已经处理过的快照,会不会被当成新快照算两遍?这两个问题——丢包与重复——是第 4 章所有预测推导的隐含假设的反面。
第 1.3 节讲过怎么测 RTT 与抖动,本节把这些测量结果真正用起来:用动态超时决定「等多久算丢」,用序号与去重表决定「重复的消息怎么处理」。站内服务端书稿讲服务端如何做可靠投递与幂等;本节只讲客户端这一侧够用的可靠层。
5.2.1 丢包与重复从哪里来
WebSocket 建在 TCP 之上,单条连接内不会丢包也不会重复。那客户端为什么还要处理这两件事?因为游戏用的是逻辑消息,而逻辑消息的生命周期可以跨越物理连接:
- 丢包:一条
input消息刚写进 socket 缓冲区,链路就断了。这条消息在 TCP 语义下「没发出去」,但在游戏语义下是「玩家按了却没生效」。 - 重复:断线重连后客户端补发
pendingInputs,如果服务端在断线前其实已经收到了一部分,补发就成了重复投递。 - 乱序:UDP 或自定义可靠层(小游戏平台常用)下,后发的消息先到是常态。
所以客户端的可靠层要解决的不是「TCP 会不会丢」,而是「跨连接、跨重试,同一个逻辑操作会不会被漏掉或被做两遍」。
5.2.2 超时判定:别写死,用动态 RTT 估计
超时阈值最容易犯的错是写死一个常数,比如「2 秒没回就重传」。写死有两个坏处:网络好时太慢(明明 50ms 就丢了却等 2 秒),网络差时太急(RTT 本来就 300ms,2 秒还没到就误判丢包、狂重传)。
正确做法沿用第 1.3 节测出的 RTT,按 RFC 6298 的口径维护两个平滑量——SRTT(平滑 RTT)与 RTTVAR(RTT 偏差),超时取 SRTT + 4 × RTTVAR,再夹一个下限:
| 量 | 含义 | 更新 |
|---|---|---|
SRTT | 平滑往返时间 | 0.875 × SRTT + 0.125 × 样本 |
RTTVAR | RTT 的平均偏差 | `0.75 × RTTVAR + 0.25 × |
timeout | 超时阈值 | max(200, SRTT + 4 × RTTVAR) |
下限取 200ms 是为了防止网络极好时抖动把超时压到几十毫秒,导致正常的延迟波动被误判成丢包。
5.2.3 哪些消息能重传,哪些必须丢
不是所有消息都值得重传。判断标准是这条消息过期之后还有没有价值:
| 消息 | 能否重传 | 理由 |
|---|---|---|
输入 input | 能 | 玩家的操作必须生效,哪怕迟到也要补 |
状态快照 snapshot | 不能,过期即丢 | 旧快照被新快照覆盖,重传等于浪费带宽 |
心跳 heartbeat | 能 | 丢了会误判掉线,必须补 |
请求 request(带 id) | 能,但要幂等 | 重传可能造成重复处理,靠序号去重 |
一句话原则:「覆盖型」的消息过期就丢,「累积型」的消息才值得重传。快照是覆盖型的(每帧都是全量的最新世界),输入是累积型的(少一条就少一个操作)。
5.2.4 幂等设计:序号信封 + 去重表
让重传安全的前提是接收端幂等——同一条消息处理一次和处理两次,结果必须一样。做法是给每条消息套一个带唯一序号的信封:
export interface Envelope {
readonly seq: number;
readonly type: string;
readonly payload?: unknown;
}
接收端维护一张去重表,记录窗口内已经处理过的序号;序号落在窗口外(太旧)或已见过,就直接丢弃。哪些操作天然幂等、哪些必须靠序号,可以对照:
- 天然幂等:
setPosition(x, y)这类「赋值」——做两遍和做一遍一样。 - 必须靠序号:「加 10 分」「开一枪」这类「累加/触发」——做两遍就出 bug。
5.2.5 心跳与保活
心跳解决的是「双方都不知道链路已经死了」的沉默期。TCP 半开连接下,一端进程已经崩溃,另一端却以为连接还在,直到下次真正发数据才发现。客户端按固定间隔(参考值 2 秒,需按你的网络条件复测)发一条 heartbeat,服务端回 heartbeatAck;连续若干个周期没回,就主动判定掉线并走上一节的重连流程。心跳本身也可能丢,所以要能被重传。
5.2.6 L1:序号信封 + 去重表 + 超时重传器
把上面三件事写成纯 TypeScript,零 cc 依赖。去重表与 RTT 估计器:
// assets/scripts/net/protocol/reliable.ts —— 纯 TS,无 cc 依赖
export interface Envelope {
readonly seq: number;
readonly type: string;
readonly payload?: unknown;
}
/** 接收端去重表:只放行窗口内首次出现的序号 */
export class DedupTable {
private readonly seen = new Set<number>();
private readonly window: number;
private highest = -1;
constructor(window: number) {
this.window = window;
}
/** true = 首次见到,应处理;false = 重复或过期,应丢弃 */
accept(seq: number): boolean {
if (seq <= this.highest - this.window || this.seen.has(seq)) {
return false;
}
this.seen.add(seq);
if (seq > this.highest) {
this.highest = seq;
}
for (const s of this.seen) {
if (s <= this.highest - this.window) {
this.seen.delete(s);
}
}
return true;
}
}
/** RTT 估计(RFC 6298 口径),超时 = SRTT + 4 × RTTVAR */
export class RttEstimator {
private srtt = 0;
private rttvar = 0;
private hasSample = false;
sample(rttMs: number): void {
if (!this.hasSample) {
this.srtt = rttMs;
this.rttvar = rttMs / 2;
this.hasSample = true;
return;
}
this.rttvar = 0.75 * this.rttvar + 0.25 * Math.abs(this.srtt - rttMs);
this.srtt = 0.875 * this.srtt + 0.125 * rttMs;
}
/** 动态超时,下限 200ms */
timeout(): number {
return Math.max(200, this.srtt + 4 * this.rttvar);
}
}
超时重传器:只跟踪需要可靠送达的消息,到点重发,收到 ACK 就删除,次数耗尽就放弃。
interface Pending {
readonly envelope: Envelope;
deadlineMs: number;
attempts: number;
}
export class Retransmitter {
private readonly pending = new Map<number, Pending>();
private readonly maxAttempts: number;
private readonly estimator: RttEstimator;
constructor(maxAttempts: number, estimator: RttEstimator) {
this.maxAttempts = maxAttempts;
this.estimator = estimator;
}
track(envelope: Envelope, nowMs: number): void {
this.pending.set(envelope.seq, {
envelope,
deadlineMs: nowMs + this.estimator.timeout(),
attempts: 1,
});
}
ack(seq: number): void {
this.pending.delete(seq);
}
/** 推进时间,返回本轮需要重传的信封(耗尽次数的直接丢弃) */
tick(nowMs: number): Envelope[] {
const out: Envelope[] = [];
for (const [seq, p] of this.pending) {
if (nowMs < p.deadlineMs) {
continue;
}
if (p.attempts >= this.maxAttempts) {
this.pending.delete(seq);
continue;
}
p.attempts += 1;
p.deadlineMs = nowMs + this.estimator.timeout();
out.push(p.envelope);
}
return out;
}
get pendingCount(): number {
return this.pending.size;
}
}
vitest 断言覆盖两个行为——重复只处理一次、丢包下重传最终送达:
import { describe, expect, it } from 'vitest';
import { DedupTable, Retransmitter, RttEstimator } from './reliable';
describe('可靠层', () => {
it('重复消息只被处理一次', () => {
const dedup = new DedupTable(16);
const processed: number[] = [];
const receive = (seq: number): void => {
if (dedup.accept(seq)) {
processed.push(seq);
}
};
receive(1); receive(2); receive(2); receive(1); receive(3);
expect(processed).toEqual([1, 2, 3]);
});
it('超时后重传一次,ACK 后停止', () => {
const est = new RttEstimator();
est.sample(100); // timeout = max(200, 100 + 4×50) = 300
const rt = new Retransmitter(3, est);
const env = { seq: 10, type: 'input' };
rt.track(env, 0);
expect(rt.tick(299)).toEqual([]);
expect(rt.tick(300).map((e) => e.seq)).toEqual([10]);
expect(rt.tick(300).length).toBe(0); // 同刻不重复
rt.ack(10);
expect(rt.pendingCount).toBe(0);
});
it('丢包下重传最终送达', () => {
const est = new RttEstimator();
est.sample(100);
const rt = new Retransmitter(5, est);
const delivered: number[] = [];
let attempts = 0;
const trySend = (env: { seq: number }): void => {
attempts += 1;
if (attempts > 2) {
delivered.push(env.seq); // 前两次丢包,第三次才到
}
};
const env = { seq: 42, type: 'input' };
rt.track(env, 0);
trySend(env);
let now = 0;
for (let i = 0; i < 10 && delivered.length === 0; i += 1) {
now += 300;
for (const e of rt.tick(now)) {
trySend(e);
}
}
expect(delivered).toContain(42);
expect(attempts).toBe(3);
});
});
跑起来:
cd mini2d && npx vitest run src/net/reliable.test.ts
第三个断言能通过,是因为 track 后第一次发送(attempts=1)被丢,tick(300) 触发重传(attempts=2)又被丢,tick(600) 再重传(attempts=3)才送达——attempts 正好等于 3,证明「前两次丢包、第三次成功」这条路径确实走到了。
5.2.7 把可靠层参数接进编辑器
上面的三个参数——心跳间隔、最大重传次数、去重窗口——都该暴露给编辑器,按机型与地区调参而不用改代码。用 @property 挂到一个配置组件上即可,它只依赖 cc 公开 API,能过 tsc --noEmit:
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ReliableConfig')
export class ReliableConfig extends Component {
@property({ tooltip: '心跳间隔,单位毫秒;太小费流量,太大察觉不到掉线' })
heartbeatMs = 2000;
@property({ tooltip: '同一消息最大重传次数,超过则判定对端不可达' })
maxRetransmit = 5;
@property({ tooltip: '去重表窗口大小,需大于网络最大乱序跨度' })
dedupWindow = 64;
}
去重窗口要选得比「网络最大乱序跨度」大:如果一次重连最多补发 20 条输入,窗口至少给到 32 以上,否则迟到的旧消息会从窗口外漏进来被重复处理。
5.2.8 ACK 与滑动窗口的取舍
看到「重传 + ACK」很容易想到干脆实现一个完整的 TCP:滑动窗口、拥塞控制、快速重传。本卷明确不做。理由很简单:游戏要的可靠层只服务「一小撮累积型消息」,快照那种覆盖型消息根本不需要可靠层,实现完整 TCP 是拿复杂的机制去解一个简单的问题。
够用的可靠层只要三样:动态超时(本节 5.2.2)、序号去重(5.2.4)、有上限的重传(5.2.6)。ACK 只需要一个简单的「按序号确认」,不需要累积确认与窗口协商——因为并发未确认的消息本来就只有个位数。
下一节换个方向:前面讲的都是「怎么把消息可靠送达」,但网络差到一定程度时,更好的选择可能是少发、降质、甚至暂时不追求同步。这就是弱网降级策略。
阅读导航:上一节:5.1 重连流程与状态恢复 · 下一节:5.3 弱网降级策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。