对战类手游的网络同步,最怕的不是"慢",而是"抖"。同一款游戏,在实验室千兆网下跑得丝滑,一到地铁、电梯、宿舍断断续续的弱网环境,技能放出去半秒没反应,走位突然回拉,玩家立刻就知道这局要输了。标题里提到的那款国民级 MOBA 手游,之所以把可靠传输做在应用层的 UDP 之上,而不是直接吃内核的 TCP,核心原因就在这儿——它需要的是一条"延迟可预期、丢包能局部补、抖动被压平"的通道。KCP 就是这条通道最典型的实现范式:一个纯用户态、几百行 C 代码、算法逻辑完全暴露给业务方的 ARQ 协议库。这篇内容不打算把 KCP 讲成一份 API 手册,而是从一个真正把它接进实时对战系统的工程视角,拆开它在协议栈里的位置、五个关键机关为什么这样设计、参数怎么配、UDP 上还缺哪几块板,以及弱网下怎么用数据证明它确实有用。适合已经懂 TCP/UDP 基础、正在做实时音视频或游戏网络同步、准备自己搭可靠层的开发者参考。
1. 为什么对战手游宁可自己造一套可靠传输
先把话说透:这不是"TCP 不行",而是"TCP 的默认取舍不适合实时对战"。TCP 的设计目标是让一串字节流不丢、不乱、不重复地送到对端,为了这个目标它愿意付出等待。而 MOBA 这类游戏要的是"当前这一帧的操作指令尽快到达",它对个别指令丢失的容忍度其实不低——丢一个技能释放指令,服务器补一个校正帧就能修复;但它对"所有指令都排队等那一个丢包"的容忍度是零。这是两种完全不同的价值排序。
1.1 手机网络里 TCP 的三个延迟放大器
第一个放大器是队头阻塞。TCP 是严格有序的字节流,第 N 个包丢了,第 N+1 到 N+k 个包即使已经躺在接收缓冲区里,应用层也读不到。在 PC 有线网络丢包率千分之几的环境里这不算事,但在移动网络下,基站切换、信号衰减、电梯井、地铁隧道,瞬时丢包率冲到 10% 以上很常见。这时候 TCP 的接收窗口会被"卡住",游戏客户端看到的就是角色瞬移、技能延迟生效。
第二个放大器是重传计时器过于保守。主流实现里最小重传超时通常不低于 200ms,很多系统默认值更接近 1 秒。也就是说一个包丢了,最坏情况下要等接近一秒才会被重发。对一局 15 分钟、每秒 30 帧状态同步的对战来说,一秒等于 30 帧的空白,体验直接崩塌。
第三个放大器藏在细节里:延迟确认和 Nagle 算法。为了减少小包数量,TCP 会攒一攒再发,也会延迟一段时间再回 ACK。这两个机制在文件传输里是优化,在实时交互里就是纯粹的人为延迟。很多团队的第一反应是关掉 Nagle(TCP_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 降到 2
TCP 的快速重传触发条件是收到 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:用内部定时器替掉延迟 ACK
interval是 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 与 peeksize
ikcp_send发送的数据如果超过 MTU,KCP 会自动按分片处理,接收端通过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 常用档位
| 场景 | nodelay | interval | resend | nc | 说明 |
|---|---|---|---|---|---|
| 实时对战(默认档) | 1 | 10 | 2 | 1 | 延迟优先,带宽消耗中等,适合大部分 4G/5G 环境 |
| 弱网兜底 | 1 | 10 | 2 | 1 | 配合上层降频,减小窗口到 64 |
| 高丢包极端场景 | 1 | 5 | 2 | 1 | interval 再压一半,包量上升明显,仅在丢包 > 20% 时考虑 |
| 大流量配置 | 0 | 40 | 2 | 1 | 关闭 nodelay,RTO 下限回到 100ms,适合批量数据 |
| 带宽受限环境 | 1 | 20 | 3 | 1 | 拉长 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/s,RTT 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%
- 一般 4G:RTT 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 倍 RTT |
| P95 延迟 | 95% 的包低于这个值 | < 2 倍 RTT |
| P99 延迟 | 尾部延迟,决定体感卡顿 | < 3 倍 RTT |
| 卡顿率 | 延迟超过 200ms 的包占比 | < 0.5% |
| 重传率 | 重传包数 / 总包数 | < 5%(弱网可放宽) |
| 带宽放大比 | KCP 实际发字节 / 有效载荷字节 | < 1.3 |
P99 和卡顿率是最关键的两个。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,并降低业务同步频率。这个保护在极端网络下是必需品,不是可选项。
还有一个不算坑但值得一提的观察:做了这么多优化之后,最终玩家侧体感提升最明显的,其实不是协议层面的改动,而是把关键指令和位置同步拆成两条通道这一个架构层面的调整。协议只是工具,用对地方才有价值。