# 自定义 UDP 协议与KCP

TCP 太保守,纯 UDP 不可靠——自己造一个适合游戏的协议

前置知识:第02章 网络同步基础 + 3_2_cs-fundamentals(TCP/UDP原理)


阅读指南(初学者必看)

为什么你需要学习自定义 UDP 协议与KCP?

TCP 的可靠有序特性会导致队头阻塞——一个包丢了,后面所有包都等着。纯 UDP 又不可靠。游戏需要的是"部分可靠、部分有序"的传输——某些消息必须可靠(聊天、交易),某些可以丢(位置更新)。自定义 UDP 协议 + KCP 就是为了满足这个需求。

学完本章,你能回答:

  • 为什么游戏不用 TCP 也不用纯 UDP?
  • ARQ 三种模式(Stop-and-Wait / Go-Back-N / Selective Repeat)有什么区别?
  • FEC 前向纠错怎么做到"丢了包也能恢复"?
  • KCP 为什么比 TCP 快40%?怎么在游戏里用?

本文结构

第一部分:为什么需要可靠 UDP?——TCP 的问题和游戏的需求
第二部分:可靠 UDP 实现(ARQ)——三种重传模式
第三部分:FEC(前向纠错)——丢包不用重传也能恢复
第四部分:KCP协议详解——比TCP更快,比UDP更可靠
第五部分:游戏应用实践——多通道协议设计

一、为什么需要可靠 UDP?

TCP 的问题:
1. 队头阻塞:一个包丢了,后面所有包都等着
2. 拥塞控制:丢包时大幅降速(游戏不需要这么保守)
3. 最小延迟:无法低于 TCP 协议本身的开销

游戏的需求:
1. 某些消息必须可靠(聊天、交易)
2. 某些消息可以丢(位置更新、动画同步)
3. 某些消息需要有序(指令序列)
4. 某些消息不需要序(语音、视频)

二、可靠 UDP 实现(ARQ)

ARQ(自动重传请求)三种模式:

1. Stop-and-Wait:
   发一个包 → 等ACK → 再发下一个
   效率极低

2. Go-Back-N:
   连续发 N 个包 → 某个包丢了 → 从丢的包开始全部重传
   效率一般

3. Selective Repeat:⭐ 推荐
   连续发 N 个包 → 某个包丢了 → 只重传丢的那个
   效率最高

三、FEC(前向纠错)

FEC:多发一些冗余数据,丢包时可以用冗余数据恢复

最简单:每发 2 个数据包,加 1 个异或包
  data1 ⊕ data2 = fec
  如果 data1 丢了,可以用 data2 ⊕ fec = data1 恢复

更高级:Reed-Solomon 编码
  N 个数据包 + M 个校验包
  任意丢失 M 个包都能恢复

四、KCP协议详解

4.1 KCP vs TCP vs UDP

特性 UDP TCP KCP
可靠性 不可靠 可靠 可配置可靠
有序性 无序 有序 可配置有序
拥塞控制 无 有(保守) 有(激进)
重传机制 无 超时重传 快速重传
延迟 最低 高(多次RTT) 低(1次RTT)
复杂度 简单 复杂 中等

4.2 KCP的核心优势

KCP设计目标:比TCP快40%
- RTO快速重传,不等超时
- 可选有序/无序
- 拥塞控制可配置
- 无队头阻塞

4.3 KCP核心配置

// KCP配置
const ikcp = {
    // 发送窗口大小
    snd_wnd: 32,

    // 接收窗口大小
    rcv_wnd: 32,

    // 最大传输单元(MTU)
    mtu: 1400,

    // 最小RTO(重传超时)
    // TCP是200ms,KCP是30ms
    rx_minrto: 30,

    // 是否快速重传
    // 0 = 关闭,n = 跳过的包数
    fastresend: 2,

    // 是否开启拥塞控制
    // 0 = 关闭,关闭后相当于快速UDP
    nodelay: 1,

    // 拥塞窗口
    cwnd: 32,

    // 最大分片数
    mss: 1400,

    // 流模式(无序接收)
    stream: false
};

4.4 RTO快速重传

TCP重传(等RTO):
  发送:1,2,3,4,5
                ↑ 包2丢了
  超时等待:...(等200ms)
  重传:2,3,4,5

KCP快速重传(等2次ACK):
  发送:1,2,3,4,5
                ↑ 包2丢了
  ACK:收到1,3,4,5
        ↑ 收到3说明2丢了
  立即重传:2(不等超时)

4.5 KCP核心结构

class IKCP {
    constructor(conv, user) {
        this.conv = conv;      // 会话ID
        this.user = user;      // 底层发送函数

        // 发送窗口和接收窗口
        this.snd_wnd = 32;
        this.rcv_wnd = 32;

        // RTT估计
        this.srtt = 0;  // smoothed RTT
        this.rttvar = 0;  // RTT variation
        this.rto = 100;   // Retransmission Timeout

        // 发送缓冲区
        this.snd_queue = [];    // 待发送
        this.snd_buf = [];      // 已发送未确认
        this.snd_nxt = 0;       // 下一个发送序号

        // 接收缓冲区
        this.rcv_queue = [];    // 已接收待交付
        this.rcv_nxt = 0;       // 下一个期待序号

        // 往返跟踪
        this.delivery = [];     // 发送包的发送时间
    }

    // 发送数据
    send(data) {
        // 分片
        const segs = this.fragment(data);

        // 加入发送队列
        for (const seg of segs) {
            seg.seq = this.snd_nxt++;
            seg.len = data.length;
            this.snd_queue.push(seg);
        }
    }

    // 输入(接收到的数据)
    input(data) {
        // 解析KCP包
        const kcp = this.parse(data);

        // 更新RTT
        this.updateRTT(kcp.timestamp);

        // 处理ACK
        if (kcp.cmd === 1) {  // ACK
            this.onAck(kcp);
        }
        // 处理数据
        else if (kcp.cmd === 2) {  // 数据
            this.onData(kcp);
        }
    }

    // 更新RTT
    updateRTT(timestamp) {
        const rtt = Date.now() - timestamp;

        if (this.srtt === 0) {
            this.srtt = rtt;
            this.rttvar = rtt / 2;
        } else {
            this.rttvar = (3 * this.rttvar + Math.abs(rtt - this.srtt)) / 4;
            this.srtt = (7 * this.srtt + rtt) / 8;
        }

        // RTO计算
        this.rto = this.srtt + Math.max(1, 4 * this.rttvar);
        this.rto = Math.max(30, Math.min(this.rto, 1000));  // 限制范围
    }

    // 发送更新
    update() {
        const now = Date.now();

        // 检查哪些包可以发送
        while (this.snd_buf.length < this.snd_wnd) {
            if (this.snd_queue.length === 0) break;

            const seg = this.snd_queue.shift();
            seg.timestamp = now;
            seg.resendts = now + this.rto;  // 重传时间
            this.snd_buf.push(seg);
        }

        // 检查需要重传的包
        for (const seg of this.snd_buf) {
            if (now >= seg.resendts) {
                // 超时重传
                this.transmit(seg);
                seg.resendts = now + this.rto;

                // 快速重传
                if (this.fastresend > 0) {
                    seg.fastresend++;
                }
            }
        }

        // 拥塞控制
        this.congestionControl();
    }
}

4.6 KCP拥塞控制

// KCP的拥塞控制比TCP激进

congestionControl() {
    // cwnd = 发送窗口

    if (this.nodelay) {
        // 快速模式:没有拥塞控制
        // 适合游戏:宁可丢包也不卡
        return;
    }

    // 标准TCP拥塞控制
    if (this.inflight > this.cwnd) {
        // 拥塞窗口减半
        this.cwnd = Math.floor(this.cwnd / 2);
    }
}

// 丢包后的处理
onLoss(cancelled) {
    if (this.fastresend > 0) {
        // 快速重传
        for (const seq of cancelled) {
            const seg = this.findInBuffer(seq);
            if (seg) {
                seg.fastresend = 1;
                seg.resendts = Date.now();  // 立即重传
            }
        }
    }

    if (!this.nodelay) {
        // 非快速模式下丢包减窗
        this.cwnd = Math.floor(this.cwnd / 2);
    }
}

五、游戏应用实践

5.1 KCP前端集成

class KCPConnection {
    constructor(ws) {
        this.ws = ws;
        this.kcp = new IKCP(12345, (data) => {
            // 底层发送
            this.ws.send(data);
        });

        // 配置
        this.kcp.nodelay = 1;   // 快速模式
        this.kcp.fastresend = 2; // 2次ACK触发快速重传
        this.kcp.snd_wnd = 32;
        this.kcp.rcv_wnd = 32;
    }

    // 接收服务器数据
    onMessage(data) {
        this.kcp.input(data);
    }

    // 发送游戏消息
    send(message) {
        const encoded = this.encode(message);
        this.kcp.send(encoded);
    }

    // 更新(每帧调用)
    update() {
        this.kcp.update(Date.now());

        // 收取消息
        while (true) {
            const data = this.kcp.recv();
            if (!data) break;
            this.onReceive(data);
        }
    }

    encode(message) {
        // 编码为Uint8Array
        return new TextEncoder().encode(JSON.stringify(message));
    }

    decode(data) {
        return JSON.parse(new TextDecoder().decode(data));
    }
}

5.2 多通道协议设计

好的游戏协议像一套高效的暗号系统:
- 紧凑:每个字节都要有用
- 分层:底层负责传输,上层负责业务
- 可扩展:未来加新功能不影响旧逻辑

协议分层架构:
应用层(游戏逻辑)
    |-- 技能系统协议
    |-- 聊天系统协议
    |-- 位置同步协议

消息层(可靠性保障)
    |-- 序列号管理
    |-- ACK机制
    |-- 重传与去重

传输层(WebSocket/UDP/KCP)
    |-- 连接管理
    |-- 心跳检测
    |-- 帧封装

消息类型定义:

const MsgType = {
    HEARTBEAT: 0x01,      // 心跳
    HEARTBEAT_ACK: 0x02,  // 心跳响应
    POSITION: 0x10,       // 位置同步(非可靠)
    INPUT: 0x11,          // 玩家输入(可靠)
    SKILL: 0x20,          // 技能释放(可靠)
    DAMAGE: 0x21,         // 伤害计算(可靠)
    CHAT: 0x30,           // 聊天消息(可靠,有序)
    GAME_START: 0x40,     // 游戏开始(可靠)
    GAME_OVER: 0x41,      // 游戏结束(可靠)
    SNAPSHOT: 0x50,       // 状态快照(状态同步用)
    DELTA: 0x51           // 增量更新(状态同步用)
};

自问自答

Q:为什么不用 QUIC 代替自定义 UDP? A:QUIC 确实解决了 TCP 的队头阻塞问题,但它仍然是通用协议,不支持"部分可靠、部分有序"的细粒度控制。而且 QUIC 在某些网络环境下可能被限制。自定义协议可以针对游戏场景做极致优化。

Q:Selective Repeat 比 Go-Back-N 好在哪? A:Go-Back-N 丢一个包要重传后面所有包,Selective Repeat 只重传丢的那个。在网络有 1% 丢包率、窗口大小 30 的情况下,Go-Back-N 可能重传 30 个包,Selective Repeat 只重传 1 个。

Q:FEC 的冗余开销值得吗? A:取决于场景。如果网络丢包率 1%~5%,FEC 用 10%~20% 的带宽换取不重传的延迟节省,非常值得。如果丢包率超过 FEC 的纠错能力,还是得靠 ARQ 重传。最佳方案是 FEC + ARQ 混合。

Q:KCP为什么比TCP快? A:KCP的RTO最小30ms(TCP是200ms),支持快速重传(不等超时),可选择性重传,拥塞控制可配置。这些设计让KCP在实时性要求高的场景下表现远优于TCP。


实践任务

  • 任务1:实现 Stop-and-Wait 模式的可靠 UDP,测量吞吐量
  • 任务2:实现 Selective Repeat 模式的可靠 UDP,对比 Stop-and-Wait 的吞吐量
  • 任务3:实现简单 FEC(异或冗余包),测试丢包恢复能力
  • 任务4:在模拟丢包环境下(1%/3%/5%),对比 ARQ-only、FEC-only、ARQ+FEC 的延迟
  • 任务5:设计一个多通道协议——可靠通道(交易)+ 不可靠通道(位置)+ 有序通道(指令)
  • 任务6:集成KCP到游戏网络层,对比TCP和KCP在100ms延迟+5%丢包下的表现

与其他章节的关联

本章内容 关联章节 关联点
可靠 UDP 第02章 网络同步基础 TCP/UDP 的局限性是自定义协议的动机
FEC 第03章 帧同步Lockstep 帧同步的输入包丢了可以用 FEC 恢复,避免卡顿
多通道 第04章 状态同步 位置同步走不可靠通道,交易走可靠通道
拥塞控制 3_2_cs-fundamentals TCP 拥塞控制的原理是自定义拥塞控制的基础
KCP 第13章 弱网优化与实战 KCP是弱网环境下优化延迟的核心工具
协议安全 第08章 游戏安全与反作弊 自定义协议需要自己实现加密和签名

上一章:05-预测与回滚 下一章:07-游戏系统设计实战