《Cocos 游戏联机实战》5.2 超时、重传与幂等

第 4 章的预测与和解默认消息按序送达,可真实链路会丢包、重复、乱序。本节给出动态超时判定(基于 1.3 的 RTT 估计而非写死)、哪些消息可重传哪些必须丢、用序号信封加去重表实现幂等,并用超时重传器配 vitest 断言「重复消息只处理一次」「丢包下重传最终送达」。

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 × 样本
RTTVARRTT 的平均偏差`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 弱网降级策略 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画