# 自定义 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-游戏系统设计实战