2026/10/28 15:48:29

KCP应用层可靠传输:MOBA弱网延迟优化与工程实践

KCP应用层可靠传输:MOBA弱网延迟优化与工程实践 对战类手游的网络同步最怕的不是慢而是抖。同一款游戏在实验室千兆网下跑得丝滑一到地铁、电梯、宿舍断断续续的弱网环境技能放出去半秒没反应走位突然回拉玩家立刻就知道这局要输了。标题里提到的那款国民级 MOBA 手游之所以把可靠传输做在应用层的 UDP 之上而不是直接吃内核的 TCP核心原因就在这儿——它需要的是一条延迟可预期、丢包能局部补、抖动被压平的通道。KCP 就是这条通道最典型的实现范式一个纯用户态、几百行 C 代码、算法逻辑完全暴露给业务方的 ARQ 协议库。这篇内容不打算把 KCP 讲成一份 API 手册而是从一个真正把它接进实时对战系统的工程视角拆开它在协议栈里的位置、五个关键机关为什么这样设计、参数怎么配、UDP 上还缺哪几块板以及弱网下怎么用数据证明它确实有用。适合已经懂 TCP/UDP 基础、正在做实时音视频或游戏网络同步、准备自己搭可靠层的开发者参考。1. 为什么对战手游宁可自己造一套可靠传输先把话说透这不是TCP 不行而是TCP 的默认取舍不适合实时对战。TCP 的设计目标是让一串字节流不丢、不乱、不重复地送到对端为了这个目标它愿意付出等待。而 MOBA 这类游戏要的是当前这一帧的操作指令尽快到达它对个别指令丢失的容忍度其实不低——丢一个技能释放指令服务器补一个校正帧就能修复但它对所有指令都排队等那一个丢包的容忍度是零。这是两种完全不同的价值排序。1.1 手机网络里 TCP 的三个延迟放大器第一个放大器是队头阻塞。TCP 是严格有序的字节流第 N 个包丢了第 N1 到 Nk 个包即使已经躺在接收缓冲区里应用层也读不到。在 PC 有线网络丢包率千分之几的环境里这不算事但在移动网络下基站切换、信号衰减、电梯井、地铁隧道瞬时丢包率冲到 10% 以上很常见。这时候 TCP 的接收窗口会被卡住游戏客户端看到的就是角色瞬移、技能延迟生效。第二个放大器是重传计时器过于保守。主流实现里最小重传超时通常不低于 200ms很多系统默认值更接近 1 秒。也就是说一个包丢了最坏情况下要等接近一秒才会被重发。对一局 15 分钟、每秒 30 帧状态同步的对战来说一秒等于 30 帧的空白体验直接崩塌。第三个放大器藏在细节里延迟确认和 Nagle 算法。为了减少小包数量TCP 会攒一攒再发也会延迟一段时间再回 ACK。这两个机制在文件传输里是优化在实时交互里就是纯粹的人为延迟。很多团队的第一反应是关掉 NagleTCP_NODELAY这确实有用但延迟确认那一侧你在应用层改不了。注意说 TCP 有这些问题不等于说它有 bug。这些全是它在通用场景下的正确工程取舍只是和实时对战的目标函数不一致。搞清楚这一点才不会在方案评审时被人问一句为什么不用 TCP就哑口。1.2 KCP 站在协议栈的哪个位置KCP 的定位非常清晰它是一个跑在 UDP 之上的、用户态的可靠传输算法库。它不碰内核不改协议栈不做系统调用层面的任何事。它做的事情是——你给它一段要发出去的数据它帮你切成带序号的分片打包成 UDP 报文通过一个你提供的回调函数丢出去对端收到 UDP 报文后喂回给它它帮你重排、去重、补漏最后还原成有序的字节流交给你。这个位置决定了三件事。第一它的行为完全可控所有参数都是你写在代码里的不像内核 TCP 那样要改 sysctl 或者依赖发行版默认值。第二它的语义是可靠有序流不是可靠消息——这一点很多人一开始会踩坑后面会细说。第三它只解决了可靠两个字连接加密保活限速全都要你自己补。这也是为什么标题强调应用层你拿到的不是一条现成的通道而是一套可组装的零件。从分层上看业务逻辑下面是你的会话管理层负责连接建立、鉴权、迁移再下面是 KCP负责可靠有序再下面是加密与防重放层最底下才是 UDP socket。这个顺序不能乱加密必须在 KCP 之外做因为 KCP 的重传会重复发送同一段明文如果先加密再进 KCP重传时密文会被重新生成反而破坏了防重放的序号语义。1.3 一个常被忽略的事实KCP 不是更快的 TCP社区里流传最广的一句话是KCP 比 TCP 快 30%~40%。这句话有前提脱离前提就是误导。KCP 换来的低延迟代价是更多的带宽消耗和更差的公平性。它的重传策略更激进快速重传阈值更低还默认关掉了拥塞窗口所以同样一条链路KCP 发出的包数量可能是 TCP 的 1.1 到 1.3 倍在丢包率高的链路上这个放大倍数还会更高。所以正确的表述是KCP 用带宽换延迟用公平性换响应速度。它适合的是玩家自己家的网络带宽相对充裕但对延迟极度敏感的场景。它不适合的是整条链路上跑着几百个租户谁抢到算谁的的场景——那种情况你需要自己在上层补一套拥塞控制否则一个客户端就能把同机房的出口打满。我在实际项目里见过一个反例某团队把 KCP 用在了一个需要向大量终端推送大文件的场景结果弱网客户端的重传把服务器出口带宽吃掉了三成最后不得不回退成控制流走 KCP、数据流走 TCP的混合方案。这个教训值得记住——KCP 是给小包、高频、低延迟设计的不是给大包、低频、要吞吐设计的。2. KCP 用带宽换延迟的五个关键机关理解了定位再来看内部。KCP 的代码量不大但每一处设计都对应一个具体的延迟来源。我把其中对实时对战影响最大的五个机关拆开讲每个都说清楚它改了什么为什么这么改代价是什么。2.1 RTO从 200ms 起步压到 30ms重传超时RTO的计算公式KCP 和 TCP 用的是同一套平滑 RTT 估计RTO SRTT max(interval, 4 × RTTVAR)。区别在于下限和抖动余量。TCP 的实现里最小 RTO 通常被钳在 200ms 甚至更高而且每次超时重传会把 RTO 翻倍指数退避防止在拥塞链路上雪上加霜。KCP 则把最小 RTO 直接压到 30ms开启 nodelay 模式时并且在计算重传时刻时不再额外叠加 1/8 RTO 的抖动余量。/* 简化后的 KCP 重传时刻计算逻辑 */ if (segment-xmit 0) { segment-rto kcp-rx_rto; /* 由 RTT 估计得出 */ segment-resendts current segment-rto rtomin; /* rtomin 在 nodelay 下为 0 */ }这个改动的实际意义有多大假设链路 RTT 是 60ms一个包丢了。TCP 场景下如果快速重传没触发比如窗口里后续包不够多你就得等接近 200ms 才看到重传KCP 场景下60ms 出头的 RTO 就能触发重传。对 30Hz 的状态同步来说这中间差了四到五帧玩家能明显感觉到技能按了没反应和技能稍微慢一点的差别。代价也很直接误判重传变多。RTO 压得越低越容易把只是网络抖动导致的晚到误判成丢包从而发出实际上并不需要的重传包。所以这个参数不能无脑往下压要结合你链路的实际 RTT 分布来定。2.2 UNA 加 ACK 列表只补丢失的那一段这是 KCP 相对 TCP 最本质的改进之一。TCP 的 ACK 是累积的确认了第 100 号就意味着 100 之前全部收到。一旦 50 号丢了即使 51 到 99 都到了发送端也只能重传 50 以及后面所有的东西。KCP 的每个报文头里有两个字段una表示这个序号之前的所有包我都收到了acklist则带着一串我额外收到的、但序号大于 una 的包。发送端拿到这两个信息后能精确地知道哪几个包真的丢了只重传那几个。接收端实际收到: 1 2 3 [5] 6 7 8 (4 号丢了) KCP 回送的 ACK 段: una 4, acklist [5, 6, 7, 8] 发送端结论: 只有 4 号需要重传在一个 RTT 100ms、丢包率 15% 的模拟环境里这个差异会非常明显TCP 每次丢包都要重传丢包点之后的整个窗口KCP 只补一个包。省下来的不只是带宽更重要的是接收端的重排队列不会堆积——重排队列一旦堆积应用的读取就会被拖慢表现出来还是延迟。顺带说一个实现细节acklist是有上限的默认最多缓存若干条 ACK超出的部分会被合并或丢弃退化成纯una语义。所以极端的乱序场景下KCP 也不是完全免疫的只是比 TCP 好得多。2.3 fastack 阈值从 3 降到 2TCP 的快速重传触发条件是收到 3 个重复 ACK。KCP 把这个阈值做成了可配置项默认推荐值是 2也就是有 2 个后续包确认收到、但当前这个包还没被确认就直接重传不等超时。/* ikcp_flush 中的快速重传判断简化 */ if (segment-fastack resent) { /* resent 即 nodelay 的第三个参数 */ if (segment-xmit kcp-fastlimit || kcp-fastlimit 0) { need_resend 1; segment-fastack 0; segment-xmit; } }阈值从 3 降到 2等于把发现丢包的时间又往前推了一个 RTT 的一部分。在 MOBA 里一个技能释放指令从客户端到服务器中间可能只经过两三个包等凑够 3 个重复 ACK 早就超时了。降到 2 之后快速重传能更早介入。不过这里有个容易被忽略的保护fastlimit。如果同一个包已经被快速重传过太多次默认 5 次就不再触发快速重传避免在极端乱序下疯狂重发同一个包。我在压测时把fastlimit调成 0关闭限制结果在 30% 乱序的模拟环境下重传包占比直接冲到 40%带宽翻倍而延迟毫无改善。这个参数一般不要动。2.4 掐掉拥塞窗口把流控权交给业务TCP 有慢启动、拥塞避免、快重传快恢复一整套拥塞控制状态机。它的核心假设是丢包等于拥塞所以一丢包就把发送窗口砍半。这个假设在 1990 年代的骨干网上是对的在今天的移动网络里经常是错的——大量丢包来自无线信号质量而不是链路拥塞。这时候砍窗口等于对着一堵墙喊小声点。KCP 的nc参数no congestion window直接把这个状态机拿掉了。开启之后发送窗口就是你通过ikcp_wndsize()设置的那个固定值不会因为丢包自动缩小。/* 关闭拥塞控制发送/接收窗口都固定为 128 个分片 */ ikcp_wndsize(kcp, 128, 128); ikcp_nodelay(kcp, 1, 10, 2, 1); /* 最后一个参数 1 即 nc 1 */代价是显而易见的KCP 会把链路当成无限容量来用。如果业务侧不自己限速一个客户端就能把上行带宽打满。所以在实战里nc 1通常要配合应用层的发送速率控制一起用——比如按每秒的指令条数做令牌桶或者根据 RTT 和丢包率动态调整状态同步的频率。2.5 interval用内部定时器替掉延迟 ACKinterval是 KCP 内部的 flush 间隔单位毫秒。它的作用是把这一小段时间内攒下的数据、ACK、重传请求一起打包发出去。这个设计初衷是减少小包数量——如果每收到一个包就立刻回一个 ACK那 UDP 包的数量会翻倍。听起来和 TCP 的延迟确认很像不一样的地方在于这个间隔是你自己设的。TCP 的延迟确认通常是 40ms 或 200ms你在应用层改不了KCP 你可以设成 10ms甚至设成 1ms代价是包数量暴涨。这里有个非常关键的联动关系很多人配参数时没意识到interval会进入 RTO 的计算公式RTO SRTT max(interval, 4 × RTTVAR)。也就是说如果你把interval设成 100ms那 RTO 的下限就被这个值托住了你前面辛苦压到 30ms 的最小 RTO 实际上用不上。想要低延迟interval必须小。这也是为什么实时对战场景的推荐值普遍是 10ms。3. 三段式 API 的工程接法KCP 的公开接口就那么十几个但真正把它接进一个多人在线的服务端光看 API 文档是不够的。这一节讲讲工程上真正会让你卡住的地方。3.1 conv、user 指针与连接表ikcp_create(conv, user)里的conv是会话号它会被写进每个 KCP 报文的头部用来在对端区分这个包属于哪条连接。user是一个透传指针KCP 自己不用它只是在回调里原样还给你。工程上通常的做法是conv用一个自增的 32 位整数或者时间戳派生值服务端维护一张conv - 会话对象的哈希表user指向会话对象本身这样在output回调里不用再查表。typedef struct session_s { uint32_t conv; ikcpcb *kcp; int udp_fd; struct sockaddr_storage peer; uint64_t last_active_ms; /* ... 业务状态 ... */ } session_t; ikcpcb *kcp ikcp_create(conv, (void *)sess); ikcp_setoutput(kcp, on_kcp_output);这里有个安全上的提醒conv是明文写在报文头里的任何人抓包都能看到。所以不能把conv当作身份凭证它只用来做路由。真正的身份校验必须靠加密层里的密钥或者签名这一块在后面第 5 节还会展开。3.2 output 回调是整个设计的枢纽ikcp_setoutput注册的回调是 KCP 唯一往外吐包的出口。KCP 内部所有的发送、重传、ACK、窗口探测最终都汇聚到这一个函数里。static int on_kcp_output(const char *buf, int len, ikcpcb *kcp, void *user) { session_t *sess (session_t *)user; /* 1. 加密 加防重放序号在 KCP 之外做 */ uint8_t sealed[MAX_PKT]; int sealed_len crypto_seal(sess, (const uint8_t *)buf, len, sealed, sizeof(sealed)); if (sealed_len 0) return -1; /* 2. 通过 UDP 发出注意处理 EAGAIN 和部分发送 */ sendto(sess-udp_fd, sealed, sealed_len, 0, (struct sockaddr *)sess-peer, sess-peer_len); return 0; }这个回调里有三个坑必须提前想清楚。第一不要在这里做阻塞操作。KCP 在ikcp_flush里可能连续调用这个回调几十次窗口里有几十个包待发如果每次都同步落盘或者加锁竞争整个 flush 的耗时会被放大进而影响同一线程上其他会话。第二发送失败要当作丢包处理不要静默吞掉。UDP 的sendto返回 EAGAIN 是很正常的发送缓冲区满这时候如果你直接忽略KCP 以为发出去了实际上没发对端永远收不到只能等超时重传。正确做法是返回错误码让 KCP 感知或者自己维护一个小队列重试。第三加密之后的长度不能超过 MTU。KCP 在分片时是按自己的 MTU 参数切的默认 1400加密会额外增加头部和认证标签AEAD 通常是 16 字节标签加 12 字节 nonce。如果你的网络路径 MTU 只有 1400那 KCP 层的 MTU 必须往下调到 1300 左右否则 IP 层会分片一旦分片的任意一片丢失整包作废重传代价翻倍。3.3 update / check用哪个驱动定时KCP 自己不会起线程也不会注册定时器它需要你周期性地调用ikcp_update(kcp, current_ms)来推进内部状态机。这个调用会做三件事检查是否有包到期需要重传、把积压的数据打包发出去、更新内部的当前时间。/* 每个网络循环 tick 调一次current 是单调递增的毫秒时间 */ uint32_t now monotonic_ms(); session_t *s; for (s session_list; s; s s-next) { ikcp_update(s-kcp, now); }问题是如果你有几千个会话每 1ms 轮询一遍所有会话CPU 会被烧光。KCP 为此提供了ikcp_check(kcp, current)它返回下一次需要调用 update 的时刻。你可以据此做最小堆调度只在真正需要的时候才去驱动某个会话。uint32_t next ikcp_check(kcp, now); if ((int32_t)(now - next) 0) { ikcp_update(kcp, now); next ikcp_check(kcp, now); } /* 把 next 塞进定时器堆 */还有一个细节current必须是单调递增的毫秒时间。如果你用的是会被系统时间调整影响的gettimeofday在 NTP 校时或者用户手动改时间的时候KCP 内部的时间差计算会直接错乱表现为疯狂重传或者长时间不发包。用clock_gettime(CLOCK_MONOTONIC)或者等价的单调时钟这是硬性要求。3.4 分片、MTU 与 peeksizeikcp_send发送的数据如果超过 MTUKCP 会自动按分片处理接收端通过ikcp_peeksize拿到下一个完整消息有多大再决定开多大的缓冲区去ikcp_recv。int msg_size ikcp_peeksize(kcp); if (msg_size 0) { uint8_t *buf malloc(msg_size); int n ikcp_recv(kcp, (char *)buf, msg_size); /* n msg_size 表示一个完整消息收到了 */ }这里有个非常容易误解的地方KCP 是流式语义不是消息式语义。默认情况下你send两次 100 字节对端可能一次recv出 200 字节。分片边界只在超过 MTU 被切开这个场景下才被保留。如果你需要严格的消息边界游戏里几乎一定需要因为指令是结构化数据必须调用ikcp_setstreammode(kcp, 0)关掉流模式或者自己在消息前面加长度头。我在一个项目里就吃过这个亏早期测试都是单条短指令怎么跑都对上线后加了批量同步两条指令被合并成一次recv解析端直接把第二条当成了第一条的尾部数据出现了极难复现的技能错乱。加长度头是最省心的做法不要依赖库的边界语义。4. 参数不是玄学nodelay 四个旋钮的取值表ikcp_nodelay(kcp, nodelay, interval, resend, nc)这四个参数是 KCP 调优的全部入口。社区里流传的1, 10, 2, 1就是它的默认推荐组合但这个组合不是万能的得知道每个数字动了会发生什么。4.1 MOBA 常用档位场景nodelayintervalresendnc说明实时对战默认档11021延迟优先带宽消耗中等适合大部分 4G/5G 环境弱网兜底11021配合上层降频减小窗口到 64高丢包极端场景1521interval 再压一半包量上升明显仅在丢包 20% 时考虑大流量配置04021关闭 nodelayRTO 下限回到 100ms适合批量数据带宽受限环境12031拉长 interval、提高重传阈值来省带宽代价是延迟上升第一个参数nodelay 1的实际效果是把最小 RTO 从 100ms 降到 30ms同时把重传时刻的抖动余量rtomin置零。这一个参数对延迟的影响最大也最容易被滥用——如果你的链路本身 RTT 就有 150ms把最小 RTO 压到 30ms 毫无意义只会制造误重传。4.2 按场景切换参数的思路参数不该写死在代码里应该能按网络质量动态切换。做法是客户端持续统计最近 10 秒的 RTT、丢包率、乱序率通过一条低频的控制通道上报给服务器服务器按阈值调整该会话的 KCP 参数。判定逻辑可以很朴素丢包率低于 3% 且 RTT 稳定用默认档丢包率 3% 到 15%把窗口从 128 降到 64抑制重传放大丢包率超过 15%进一步降频——不是降 KCP 的参数而是降业务的同步频率因为这种链路下再怎么优化协议也救不回来只能减少总数据量。提示动态调参时ikcp_nodelay和ikcp_wndsize都可以在运行时反复调用不需要重建 kcp 对象。但ikcp_setmtu会重建内部缓冲区涉及内存分配不要在热路径里频繁调。4.3 窗口大小与带宽时延积窗口设多大合适这个不是拍脑袋可以算。窗口的物理意义是在途未被确认的数据量它必须能覆盖一个 RTT 内的发送量否则发送端会经常性地窗口满、停下来等 ACK吞吐被卡死。所需窗口字节数 ≈ 带宽 × RTT举个具体例子假设单连接上行带宽 200 KB/sRTT 100ms那么带宽时延积是 200 KB/s × 0.1s 20 KB。KCP 默认 MTU 1400每个分片有效载荷约 1376 字节那么 20 KB 需要约 15 个分片。默认发送窗口 32 是够的。但注意MOBA 的上行数据量其实很小——每秒几十条指令加起来可能就几 KB。这种情况下窗口根本不是瓶颈设 32 完全够用。反倒是服务端下行的状态同步数据量大得多需要更认真地算。还要提醒一个容易忽略的点接收窗口比发送窗口更容易出问题。如果接收端的rcv_wnd设得太小它会主动在报文里通告一个很小的窗口发送端就会限速。而接收端的窗口大小往往和它处理消息的速度有关——如果你的业务线程处理不过来接收缓冲区堆满窗口自然就小了。这时候看起来是网络慢实际是处理慢排查方向完全不同。5. UDP 通道上必须自己补的几块板KCP 只解决了可靠性。一条能上生产的 UDP 通道至少还缺四块板保活、加密、拥塞控制、通道分层。这几块任何一块没做好前面调的参数都白搭。5.1 心跳与地址漂移移动网络里NAT 设备的映射表是有超时时间的常见值从 30 秒到 5 分钟不等。如果这条 UDP 通道超过超时时间没有任何报文双向流动NAT 就会把映射删掉之后服务器再往这个地址发包全部石沉大海。解决办法是心跳但要打得聪明。不要用一个固定 15 秒的定时器无脑发因为空闲时打心跳是浪费电量和流量而高频状态下根本不需要额外心跳。常见的做法是空闲超时触发如果 5 秒内没有任何业务数据发出就打一个心跳包如果连续 3 个心跳都没收到回应判定连接断开走重连流程。地址漂移是另一个必须处理的问题。玩家从 WiFi 切到 4G客户端的公网出口地址变了但conv没变。服务端收到一个来自新地址、但conv已知的包这时候要做一个判断是否允许这条会话迁移到新地址。直接允许是不安全的——任何人只要猜到一个conv就能把你的会话劫持走。常见的做法是迁移必须携带有效的会话令牌加密层里的密钥派生值并且迁移后旧地址的包在短时间内仍被接受避免乱序导致的老包被误判。同时要限制迁移频率比如 10 秒内最多迁移一次防止被用来做放大攻击。/* 会话地址迁移的判定骨架 */ if (memcmp(pkt_src, sess-peer, addrlen) ! 0) { if (!crypto_verify_token(sess, pkt) || now - sess-last_migrate_ms 10000 || now - sess-last_active_ms 30000) { return DROP; /* 校验不过、太频繁、或会话已过期 */ } sess-peer pkt_src; /* 允许迁移 */ sess-last_migrate_ms now; }5.2 加密、防重放与抗伪造UDP 的报文是谁都能发、谁都能改的。攻击者只要知道了服务器的地址和端口就能往里灌垃圾包。KCP 收到垃圾包会尝试解析虽然大部分会被丢弃但解析本身要消耗 CPU而且伪造的 ACK 有可能让 KCP 误以为数据已送达导致真正的数据永远丢失。所以加密层必须放在 KCP 外面而且要解决三件事机密性、完整性、防重放。机密性和完整性用 AEAD 类算法比如 ChaCha20-Poly1305一次搞定关键在于 nonce 的构造。nonce 千万不能用随机数因为 UDP 是无序的接收端没法靠递增来校验。推荐的做法是每条连接有一个 64 位单调递增的包序号nonce 由连接标识 包序号派生接收端维护一个滑动窗口序号在窗口内且未出现过的包才接受。/* 接收侧防重放窗口简化版 */ typedef struct { uint64_t max_seq; uint64_t bitmap; /* 记录最近 64 个序号的出现情况 */ } replay_window_t; int replay_check(replay_window_t *w, uint64_t seq) { if (seq w-max_seq) return 1; /* 新包接受 */ if (w-max_seq - seq 64) return 0; /* 太旧拒绝 */ uint64_t bit 1ULL (w-max_seq - seq); if (w-bitmap bit) return 0; /* 重复拒绝 */ return 1; }这个滑动窗口的设计和 IPsec 的防重放是同一个思路非常成熟。要注意的是窗口宽度不能太小——太小会把正常的乱序包当重放丢掉太小会拖慢恢复。64 到 1024 是比较常见的取值具体看你的乱序程度。5.3 拥塞控制的空白怎么填前面说了KCP 默认不做拥塞控制。这在玩家自己家网络的场景下没问题但在服务端下行方向上一个会话可能同时要给几百个玩家推状态不控制就是灾难。填充这块空白有两个层次的方案。轻量方案是在业务层做速率限制给每条连接一个令牌桶桶容量按正常状态同步速率 × 2设置超出的部分要么丢弃要么降级比如降低该玩家的同步频率。这个方案实现简单能挡住绝大部分问题。重量级方案是在 KCP 外面套一层类似 BBR 的带宽估计持续采样发送速率和 RTT 的变化估算链路的瓶颈带宽据此调整发送窗口。这个方案效果更好但实现复杂而且需要足够长的采样窗口对短连接不友好。我的建议是——先上轻量方案等到确实观测到带宽挤占问题再考虑重量级方案不要一上来就搞复杂的。还有一个非常实用的土办法分层限速。给不同优先级的消息打标签技能释放、移动指令走高速通道不限速聊天、成就播报、商城刷新走低速通道每分钟限几条。这样即使总带宽被打满关键指令也基本不受影响。5.4 双通道可靠指令流与不可靠状态流这是 MOBA 网络架构里最关键的一个设计决策也是应用层 KCP这个说法最有价值的地方。游戏里的数据其实分两类。一类是不能丢的技能释放、购买装备、技能升级这些丢一条就影响战局必须可靠送达。另一类是丢了也无所谓、但必须新鲜的玩家位置、朝向、血量显示这些每秒产生几十条丢一条下一个同步帧就补上了但如果为了补一条旧的位置数据而阻塞新数据反而是灾难。把这两类数据塞进同一条 KCP 流里就等于让位置同步去等技能指令的重传这是自找麻烦。正确的做法是两条通道并行通道承载内容传输方式丢包处理可靠通道技能、购买、加入/退出、关键事件KCP开启重传必须补允许延迟不可靠通道位置、朝向、动画状态、瞬时血量裸 UDP 或 KCP 的免重传模式直接丢弃靠下一帧覆盖不可靠通道的实现有两种路子。一是干脆不用 KCP直接裸 UDP 发包包序号自己算接收端按序号丢弃过期包。二是复用 KCP 但对这类消息设置一个很短的存活时间——不过 KCP 本身没有消息过期的语义需要你自己在消息头里带时间戳接收端判断超时就丢弃。第一种更简单直接我一般推荐第一种。双通道的一个直接好处是可靠通道可以跑得更保守。因为它不用再背位置同步的流量窗口可以设小重传可以更稳不用担心重传把关键指令挤掉。两条通道各管各的互不干扰。6. 弱网验证怎么证明它真的比 TCP 快方案定下来最怕的是看起来很美、实测没差别。这一节讲怎么设计验证以及我踩过的三个坑。6.1 网络模拟参数怎么设本地测试一定要做网络模拟否则你测的是回环网卡KCP 和 TCP 都一样快。模拟要覆盖三个维度延迟、丢包、乱序。推荐至少跑四组良好网络RTT 40ms丢包 0.1%乱序 0.5%一般 4GRTT 80ms丢包 3%乱序 2%弱网RTT 150ms丢包 10%乱序 5%极端弱网RTT 300ms丢包 20%乱序 10%乱序这个维度最容易被漏掉但它恰恰是 KCP 和 TCP 差距最大的地方。TCP 在乱序下会误判丢包触发不必要的快速重传窗口被打小KCP 的选择性 ACK 能准确识别乱序不会被误导。模拟工具用什么不重要重要的是要在真实的移动网络上抽检一遍。实验室模拟出来的丢包是均匀随机的真实移动网络的丢包是突发性的——进电梯时连续丢 200ms出电梯后又恢复正常。突发丢包对 KCP 的考验比均匀丢包大得多因为它一次会丢一串连续的包选择重传的优势会被削弱。6.2 该看哪些指标而不是平均延迟平均延迟是最没用的指标。一局游戏里 99% 的包延迟都很低1% 的包延迟爆炸平均值看起来还是很漂亮但玩家感受到的就是那 1%。真正要看的是分位数和尾部指标指标含义关注阈值P50 延迟中位数代表正常情况 1.2 倍 RTTP95 延迟95% 的包低于这个值 2 倍 RTTP99 延迟尾部延迟决定体感卡顿 3 倍 RTT卡顿率延迟超过 200ms 的包占比 0.5%重传率重传包数 / 总包数 5%弱网可放宽带宽放大比KCP 实际发字节 / 有效载荷字节 1.3P99 和卡顿率是最关键的两个。KCP 相对 TCP 的优势主要体现在 P99 从接近超时时间降到接近两个 RTT——这个改善在平均值上看不出来在 P99 上非常明显。还有一个业务侧指标要盯指令生效延迟也就是从玩家点击技能到客户端看到技能动画开始播放的时间。这个指标把网络、服务器处理、帧同步全串起来了是最贴近玩家体感的数字。我一般会把它和网络层的 P99 放在一张图上看如果网络层 P99 很好但指令生效延迟很差说明瓶颈在别处不要在协议上继续折腾。6.3 三个真实踩过的坑第一个坑update 的时间精度不够。早期我用的是一个 20ms 精度的定时器去驱动所有会话的ikcp_update结果interval设成 10ms 完全没有意义因为驱动频率根本到不了。表现是延迟统计很好但实际体感比预期差一截。改成按ikcp_check返回的时刻精确调度之后P99 直接降了 30ms。教训是参数设了不等于生效要验证驱动的实际频率。第二个坑多线程调用没有加锁。KCP 的内部状态不是线程安全的。我见过一个实现接收线程调ikcp_input业务线程调ikcp_send定时器线程调ikcp_update三条路径并发修改同一份状态问题在低负载时完全看不出来一上压力就出现随机的序号错乱和内存越界。最稳的做法是每个会话绑定一个固定的线程所有 KCP 调用都在这一个线程上完成用消息队列把跨线程的请求投递过去而不是加锁。加锁虽然能保证正确性但会把 flush 的耗时拉长反而影响其他会话。第三个坑重传放大没做熔断。有一次压测模拟 40% 丢包结果服务器出口带宽被打到 100%所有会话一起卡死。原因是 KCP 在这种丢包率下疯狂重传而nc 1又不做拥塞控制形成正反馈。后来加了一个熔断机制单会话每秒发出的字节数超过阈值的 3 倍就强制把它的发送窗口降到 8并降低业务同步频率。这个保护在极端网络下是必需品不是可选项。还有一个不算坑但值得一提的观察做了这么多优化之后最终玩家侧体感提升最明显的其实不是协议层面的改动而是把关键指令和位置同步拆成两条通道这一个架构层面的调整。协议只是工具用对地方才有价值。