笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 接入与通信

    • 游戏基础架构与工具
    • 接入网关(长连接接入层)
    • 消息总线(共享内存 IPC)
    • 帧同步(Lockstep)
    • 全栈极限低延迟(物理层→应用层)
    • KCP 与 QUIC
  • 网络与服务网格

    • CNI 与 K8s 网络插件
    • 容器运行时:Docker 与 containerd
    • Istio 与 Cilium 服务网格
    • 服务网格:中心化 vs 去中心化
    • eBPF 原理与在网络/可观测/安全的落地
    • 自研 Mesh 服务网格 × K8s 部署
    • 自研 Mesh × K8s · 数据面深水区
    • Tco 协程框架 · 运行时深水区
    • K8s 异构 & 网络插件
  • 有状态服务

    • 分布式游戏存储(分布式 KV)
    • 有状态服务的数据迁移
    • 有状态服务的数据恢复与容灾
    • 有状态服务的 K8s 编排层治理(防漂移 + 节点崩溃恢复)
    • 内存配置热刷新与更新机制
  • 算法与协程

    • 一致性哈希四算法实现(Ring Hash / Ketama / Maglev / Jump Hash)
    • 蓄水池抽样(Reservoir Sampling)与加权扩展
    • C++ 协程:有栈 vs 无栈 vs C++20 原生(函数染色与 libco)
    • Raft 与 Gossip:CP 强一致共识 vs AP 最终一致传播
  • 流量与承载

    • 游戏秒杀场景承载
    • 令牌桶与漏桶
    • 限流与熔断
    • 限流:互联网 vs 游戏
    • 灰度发布 · Canary / BlueGreen / A-B / Shadow
    • 服务器日常系统开发与维护 SOP
    • 极限发压工具设计(多长连接 + 发压任务)
    • 性能分析与优化方法论(六条排查线)
  • 编译与排查

    • 编译优化与 LLVM/Clang/GCC
    • Sanitizer 工具链与内存泄漏定位

KCP 与 QUIC

TCP 是为吞吐量设计的,它的拥塞控制与重传策略在丢包时会大幅增加延迟。游戏行业为此走出了两条路:KCP(用户态 ARQ 库,以带宽换延迟)和 QUIC(IETF 标准化 L4 协议,彻底重构传输层)。本文讲清两者在协议栈中的位置、各自解决的问题、核心机制、优劣势,以及游戏场景下如何选型。

一句话结论

KCP 是 UDP 上的用户态 ARQ 库,以带宽换延迟;QUIC 是 UDP 上的标准化 L4 协议,消灭队头阻塞、握手延迟与连接中断。

场景问题

打个比方:TCP 像快递公司的挂号信——签收、丢件重发全套包办,你不用操心,但规则是快递公司定的:他规定「等 200ms 没签收才重发」,你嫌慢也改不了。UDP 像把信直接扔进邮筒——扔了就不管,丢了不通知你。KCP 是你自己雇个跑腿小哥:底层还是走 UDP 这个邮筒,但你在外面自己套一层——自己给每封信编号、自己记谁签收了、自己决定「10ms 没回音就重发」。规则全在你手里,代价是这套逻辑得你自己养。QUIC 则是一家新成立的快递公司:也走邮筒(UDP),但服务规则重新设计过(不再一件卡住全部、换地址不断单),而且有国家标准(RFC)背书,别家也能照着做。

实时对战游戏(FPS、MOBA)对延迟极度敏感:一个 200ms 的重传等待就能让玩家感知到"卡顿"。TCP 在这里有三个根本性问题:

  1. 队头阻塞(HOL Blocking):TCP 是字节流,一个包丢失,后续所有数据必须等待重传,即使后续数据已经到达内核缓冲区。
  2. 拥塞控制牺牲实时性:TCP 的 RTO 最小值通常 200ms,慢启动与拥塞避免在弱网下会大幅降低发送速率,延迟飙升。
  3. 连接绑定四元组:TCP 连接由 (src_ip, src_port, dst_ip, dst_port) 唯一标识,手机从 WiFi 切换到 4G 后 IP 变化,连接立即断开。

KCP 和 QUIC 分别从不同角度解决这些问题。

为什么会有重传:丢包是分组交换网的常态

打个比方:寄一百件快递,路上总会丢几件——不是快递公司故意的,是路就这么宽。分拣中心(路由器)门口只放得下 50 个包裹,第 51 个到了没地方摆,只能扔掉。而且没人会打电话通知你「第 51 件被扔了」,你只能靠「收货人一直没签收」自己发现。重传就是你自己发现、自己再寄一遍。

包会在哪些地方丢

丢包原因主要出现在是不是「拥塞」重传管不管用
路由器队列满:尾丢弃(tail drop)或 AQM 提前丢有线骨干、任何瓶颈链路是管用,但必须同时降速
无线信道误码,CRC 校验失败整帧丢4G/5G/WiFi不是管用,且本来就不该降速
网卡 ring buffer / socket 缓冲溢出端侧突发流量算端侧拥塞管用,配合流控
IP 分片中任一片丢,整包报废UDP 包超 MTU不是管用,但等于人为放大丢包率
路由抖动瞬间的黑洞、TTL 归零路由收敛期不是管用(下次走新路径)
中间设备策略性丢弃(QoS 限速、UDP 降优先级)运营商、企业网不是未必,越重传越被限
校验和错误,接收方静默丢弃任何链路不是管用

一句关键

丢包不等于拥塞。 TCP 把所有丢包都当拥塞信号,所以在无线网络里会因为信道误码白白降速——这正是 BBR(改用带宽/RTT 拐点)与 KCP(干脆可关拥塞控制)都在绕开的同一个误判。

IP 只承诺「尽力而为」,所以必须有人兜底

IP 层的契约只有一句:尽力送,不保证送到、不保证顺序、不保证不重复。它连「丢了」都不通知你(ICMP 只覆盖极少数情况,且在公网上常被过滤)。既然网络不兜底,可靠性只能由端点自己补,而补的办法只有两条:

路线做法代价什么时候付恢复延迟
ARQ(重传)发现丢了再补一份只在丢包时付出至少一个 RTT
FEC(前向纠错)预先多发冗余包,收方自己算回来不丢也一直付带宽0(不用往返)

TCP 选 ARQ,因为 1980 年代带宽极贵而丢包率不高,「按需付费」明显划算。KCP 上 Reed-Solomon FEC、QUIC 社区反复讨论 FEC,都是因为这笔账今天反过来了:带宽便宜,而那一个 RTT 是最贵的东西。

发送方怎么知道「丢了」

网络不通知,所以只有两条路——要么等不到确认(超时),要么发现自己被跳过了(重复确认):

两条路的性格完全不同:

快速重传(重复 ACK / fastack)超时重传(RTO)
触发条件后面的包已经到了,说明它被跳过什么都没回来
快不快快,约 1 个 RTT慢,至少一个 RTO
什么时候用不上尾包丢失——后面没有包了,凑不出重复 ACK总能兜底,它是最后防线
对 cwnd 的处理减半,还留着(快速恢复)打回 1,重回慢启动
误判代价包只是乱序晚到 → 白发一份(伪重传)网络只是抖了一下 → 白发 + 白降速

尾包丢失是最贵的一种丢包

恰好是「这一批最后一个包」丢了,就没有后续包来产生重复 ACK,只能干等一个完整 RTO。游戏指令、RPC 这类小批量请求-响应流量,几乎每次交互的最后一个包都踩在这个坑上。现代 Linux 用 TLP(Tail Loss Probe,RFC 8985 RACK-TLP)补救:约 2×SRTT 后主动发一个探测包,把「只能等 RTO」重新变回「能走快速重传」。所以「TCP 尾包必等 200ms」是旧默认下的结论,新内核已缓解,但仍慢于 KCP 的低 RTO。

为什么 RTO 有个下限,而且下限还挺大

RTO 太小会伪重传(spurious retransmission):网络只是抖了一下、ACK 晚到了几毫秒,你已经重发过了——白花带宽,还白白把 cwnd 砍掉一半。RTO 太大则丢包恢复慢。所以要设下限:

值理由
RFC 6298 建议最小 RTO 1 秒保守到极致,绝不误判
Linux 实际(TCP_RTO_MIN)200ms兼顾数据中心与公网的折中,写在内核里基本改不动
KCP 普通模式(IKCP_RTO_MIN)100ms可配
KCP nodelay 模式(IKCP_RTO_NDL)30ms赌「宁可白发也不等」

下限什么时候才真正咬人

RTO 由 Jacobson 公式算出(srtt + 4×rttvar)。RTT 60ms、抖动 5ms 时算出来约 80ms——TCP 会被下限强行抬到 200ms,KCP 就用 80ms。RTT 越小,这个下限造成的浪费越大,而手游、同城机房恰好都是小 RTT 场景。

重传自带三个副作用

  1. 延迟下限就是一个 RTT:重传最快也得「发现丢了 → 重发 → 再飞一趟」。所以想再降延迟,只能不重传(FEC)或不等发现(提前多发)。
  2. 重传歧义(retransmission ambiguity):收到 ACK 时分不清它确认的是原包还是重传包,RTT 采样会被污染。Karn 算法的解法是:重传过的包不参与 RTT 采样,RTO 只靠退避维持——KCP 的 ikcp_update_ack 同样遵循这条。
  3. 重传本身在加剧拥塞:链路已经堵了,你还往里塞副本。这一条直接引出下一节。

为什么必须有拥塞控制

不做会怎样:1986 年那个正反馈环

真实数字:1986 年 NSFNET 主干从 32 kbps 掉到 40 bps——链路没坏,是所有人都在重传。链路利用率接近满,但跑的全是重复副本,有效吞吐几乎归零。这就是 拥塞崩溃(congestion collapse),也是 1988 年 Van Jacobson 那篇论文的直接动机。

关键区分:throughput ≠ goodput

崩溃时链路不空闲,它很忙——忙着搬运重复的包。所以「带宽跑满」不等于「事情在推进」。拥塞控制保护的正是 goodput,不是链路利用率。

knee 与 cliff:为什么必须停在第一个拐点

 goodput ↑
         │      knee            cliff
         │        ↓               ↓
         │   ─────●───────────────●
         │  ╱                      ╲
         │ ╱                        ╲___ 崩溃
         └──────────────────────────────→ 负载
    延迟 ↑
         │                        ╱│
         │                    ╱    │ 排队 + 重传叠加,暴涨
         │   ───────────●╱         │
         └──────────────────────────────→ 负载
                     knee
位置状态谁工作在这里
knee(膝点)吞吐刚好打满,队列还没堆起来,延迟仍是物理下限BBR 瞄准这里(在途量 = BDP)
knee 与 cliff 之间吞吐不再涨,多出来的全变排队延迟CUBIC/Reno 稳态就在这段(它必须等丢包才知道退)
cliff(崖点)队列溢出、大量丢包、重传副本涌入,goodput 塌方没有拥塞控制时的终点

拥塞控制的全部工作就是:在不知道瓶颈在哪、也没人告诉你的前提下,把负载稳在 knee 附近,绝不走到 cliff。

三个非它不可的理由

理由没有它会怎样
① 瓶颈带宽未知网络不会告诉你这条路能跑多快,也不会随时更新(换基站就变了),只能靠端点自己试探
② 正反馈会崩溃丢包 → 重传 → 更堵 → 更多丢包,goodput 归零(1986 实证)
③ 多流之间要分配没有中心分配器,只有大家跑同一套规则才谈得上公平;一条流不守规矩就能吃掉别人的份额

拥塞控制与流量控制解决的是完全不同的问题

流量控制(rwnd)是你和对端两个人之间的事:别把对方的缓冲区打爆——对方会在 ACK 里明确告诉你还剩多少。拥塞控制(cwnd)是你和一路上所有陌生人之间的事:别把公共道路堵死——没有任何人会告诉你该发多快。一个有人报数,一个只能靠猜,这是两者所有差异的根源。

为什么不能交给路由器管

最自然的想法是「让路由器告诉每条流该发多快」,但办不到:

障碍说明
端到端原则网络核心保持简单、无状态,复杂性放到端点,这是互联网能长这么大的结构前提
状态爆炸骨干路由器同时承载数百万条流,为每条流保存速率状态与做公平调度,转发面根本吃不下
路径不唯一一条流沿途要经过多个自治域的设备,谁说话算数?谁负责一致?
信任问题端点也不能盲信中间设备的指令,会被伪造

折中方案是路由器只给极少的提示,决定权仍在端点:

  • RED / AQM(如 CoDel、FQ-CoDel):队列刚开始堆积就提前随机丢一个包,让发送方早点收到信号,而不是等队列满了集体丢(避免全局同步)。
  • ECN(RFC 3168):路由器不丢包,只在 IP 头打一个 CE 标记(一个 bit)说「我快堵了」,接收方回传给发送方。用一个 bit 替代「丢一个包」这种昂贵的报警方式——DCTCP、BBRv2/v3 都用它。

AIMD 为什么会收敛到公平

AIMD = Additive Increase, Multiplicative Decrease:没堵时每 RTT +1 MSS(加性增),丢包时 ×0.5(乘性减)。它被选中有数学理由(Chiu & Jain, 1989),不是工程直觉:

策略差距会怎么变
加性增 + 乘性减(AIMD)乘性减按比例砍——占得多的那条被砍掉的绝对量更大;加性增又给两条加同样多。差距每轮被压缩,收敛到均分
加性增 + 加性减两条都加同样、减同样,初始差距永远保持
乘性增 + 乘性减比例始终不变,不收敛

举例:两条流分别占 80 / 20。丢包各 ×0.5 → 40 / 10(差距 60 → 30);各 +10 → 50 / 20(差距 30);再来一轮 → 25/10 → 35/20(差距 15)。差距被不断折半。

AIMD 的公平只在「同 RTT + 同算法」下成立

RTT 短的流每单位时间加得更频繁,天然抢得更多(RTT 不公平,跨国流天生吃亏)。算法不同也不公平(BBR vs CUBIC)。而 KCP nc=1 干脆退出这个游戏——它不退让,规则是给守规矩的人用的,这就是为什么它在可控网络里是收益、在公网大规模部署就是挤占。

TCP 的两个窗口:rwnd 与 cwnd

打个比方:往一个仓库送货,有两个「路上最多能同时跑几车」的限制。rwnd 是收货方喊的:「我卸货区只放得下 10 车,别再多了」——超了他只能把货扔地上(丢包)。cwnd 是发货方自己猜的:「这条路我估摸着同时跑 8 车不堵」——没人告诉他路况,他只能试探着加,一撞上堵车(丢包)就大幅往回缩。真正能发多少,取两者里更小的那个。

rwnd(接收窗口,Flow Control)cwnd(拥塞窗口,Congestion Control)
谁维护接收方发送方自己
怎么知道ACK 头 16 位 Window 字段明确告知(大窗口靠 Window Scale 选项放大)没人告知,只能靠丢包/延迟信号反推
保护谁接收方的内核接收缓冲区中间链路与路由器队列(即「别人」)
失控后果对端缓冲溢出,数据被丢排队延迟暴涨、丢包放大,极端情况拥塞崩溃
常见单位字节按 MSS 计

有效发送窗口 = min(cwnd, rwnd),在途未确认字节数(in-flight)不得超过它。

理解一切的那把钥匙

TCP 不直接控速率,它控「在途量」;速率 ≈ 窗口 / RTT,是副产品。 所以同一个窗口,RTT 翻倍速率就腰斩——这也是为什么跨国长链路上 TCP 天生吃不满带宽。

带宽时延积 BDP = 带宽 × RTT,是「这条路physically能装多少货」:

  • 窗口 小于 BDP:链路吃不满,带宽白扔。
  • 窗口 大于 BDP:多出来的那部分全部变成路由器队列里的排队延迟,一点吞吐都换不到。

举例:100 Mbps × 30 ms RTT = 375 KB。想跑满得有 375 KB 在途;若把窗口开到 1 MB,多出的约 650 KB 就堆在缓冲区里排队,纯排队时间约 50 ms——这就是 bufferbloat(缓冲区膨胀):设备厂商为了「少丢包」把缓冲区做大,结果换来的是延迟。

cwnd 的演进(一条主线:拿什么当拥塞信号):

阶段做法拥塞信号
慢启动cwnd 从 1~10 MSS 起,每 RTT 翻倍— (指数试探)
拥塞避免过 ssthresh 后每 RTT 只 +1 MSS— (线性小心加)
Reno(1990)3 个重复 ACK → 快速重传 + cwnd 减半;RTO 超时 → cwnd 打回 1、重回慢启动丢包
CUBIC(2006,Linux 默认)三次函数增长,长肥管道下更快回到丢包前窗口丢包
BBR(2016)不看丢包,改测「瓶颈带宽 + 最小 RTT」,主动不填满缓冲区带宽/RTT 拐点

为什么 RTT 比丢包是更好的信号

丢包信号有个致命的顺序问题:队列必须先满,才会丢包。所以丢包发生时,延迟早就涨上去了——你是在延迟已经坏掉之后才收到通知。

RTT 涨得更早:缓冲区开始堆积的那一刻,排队时延立刻体现在 RTT 上,队列还没满、还没丢包。

丢包信号RTT / 带宽信号
何时报警队列满了之后队列开始堆积就报
报警时延迟状况已经坏了还没坏
在大缓冲设备上报警极晚(bufferbloat 越严重越晚)不受缓冲大小影响
信号类型二值(丢了/没丢),只能砍半连续(堆了多少),可微调
误报来源无线随机丢包被误判成拥塞路由抖动、ACK 聚合

无线场景还有第二层收益:4G/WiFi 的丢包大量来自信道误码而非拥塞。CUBIC 会把它当拥塞、白降速;而 RTT 没涨就说明没堵,本来就不该降。

代价:RTT 信号「竞争力弱」

一看 RTT 涨就退让,旁边跑 CUBIC 的流不退,带宽就被它吃走。这是「延迟友好」与「抢带宽」的直接冲突,也是所有延迟敏感型拥塞控制的共同软肋。

BBR 到底做了什么

BBR = Bottleneck Bandwidth and Round-trip propagation time,名字就是它测的两个量。

核心洞察来自 Kleinrock(1979)的最优工作点——链路有两个拐点:

   吞吐量 ↑
          │      ┌────────────  带宽打满,再加只涨队列
          │     /
          │    /
          └───┴───────────────→ 在途数据量
              ↑ BDP(最优点)
     RTT  ↑
          │              ╱  排队延迟线性上升
          │             ╱
          └────────────┴─────→ 在途数据量
                     BDP
在途量结果
< BDP带宽没吃满,RTT 保持最小
= BDP带宽满且 RTT 仍最小 —— 最优点
> BDP带宽不再涨,多出来的纯变排队延迟
>> BDP + 缓冲大小才开始丢包

CUBIC 一路加到最后那一档才知道退,所以它稳态就工作在「缓冲区被填满」的状态。BBR 直接瞄准 BDP 那个点。

它怎么测:

量怎么得到为什么不能同时测
BtlBw 瓶颈带宽最近约 10 个 RTT 窗口内交付速率的最大值测带宽要加压,加压就推高 RTT
RTprop 传播时延最近约 10 秒内 RTT 的最小值测最小 RTT 要清空队列,清空就没跑满带宽

两者物理上无法同时观测,所以 BBR 交替探测,各用自己窗口内的极值来估计。目标在途量 = BtlBw × RTprop,即 BDP。

四个状态:

状态做什么
STARTUP每 RTT 速率 ×2/ln2(≈2.89 倍)快速探带宽;连续 3 轮涨不动即判定满了
DRAIN降速,排掉 STARTUP 期间堆出来的队列
PROBE_BW稳态。8 个 RTT 一轮,增益循环 [1.25, 0.75, 1, 1, 1, 1, 1, 1]——1.25 试探有没有更多带宽,紧跟 0.75 把试探造的队列排掉
PROBE_RTT每 10 秒,若期间没刷新过最小 RTT,把在途量压到 4 个包约 200ms,强行清空队列重测 RTprop

两个和 CUBIC 最不一样的动作:

  1. pacing,不靠窗口挤:BBR 按 BtlBw 均匀铺开发包间隔;CUBIC 把整窗数据一次性打出去(突发),突发本身就在制造瞬时队列。
  2. 丢包不再自动等于降窗:BBR 只在窗口内交付速率真的掉下来时才下调 BtlBw,随机丢包不影响它的带宽估计。

结果:稳态在途量 ≈ BDP,队列接近空,RTT 接近物理下限、丢包率接近 0,同时带宽吃满——这是 CUBIC 结构上做不到的。

BBR 的三个坑

  • vs CUBIC 不公平:深缓冲共享瓶颈上 BBRv1 挤压 CUBIC;浅缓冲上 BBRv1 又可能自己吃亏。
  • BBRv1 多流抢队列:多条 BBR 流共存时 BtlBw 估计会偏高(各自都以为能拿满),持续制造队列与丢包。BBRv2/v3 为此引入显式 inflight 上限与丢包/ECN 响应。
  • PROBE_RTT 有代价:每 10 秒一次约 200ms 降速。批量传输无感,但对延迟极端敏感的场景是个周期性小坑。

和 KCP 的关系——方向相反的两个答案:

KCP nc=1BBR
对拥塞的态度拒绝退让,成本转给别人主动少占,不填缓冲
降延迟靠多发冗余包,不等重传不排队,队列时延接近 0
公网友好度差(挤占)好(BBR 之间;对 CUBIC 仍有争议)

两者可叠加:KCP 砍掉「重传等待」那部分延迟,BBR 砍掉「排队」那部分延迟。QUIC 栈(quiche、msquic)默认就带 BBR 或 CUBIC 可选,这是「QUIC + BBR」成为移动端弱网标配组合的原因。

TCP 为什么这么设计,它到底保障什么

历史起点是一次真实事故:1986 年 NSFNET 主干拥塞崩溃,链路吞吐量从 32 kbps 崩到 40 bps——大家超时就重传,重传又加剧拥塞,形成正反馈把网络灌死。1988 年 Van Jacobson 的《Congestion Avoidance and Control》定下了慢启动 + cwnd + AIMD 这套至今仍在用的骨架。

关键约束是端到端原则:路由器不为每条流保存状态,也不会告诉你该发多快。既然网络不管,就只能由端点自我约束;而 AIMD(加性增、乘性减)被证明能让多条竞争流自然收敛到公平份额。

TCP 保障的东西靠哪个机制
网络不被灌崩cwnd + 慢启动 + AIMD
多条流公平分带宽AIMD 的收敛性
接收方不被淹rwnd
字节流不丢、不乱序、不重复序号 + 累积 ACK + 重传

这四条里没有一条是「延迟」

TCP 的每个核心机制都在保护网络和正确性。延迟是结果,从来不是目标。所以「TCP 延迟高」不是实现 bug,是它的设计需求里根本没写这一条。

一句话记住差别

一句话
rwnd收货方喊「我放不下了」——保护对端缓冲区
cwnd发货方自己猜路况——保护中间网络和别人
真实窗口取两者最小值;速率 = 窗口 / RTT,窗口超过 BDP 只换来排队延迟

实现方案

先搞清 ARQ 是什么

ARQ = Automatic Repeat reQuest(自动重传请求),是「丢包自动重发」这套机制的统称,由三件东西组成:

  1. 序号——每个包编号,才知道谁是谁、谁丢了
  2. 确认(ACK)——收到了回一声
  3. 超时重传——等不到 ACK 就重发

TCP 内置了 ARQ,所以用 TCP 不用管丢包;UDP 没有,发出去就不管,丢了也不通知。KCP 做的事就是在 UDP 之上补一套 ARQ 回来。

「用户态 ARQ 库」三个词分开看:

词含义
ARQ实现的就是「序号 + ACK + 重传」那套可靠传输机制
用户态不在操作系统内核里,是链接进你自己进程的代码(约 1000 行 C)
库不是协议规范,是一份能直接编译进程序的源码

同样都是 ARQ,位置不同带来的差别:

TCP 的 ARQKCP 的 ARQ
代码在哪操作系统内核你的应用进程里
参数可调性大部分改不了(RTO 最小 200ms 基本写死)全部可调
谁维护/升级内核开发者,跟着系统版本走自己维护,改完重新编译即可
跨平台一致性各系统内核实现有差异同一份代码,各平台行为一致

为什么说 KCP「以带宽换延迟」

关键前提:一次重传至少要花一个 RTT。所以想降延迟,唯一的办法是别等——宁可多发几个可能白发的包,也不干等着确认「到底丢没丢」。

KCP 的机制几乎条条都在做这一件事,每一条都在多花带宽换「不等」:

机制TCP 的做法KCP 的做法多花的带宽从哪来
判丢包等 3 个重复 ACK等 2 个(resend=2)包只是晚到并没丢,你已经重发了 → 白发一份
超时时长最小 RTO 200ms最小 30ms(nodelay 模式)网络抖一下就触发重传 → 白发
超时退避超时后 RTO 翻倍只 ×1.5退避不够狠,等得不够久就再发 → 白发
攒小包Nagle 攒够再发立刻发包头占比升高(游戏包可能才 20 字节,UDP+IP 头就 28 字节)
拥塞控制检测到拥塞就降速可关闭(nc=1)不降速,继续按原速率灌

这几条加起来,带宽通常比 TCP 多 10%~20%(业界经验量级),换来的是丢包时延迟从「200ms 起跳」降到「几十 ms」。

打个比方:你要在信号断断续续的电话里跟朋友交代一件要紧事。TCP 的策略:说一遍,等他回「听到了」;没回就等 200ms 再说一遍——省口水,但他没听清那次你就白等 200ms。KCP 的策略:要紧的话连说三遍,10ms 没回音立刻再说——费口水(带宽),但基本不会漏,漏了也马上补上。

反直觉但常考:KCP 不是「更快」,是「更稳更低延迟」

KCP 的吞吐量往往低于 TCP(带宽被冗余重传吃掉了一部分),它换到的只是更低、更稳定的端到端延迟。面试里把「吞吐量高」和「延迟低」这两件事分清是加分项——它们经常是此消彼长的。

这个取舍什么时候划算:

场景划不划算原因
游戏实时对战✅ 划算包很小(几十字节),多 20% 也就几 KB/s;延迟 200ms→30ms 是玩家能直接感知的体验差
传大文件 / 看视频❌ 不划算多 20% 带宽是实打实的成本;延迟高点无感(本来就有缓冲)

协议层次定位

应用层 (HTTP/3, 游戏逻辑)
        │
        ├── KCP(用户态 ARQ 库,非标准化,"L4.5")
        │         │
        └── QUIC(IETF RFC 9000,标准化 L4 传输协议)
                  │
              UDP(L4,内核)
                  │
              IP(L3)

KCP:不是内核协议,是一个用户态库(C 语言,~1000 行,skywind3000/kcp)。它在 UDP socket 之上实现 ARQ(自动重传请求),提供可靠有序传输,但所有逻辑都在应用进程里跑。没有 IETF RFC,协议细节以源码为准。

QUIC:IETF 标准化的 L4 传输层协议(RFC 9000,2021),运行在 UDP 之上,集成 TLS 1.3,HTTP/3(RFC 9114)在其上运行。注意区分:

  • gQUIC:Google 2012 年的私有版本,已废弃,与 IETF QUIC 不兼容。
  • IETF QUIC(RFC 9000):现行标准,本文所指。

KCP:用户态 ARQ

解决的问题

TCP 的重传与拥塞控制以网络友好性(吞吐量)为优先,代价是延迟不可控。KCP 的设计哲学是:以带宽换延迟——多发一些包,换取更低、更稳定的端到端延迟。

核心机制(六项对 ARQ 的改造)

① 无 Nagle 算法

TCP 默认开启 Nagle:小包等凑到一定大小再发,减少包数量。KCP 默认关闭,数据立即发送,消除 Nagle 引入的最多 200ms 等待。

② 快速重传(Fast Retransmit)

TCP 需要收到 3 个重复 ACK 才触发快速重传。KCP 可配置为收到 2 个跳过确认(resend=2)即触发重传,不等超时,大幅缩短丢包恢复时间。

③ 可配置低 RTO

TCP 的最小 RTO 通常 200ms(Linux 内核默认)。KCP 普通模式最小 RTO 为 IKCP_RTO_MIN = 100ms,无延迟模式下为 IKCP_RTO_NDL = 30ms,配置上还可进一步压到 10ms 量级,在高频丢包场景下重传更激进。

④ RTO 增长更平缓(×1.5 而非 ×2)

TCP 超时重传后 RTO 翻倍(指数退避),连续丢包时等待时间指数膨胀,这是「长尾延迟」的主要来源。KCP 在快速模式(nodelay=1)下 RTO 只增长 1.5 倍,显著缓解连续丢包的延迟累积。

⑤ 选择性重传(Selective Repeat)

只重传真正丢失的包,不像 TCP 早期实现那样回退整个窗口(Go-Back-N)重发丢失包之后的所有数据。

⑥ UNA + ACK 双重确认(捎带确认)

KCP 每个数据包头部都带 una 字段(当前已确认的最左边界序号)。这意味着即使某个独立的 ACK 包丢了,只要后续任何数据包到达,对端仍能通过 una 推断出之前的包已被接收——ACK 信息有了天然冗余。TCP 只有 ACK 包携带确认信息,ACK 丢了就得等超时。

ikcp_nodelay 参数与两种典型模式:

// ikcp_nodelay(kcp, nodelay, interval, resend, nc)
// nodelay:  0=关闭无延迟模式  1=开启(RTO 增长 ×2 → ×1.5)
// interval: 内部时钟间隔(ms), 默认100, 可设10
// resend:   快速重传触发阈值, 0=关闭, 2=推荐
// nc:       0=启用拥塞控制  1=关闭(极速模式)

ikcp_nodelay(kcp, 0, 40, 0, 0);  // 普通模式:标准互联网环境
ikcp_nodelay(kcp, 1, 10, 2, 1);  // 极速模式:竞技游戏、实时音视频
模式配置RTO 增长最小 RTO快速重传拥塞控制场景
普通(0, 40, 0, 0)×2100ms关开标准互联网
极速(1, 10, 2, 1)×1.530ms2 次跳过关竞技游戏、音视频

实现内幕:24 字节头与三个数据结构

打个比方:KCP 的内部结构像一间快递驿站。snd_queue 是刚收进来还没上架的包裹堆(应用层刚 ikcp_send 进来的),snd_buf 是已上架、贴好单号、正在等签收的货架(只有上架的才真正发出去、才参与重传),货架容量就是发送窗口。ikcp_flush 是驿站员——每 10ms 巡一次,把新包裹上架、把该重发的重发、把要回的签收单发出去。

协议头固定 24 字节(IKCP_OVERHEAD = 24):

字段字节含义
conv4会话编号。UDP 多路复用时区分是哪条逻辑连接,也是 KCP 能在 IP 变化后不断连的原因
cmd1指令:PUSH(81) 数据 / ACK(82) 确认 / WASK(83) 窗口探测 / WINS(84) 窗口告知
frg1分片标识,0 表示最后一片
wnd2本端剩余接收窗口,告知对端
ts4发送时间戳,用于算 RTT
sn4包序号
una4左边界序号,捎带确认
len4后续数据长度

三个核心数据结构:

  • IKCPSEG(段):一个 KCP 包,含协议头 + 数据指针 + fastack 计数。收发队列里流转的就是它。
  • IKCPCB(控制块):一条连接的全部状态——收发队列、RTO 变量(rx_srtt/rx_rttval)、拥塞窗口(cwnd/ssthresh)、窗口指针(snd_una/snd_nxt/rcv_nxt)、远端窗口(rmt_wnd)。
  • IQUEUEHEAD(双向链表):管理四个队列,插入删除 O(1)。

滑动窗口四个指针:

变量含义
snd_una发送窗口左边界,此前全部已确认
snd_nxt下一个待分配序号(右边界)
rcv_nxt期望收到的下一个连续序号
rmt_wnd对端告知的接收窗口

数据流转:ikcp_send 放进 snd_queue → ikcp_flush 按 min(snd_wnd, rmt_wnd) 搬进 snd_buf → 只有 snd_buf 里的才真正发送并参与重传 → 确认后移出,腾出窗口。

快速重传的计数逻辑(ikcp_parse_fastack):收到 ACK 时遍历 snd_buf,给所有「序号小于该 ACK 且仍未确认」的包 fastack++。举例——发了 1、2、3、4、5,对端只收到 1、3、4、5,那么 3、4、5 的 ACK 回来时,包 2 的 fastack 依次涨到 3;达到 resend=2 阈值时立刻重传包 2,不等 RTO。

RTO 计算(Jacobson 算法,与 TCP 同源):

delta      = rtt - rx_srtt
rx_srtt    = (7 * rx_srtt + rtt) / 8            // 平滑 RTT
rx_rttval  = (3 * rx_rttval + |delta|) / 4      // RTT 偏差
rto        = rx_srtt + max(interval, 4 * rx_rttval)
// 上下限:clamp 到 [rx_minrto, IKCP_RTO_MAX=60000ms]

核心函数分工:

函数职责
ikcp_flush协议心脏。搬队列、生成 ACK、决策重传、发窗口探测。每 interval 调一次
ikcp_input处理收到的 UDP 包:解头、更新 RTT、处理 UNA 确认、数据入接收缓存
ikcp_update_ack收 ACK 后更新 RTT 采样、重算 RTO
ikcp_parse_fastack统计被跳过的包数,喂给快速重传
ikcp_send/ikcp_recv应用层收发接口(分片入队 / 取出已排序数据)

集成四步:ikcp_create(conv, user) 建对象 → 设 kcp->output 回调(底层 UDP 发送)→ 固定频率(推荐 10ms)调 ikcp_update 驱动定时器 → 用 ikcp_input(喂入收到的 UDP 包)+ ikcp_send/ikcp_recv(应用层收发)。

为什么必须自己周期调 ikcp_update

KCP 是纯算法库,没有自己的线程也不碰 socket。重传超时、发 ACK、窗口探测全靠你周期性调 ikcp_update 来推动。这既是它「无内核依赖、纯用户态」的原因,也是它必须由你自己接管收发和心跳的代价。

拥塞控制:可关,但关之前想清楚

KCP 实现了完整的类 TCP 拥塞控制(慢启动 + 拥塞避免),但比 TCP 温和,且可以整个关掉:

  • 慢启动:cwnd < ssthresh 时随每个有效 ACK 指数增长;超过后进入拥塞避免,每 RTT 线性 +1 MSS。
  • 超时重传(RTO 超时):ssthresh 减半(最小 2),cwnd 重置为 1,退回慢启动——和 TCP 一样剧烈。
  • 快速重传触发:ssthresh 减半,但 cwnd = ssthresh + resend,不跌到 1,避免窗口剧烈震荡。
  • nc=1(nocwnd):完全关闭拥塞控制,发送窗口只受 min(snd_wnd, rmt_wnd) 约束,网络拥塞也不降速。

关拥塞控制 = 把成本转移给别人

nc=1 之所以延迟稳,是因为它拒绝对拥塞做出让——别人退让它不退。单个连接看是收益,大规模部署在公网上就是对其他流量(包括你自己其他业务)的挤压,极端情况下加剧全网拥塞。所以它适合「可控网络 + 小包 + 延迟命门」的场景,不适合当默认值到处开。

重传状态模拟:同一次丢包,TCP 与 KCP 逐帧推演

下面用同一组参数把两者的重传过程一帧一帧算出来。所有数字都是按各自算法手推的,不是实测。

统一场景参数:

参数值
单程时延30ms(RTT = 60ms)
RTT 抖动 rttvar≈2ms
应用发包节奏游戏状态同步 50Hz,每 20ms 一个包
TCP快速重传阈值 3 个重复 ACK;RTO = srtt + 4·rttvar = 68ms,被 TCP_RTO_MIN 抬到 200ms
KCPikcp_nodelay(kcp, 1, 10, 2, 1);RTO = srtt + max(interval, 4·rttvar) = 70ms

单个报文的状态机(TCP 与 KCP 结构相同,只有阈值不同):

场景 A:中途丢包(后面还有包接着来)

发送 P1~P6,P3 丢失。表中「事件」按发送方本地时钟:

t (ms)发生了什么TCP 侧状态KCP 侧状态
0 / 20 / 40 / 60 / 80 / 100依次发出 P1 P2 P3(丢) P4 P5 P6在途 6 个在途 6 个
60P1 的 ACK 回来snd_una=2una=2
80P2 的 ACK 回来snd_una=3una=3
120P4 到达对端后回的确认到了重复 ACK #1P3 fastack=1
140P5 的确认到了重复 ACK #2(还差一个)fastack=2 = resend → 重传 P3
160P6 的确认到了重复 ACK #3 → 重传 P3P3' 已在路上
170——对端收到 P3',交付 P3 P4 P5 P6
190—对端收到 P3',交付 P3 P4 P5 P6—

逐包端到端延迟(发送时刻 → 对端交付时刻):

包物理最小TCPKCP说明
P3(真丢的那个)30ms150ms130ms差 20ms = 少等一个重复 ACK 的间隔
P430ms130ms110ms它没丢,却被 P3 连坐了 100ms / 80ms
P530ms110ms90ms同上
P630ms90ms70ms同上

这个场景里 KCP 只赢 20ms,而且队头阻塞它也有

两个必须诚实说清的点:① 中途丢包时快速重传本来就已经很快,KCP 把阈值从 3 降到 2 只省下一个发包间隔——KCP 真正的优势不在这条路径上,而在下面的 RTO 路径。② KCP 是单流有序可靠,队头阻塞它一样有:P4 P5 P6 早就到了,仍然被扣住等 P3。KCP 只是把「扣住的时长」缩短,没有取消扣住。要真正不连坐,得靠 QUIC 的独立 Stream,或者干脆把这类数据走不可靠通道(最新状态覆盖旧状态,旧的丢了就别管)。

另一个容易漏的细节:KCP 的 ACK 也是在 ikcp_flush 里发的,所以它的 ACK 本身最多晚一个 interval(10ms)。上表按对齐取了下界,实际 KCP 在 ACK 侧还要还回去一点。

场景 B:尾包丢失(后面没有包了)

同样是 t=40 发出的那个包丢了,但这是一次技能指令,之后 100ms 内没有新包——凑不出重复 ACK,只能等超时:

方案触发重传的时刻对端拿到的时刻端到端延迟
TCP(旧默认,无 TLP)t=240(等满 200ms RTO)270230ms
TCP + TLP(RFC 8985)t=160(PTO ≈ 2·srtt 后主动探测,探测包即重传)190150ms
KCP nodelayt=110(等满 70ms RTO)140100ms
KCP + FEC 10:3不重传,冗余包直接算回原包≈30~50接近物理下限

这一栏才是 KCP 拉开差距的地方:230ms → 100ms,而且这个差距完全来自「RTO 下限被谁定」——TCP 的 200ms 写在内核里,KCP 的 70ms 是自己算出来的。

场景 C:连续丢包,退避累积

同一个包连丢三次,看第 n 次重传发生在首次丢包之后多久:

重传轮次TCP(200ms 起,×2)KCP 普通(100ms 起,≈×2)KCP nodelay(70ms 起,×1.5)
第 1 次200ms100ms70ms
第 2 次600ms(+400)300ms(+200)175ms(+105)
第 3 次1400ms(+800)700ms(+400)332ms(+157)

指数退避在这里原形毕露:TCP 连丢三次就是 1.4 秒,玩家感受是「彻底卡住了」。这就是「TCP 生产长尾延迟」最直接的算术来源,也是 KCP 只 ×1.5 的全部意义。

源码对应

ikcp_flush 里超时分支:nodelay == 0 时 segment->rto += max(segment->rto, rx_rto)(约等于翻倍),nodelay == 1 时 segment->rto += segment->rto / 2(×1.5)。注意退避记在每个 segment 自己的 rto 上,不是连接级的 rx_rto——所以某个包连丢不会拖累其他包的重传时机。

拥塞窗口在这两条路径上的不同下场

接着场景 A / B 往下算,初始 cwnd=16、ssthresh=64、在途 16 个:

事件TCP RenoKCP(nc=0)
快速重传触发ssthresh = cwnd/2 = 8;cwnd = ssthresh + 3 = 11(快速恢复),恢复结束回到 8ssthresh = inflight/2 = 8;cwnd = ssthresh + resend = 10,不跌到 1
RTO 超时触发ssthresh = 8;cwnd = 1,重回慢启动ssthresh = cwnd/2;cwnd = 1,重回慢启动——和 TCP 一样狠
之后恢复速度慢启动指数涨回,但过 ssthresh 后每 RTT 只 +1同上
nc=1 时—这两条照样在算 cwnd,但发送窗口取 min(snd_wnd, rmt_wnd) 不再看它,等于失效

两条重传路径的代价差着一个数量级

快速重传只是把窗口砍半(还留着),超时重传是把窗口打回 1 并且退避。所以「尽量走快速重传、别掉进 RTO」不只是省那点等待时间——它决定了丢包之后你还能不能保持发送速率。KCP 把 resend 设 2、把最小 RTO 压到 30ms,本质都是在扩大快速重传能覆盖的比例,把流量尽量挡在 RTO 那条路之外。

弱网实测表现与 KCP 优劣势

丢包率 5%~30% 是 KCP 优势最明显的区间(下表为引用的业界测试量级,非本环境实测):

丢包率KCP 平均延迟KCP 尾延迟(P99)
5%比 TCP 低 ~30%比 TCP 低 ~50%
10%低 ~35%低 ~60%
20%低 ~40%低 ~65%
30%低 40%+降至 TCP 的约 1/3

有测试称 20% 丢包 + FEC 下,98% 样本延迟可控在 416ms 内,而 TCP 约 70% 样本超过 800ms。注意口径:这类数字依赖具体测试环境与参数,宜当量级参考,面试可说"业界测试显示同等丢包下 KCP 尾延迟约为 TCP 的 1/3 量级",不宜背成精确结论。

优势:

  • 端到端延迟显著低于 TCP,弱网(尤其 5%~30% 丢包)下尾延迟优势最明显。
  • 轻量(~1000 行 C,两个源文件),纯算法不依赖系统 API,易嵌入任何语言/平台。
  • 完全可控:RTO 增长、重传阈值、窗口、拥塞控制开关全可调。
  • 连接迁移天然支持:靠 conv 会话 ID 标识逻辑连接,IP 变化(WiFi↔4G)不需要重新握手。

劣势:

  • 带宽消耗高 10%~20%:激进重传 + 可关拥塞控制 = 更多冗余包。
  • 无内核级拥塞控制约束(nc=1 时):大规模部署对公网不友好(抢占带宽)。
  • 非标准化:无 RFC,协议升级靠自己维护,互操作性差。
  • 无加密:需应用层自己加密(通常套 DTLS 或自研加密层)。
  • 需自行管理连接:不提供握手/挥手/超时断开,心跳与连接生命周期全要应用层自己写。
  • 运营商 UDP QoS 风险:部分运营商对 UDP 限速或降优先级,可能反而变慢,需 udp2raw 之类伪装成 TCP 规避。
  • 生态相对小众:主要集中在国内游戏与音视频行业,不像 QUIC 有 IETF 标准与浏览器原生支持。

关于「连接迁移」的口径修正

KCP 的 conv 会话 ID 确实让逻辑连接不绑定 IP 四元组,IP 变化后不需要重新握手,这一点与 QUIC 的 Connection ID 思路相同。但 KCP 没有 QUIC 那套路径验证(PATH_CHALLENGE)机制——它不主动探测新路径、也不防御连接 ID 被第三方冒用,实际工程中通常仍要应用层配合处理重绑定与安全校验。所以更准确的说法是:KCP 具备会话层面的迁移基础,但迁移的完整性与安全性弱于 QUIC,而非「无连接迁移」。

生产实践:调参、FEC 与 MTU

参数调优:

参数推荐调优建议
nodelay1实时场景必开;普通传输可 0
interval10游戏/音视频 10ms;文件传输可放宽到 20~40ms(省 CPU)
resend2丢包 >5% 设 2;极低丢包可设 1
nc1带宽充足时开;带宽受限或公网大规模部署设 0

FEC 前向纠错(丢包 >10% 时配合 Reed-Solomon):用冗余包换「零重传恢复」,丢包不必等重传就能还原,直接砍掉重传那一个 RTT。

配置冗余带宽适用丢包率
10:220%+20%5%~10%
10:330%+30%10%~20%
10:550%+50%20%~30%

这是「以带宽换延迟」的极端形态——连重传都不等了,直接预先多发冗余。

MTU 设为 1350~1400 字节:避免 UDP 包超 MTU 后被 IP 层分片。分片后任一片丢失整个包就废了,等于人为放大丢包率——弱网下这个坑很致命。

四个常见故障:

症状根因处理
延迟持续增大发送速率超过可用带宽,数据在缓冲区堆积监控 ikcp_waitsnd(待发字节数),超窗口就应用层限速
明明用了 KCP 反而更慢运营商对 UDP 限速/降优先级udp2raw 伪装成 TCP 流量
内存持续增长 OOMikcp_update 频率与发送速率不匹配,内部队列无限增长匹配调用频率 + 限速 + 监控队列长度
CPU 占用高interval=10ms 时 ikcp_flush 每秒调 100 次,且它是 CPU 密集操作高并发用 kcp-go 等绑定库做并发调度;非实时场景放宽 interval

落地案例与同类对比

生产使用:《原神》(全球移动网络下的战斗指令加速)、《王者荣耀》(WiFi/4G 切换时的延迟波动)、阿里云 GRTN(视频推流弱网优化)、kcptun(TCP-over-KCP 隧道 + FEC,跨国加速)、v2ray(可选底层传输)、SpatialOS(大规模分布式游戏)。

KCP vs ENet(另一个知名 UDP 可靠库):

KCPENet
极端丢包延迟更优(有评测称低至 ENet 的 1/3)逊于 KCP
连接管理需自己写开箱即用
多通道需自己做(如 smux)内置
加密需自己套需自己套

一句话:KCP 赌延迟极致、要你自己补周边;ENet 更完整易用、延迟不极致。

QUIC:标准化 L4 重构

打个比方:TCP 是一条单行道的传送带——所有货物排成一列,前面一箱倒了,后面全堵着(队头阻塞);而且这条传送带是绑定在「你家门口到我家门口」这条固定路线上的,你一搬家(IP 变了)传送带就断了。QUIC 相当于把它改成同一条路上并排的多条独立传送带:一条堵了其他照跑(独立 Stream);并且货单上写的是收货人编号而不是门牌号(Connection ID),你搬家了单子还认人,货照送(连接迁移)。

解决的三大问题

① TCP 队头阻塞

HTTP/2 over TCP 虽然在应用层实现了多路复用(多个 Stream),但底层仍是 TCP 字节流——一个包丢失,TCP 会阻塞所有 Stream 的数据交付,直到重传成功。

QUIC 在 UDP 上实现了独立的 Stream:每个 Stream 有自己的流控,一个 Stream 丢包只阻塞该 Stream,其他 Stream 不受影响。

② 握手延迟

TCP + TLS 1.2 需要 3-RTT(TCP 握手 1-RTT + TLS 握手 2-RTT)。QUIC 集成 TLS 1.3:

  • 首次连接:1-RTT(QUIC 握手与 TLS 握手合并)
  • 会话恢复:0-RTT(复用之前的会话票据,第一个包就能携带应用数据)

③ 连接迁移(Connection Migration)

TCP 连接由四元组 (src_ip, src_port, dst_ip, dst_port) 标识,IP 变化即断连。

QUIC 用 Connection ID(CID) 标识连接,CID 与 IP/端口无关。手机从 WiFi 切换到 4G,IP 变了,但 CID 不变,QUIC 连接无缝迁移,不需要重新握手。

QUIC 核心机制

① 多路复用 Stream,无队头阻塞(见上)

② 0-RTT / 1-RTT 握手(见上)

③ 连接迁移(见上)

④ 用户态实现

QUIC 栈运行在用户态(如 quiche、msquic、ngtcp2),不依赖内核升级,可以快速迭代协议逻辑。这也是 QUIC 能在 RFC 发布前就大规模部署的原因。

⑤ 内置加密

QUIC 强制 TLS 1.3,所有数据(包括头部大部分字段)都加密,无法被中间设备篡改或注入。

QUIC 优劣势

优势:

  • 消灭 TCP 队头阻塞,多路复用真正独立。
  • 0-RTT 握手,弱网首包延迟极低。
  • 连接迁移,移动端网络切换不断连。
  • 内置 TLS 1.3,安全性强。
  • 用户态实现,迭代快。

劣势:

  • UDP 被防火墙/运营商限速或封锁:部分企业网络、运营商对 UDP 有 QoS 限制,QUIC 可能被降级到 TCP。
  • CPU 开销更高:加密/解密在用户态,比内核 TCP 的硬件卸载(TSO/GRO)开销大。
  • 实现复杂:QUIC 栈比 TCP socket 复杂得多,调试困难。
  • 0-RTT 重放攻击:0-RTT 数据在握手完成前发送,存在重放风险,需应用层幂等保护。

KCP vs QUIC vs TCP 对比

维度TCPKCPQUIC (RFC 9000)
协议层次L4,内核UDP 上的用户态库(非标准)L4,UDP 上,用户态(RFC 9000)
标准化IETF RFC 793无(源码即规范)IETF RFC 9000
可靠性有序可靠有序可靠(ARQ)有序可靠(per-Stream)
队头阻塞有(字节流)有(单流)无(Stream 独立)
握手延迟1-RTT(+TLS 2-RTT)无握手(UDP)1-RTT / 0-RTT
连接迁移❌(四元组绑定)⚠️ 部分(conv 会话 ID 不绑 IP,但无路径验证)✅(Connection ID + 路径验证)
延迟优化拥塞控制优先激进重传 + 低 RTO无队头阻塞 + 0-RTT
加密需叠加 TLS需应用层加密内置 TLS 1.3
带宽效率高(拥塞控制)低(多余重传包)中(加密开销)
内核依赖是否(纯用户态)否(纯用户态)
适用场景通用、文件传输、长连接实时对战、低延迟 UDPHTTP/3、移动端弱网

一句话记住三者差别

如果上面这张表太密,先记住这三句:

一句话类比
TCP内核帮你包办可靠传输,但规则改不了,一个包丢了后面全等着快递公司的挂号信:全套包办,规则他定
KCP你在 UDP 上自己写一套重传逻辑,规则全自己定,多发包换低延迟自己雇的跑腿小哥:规则你定,人你养
QUIC重新设计的标准传输协议,多条独立车道 + 换网不断连 + 握手更快新快递公司:服务规则重做,还有国标背书

三者关系:KCP 和 QUIC 都是「在 UDP 上重建可靠性」,但 KCP 是一份你可以随便改的库、赌延迟;QUIC 是一份大家都遵守的标准、赌通用性与体验。

带宽充裕时代:为什么天平倒向 KCP 与 QUIC

稀缺资源换了位置

1988 年 TCP 定下规则时,主干链路是 56 kbps 量级,带宽是全网最紧的东西,省带宽等于救命。今天家宽百兆到千兆、5G 上行几十兆,而一个 FPS 的状态同步包只有 20~100 字节、每秒 30~60 个——单玩家上行不到 50 kbps。

于是「多花 20% 带宽换掉 170ms 延迟」这笔账,在 1988 年是荒谬的,在今天是几 KB/s 换一个能不能打的体验。

一句话

TCP 优化的是那个年代最贵的东西(带宽)。今天最贵的东西换成了延迟,尤其是尾延迟,而 TCP 的机制里没有一条是为它服务的。

唯一没变便宜的东西:光速

带宽能靠加钱扩容,传播时延不能——它由物理距离和光速决定:

  • 光纤中光速约 20 万 km/s(折射率使其为真空光速的约 2/3)。
  • 上海 ↔ 洛杉矶约 10000 km,单程理论 50ms,往返 100ms;绕路、光电转换、路由跳数后实测常在 150~200ms。

这条下限花钱买不到。所以低延迟工程的全部空间只剩三件事:

能做的手段
缩短物理距离就近部署边缘节点、跨区域专线
减少往返次数0-RTT 握手、别为丢包多等一个 RTT
不让数据在队列里排队小缓冲、不填满瓶颈、FEC 免重传

TCP 在后两项上全面吃亏:TCP+TLS 1.2 要 3-RTT 才能发第一个字节;丢包必须等一个 RTT 才能补上;基于丢包的拥塞控制必须先把缓冲区填满才知道该退——填满的过程本身就是延迟。

真正的痛点是尾延迟,不是平均延迟

游戏体验由最差的那几个包决定,不是平均值。平均 40ms、P99 400ms 的连接,玩家感受是「时不时抽一下」——技能放空、位置回拉,比稳定 80ms 难受得多。

TCP 三个机制专门在生产长尾:

机制尾延迟怎么来的
最小 RTO 200ms一次超时重传直接给这个包记上 200ms+
RTO 指数退避(×2)连续丢两次:200 → 400 → 800ms
队头阻塞一个包丢了,后面已经到达内核的所有包一起被扣住

第三条最致命:它把单个包的坏运气放大成整条流的停顿。而游戏状态同步里,第 100 帧的位置到了、第 99 帧丢了,其实完全可以先用第 100 帧——TCP 的字节流语义不允许你这么干。

关键错配

TCP 提供的是严格有序的字节流,而游戏要的是尽快拿到最新状态。旧位置根本不重要——最新的那个才重要。TCP 为了「不乱序」而扣住新数据,恰好扣住了唯一有价值的东西。

KCP 与 QUIC 各自从一端拆掉了这个错配:KCP 把重传参数交给你(不等 200ms、不指数退避、宁可白发),QUIC 拆掉有序性绑定(Stream 独立,一条堵不连坐其他)。

历史演进:三十年五个阶段

阶段时间发生了什么稀缺资源
① 定型1974~1988RFC 793 定 TCP;1986 NSFNET 拥塞崩溃;1988 Jacobson 补上慢启动 + cwnd + AIMD带宽/稳定性
② 自建可靠 UDP1996~2005Quake / 半条命时代,游戏发现 TCP 不能用于实时同步,各家在 UDP 上自写 ARQ(ENet 2004、RakNet)延迟,但各自为战
③ 工程化2011~2016KCP(2011,~1000 行 C)把「以带宽换延迟」做成可复用库;带宽已明显不紧延迟
④ 标准化2012~2021Google gQUIC(2012)→ IETF QUIC RFC 9000(2021);把 UDP 重构成标准 L4延迟 + 通用性 + 安全
⑤ 拥塞控制换范式2016~今BBR 不再拿丢包当信号,改测带宽/RTT 拐点,主动不填满缓冲区排队延迟

三条主线贯穿其中:

  1. 可靠性下移到用户态——内核升级太慢,协议迭代必须脱离内核(KCP、QUIC 栈都是用户态)。
  2. 连接标识从「地址」变成「身份」——四元组 → conv / Connection ID,因为终端从固定电脑变成了会切网的手机。
  3. 拥塞信号从「丢包」变成「延迟/带宽」——丢包信号天生要求先把队列填满,与低延迟目标直接冲突。

演进的一句话总结

不是 TCP 变差了,是约束变了:带宽从最贵变成最便宜,终端从不动变成会漫游,业务从传文件变成实时交互。TCP 的答案仍然正确——只是那道题已经换了。

游戏场景选型建议

FPS / MOBA 实时对战同步:选 KCP

  • 延迟极致优先,可接受多消耗 10%~20% 带宽。
  • 自研协议栈,完全可控,可针对游戏包特征(小包、高频)调优。
  • 典型:王者荣耀、PUBG Mobile 的实时同步层。

移动端弱网登录 / HTTP/3 接入:选 QUIC

  • 需要标准化、互操作性、内置加密。
  • 移动端网络切换频繁,连接迁移价值大。
  • 0-RTT 对首屏加载体验提升明显。

长连接推送 / 聊天 / 非实时业务:选 TCP / WebSocket

  • 延迟不敏感,TCP 的拥塞控制反而保护带宽。
  • 生态成熟,运维简单。

极限低延迟 + 标准化:QUIC + 自定义 Stream 调度

  • 新一代游戏后端探索方向,兼顾标准化与低延迟。

为什么这么做

  • 为什么网络会丢包、为什么必须重传:IP 层的契约只是「尽力而为」——不保证送到、不保证顺序、不保证不重复,连丢了都不通知(ICMP 常被过滤)。丢包来源分两类:拥塞性(路由器队列满,尾丢弃或 AQM 提前丢)与非拥塞性(无线信道误码、IP 分片丢片、缓冲溢出、QoS 策略丢弃)。既然网络不兜底,端点只有两条路补:ARQ(丢了再补,代价只在丢包时付,但恢复要一个 RTT)或 FEC(预先多发冗余,一直付带宽,但恢复 0 RTT)。TCP 选 ARQ 是因为 1980 年代带宽极贵;KCP 上 FEC 是因为今天 RTT 比带宽贵。
  • 为什么必须有拥塞控制:三个理由——① 瓶颈带宽未知且会变(换基站就变),没人告知,只能端点试探;② 不控会形成正反馈:丢包→重传→更堵→更多丢包,1986 年 NSFNET 实证从 32kbps 崩到 40bps,链路满载但 goodput 归零(拥塞崩溃);③ 没有中心分配器,多流公平只能靠大家跑同一套规则。目标是把负载稳在 knee(吞吐刚满、队列未堆)而不越过 cliff(队列溢出、副本涌入、吞吐塌方)。
  • 为什么不把拥塞控制交给路由器:端到端原则要求网络核心简单无状态;骨干路由器承载数百万条流,逐流保存速率状态会让转发面爆掉;路径跨多个自治域没人说话算数;端点也不能盲信可伪造的中间指令。折中是路由器只给极弱提示、决定权仍在端点:AQM/RED 提前随机丢一个包让信号早到,ECN 用 IP 头一个 CE bit 替代「丢一个包」这种昂贵报警。
  • 为什么用 AIMD 而不是别的增减组合:乘性减按比例砍,占得多的被砍掉的绝对量更大;加性增又给每条流加同样多,差距每轮被压缩,收敛到均分(Chiu & Jain, 1989)。加增加减保持初始差距,乘增乘减比例不变不收敛。但这个公平只在同 RTT + 同算法下成立——RTT 短的抢得更多,算法不同也不公平(BBR vs CUBIC),而 KCP nc=1 干脆退出这个游戏。
  • 为什么 RTO 有个不小的下限:RTO 太小会伪重传——网络只是抖一下、ACK 晚到几毫秒就白发一份、还白砍 cwnd 一半。RFC 6298 建议最小 1s,Linux 取 200ms 并基本写死。而 Jacobson 公式在 RTT 60ms、抖动 5ms 时算出来只要约 80ms——RTT 越小,这个下限浪费越大,手游与同城机房恰好都是小 RTT 场景,这是 KCP 把最小 RTO 压到 30/100ms 的全部动机。
  • 为什么尾包丢失特别贵:最后一个包丢了,后面没有包能产生重复 ACK,凑不出快速重传,只能干等一个完整 RTO。游戏指令、RPC 这类小批量请求-响应几乎每次都踩这个坑。Linux 用 TLP(RFC 8985 RACK-TLP)在约 2×SRTT 后主动发探测包补救,把「只能等 RTO」变回「能走快速重传」。
  • 为什么要拼命避免掉进 RTO 路径:两条重传路径代价差一个数量级——快速重传只把 cwnd 砍半(还留着,快速恢复),超时重传把 cwnd 打回 1 并退避。KCP 把 resend 设 2、最小 RTO 压到 30ms,本质都是在扩大快速重传能覆盖的比例,把流量挡在 RTO 之外。
  • 为什么 KCP 在中途丢包时只比 TCP 快一点、在尾包丢失时快很多:中途丢包走快速重传,KCP 把阈值从 3 降到 2 只省一个发包间隔(约 20ms);尾包丢失走 RTO,TCP 旧默认 230ms vs KCP 100ms。KCP 真正的战场是 RTO 路径与连续退避(连丢三次:TCP 1400ms vs KCP nodelay 332ms),不是快速重传路径。
  • 为什么 KCP 也有队头阻塞:KCP 是单流有序可靠——后面的包早到了也要被扣住等前面那个补上,它只缩短「扣住的时长」,没有取消扣住。要真正不连坐得靠 QUIC 的独立 Stream,或者把这类数据走不可靠通道(最新状态覆盖旧状态)。
  • 为什么 KCP 比 TCP 延迟低:KCP 关闭 Nagle(立即发)、快速重传(2 个跳过即重传,不等超时)、最小 RTO 低至 30ms(TCP 是 200ms)、超时退避只 ×1.5 而非翻倍、选择性重传、可关拥塞控制(不降速)。代价是多发包,但延迟稳定性远好于 TCP。
  • 为什么 QUIC 消灭队头阻塞:QUIC 在 UDP 上实现独立 Stream,每个 Stream 有独立的流控与重传,一个 Stream 丢包不影响其他 Stream 的数据交付——这是 TCP 字节流模型根本做不到的。
  • 为什么 QUIC 能 0-RTT:QUIC 把传输握手与 TLS 1.3 握手合并,会话恢复时客户端用之前缓存的 Session Ticket 直接在第一个包里携带应用数据,服务端解密后即可处理,无需等待握手完成。
  • 为什么 QUIC 连接不因 IP 变化断开:QUIC 用 Connection ID 标识连接,CID 由双方协商,与网络地址无关。IP 变化后客户端发送 PATH_CHALLENGE,服务端验证新路径后迁移,连接状态完整保留。

为什么别的选择不行

  • 为什么不直接用 UDP 裸包:裸 UDP 无可靠性保证,游戏逻辑包(技能、位置)丢失会导致状态不一致,需要应用层自己实现 ARQ——这就是 KCP 在做的事,不如直接用 KCP。
  • 为什么不用 TCP 做实时对战:TCP 的 RTO 最小 200ms,丢包时队头阻塞会让后续所有包等待,在 1%~5% 丢包率的移动网络下延迟抖动极大,玩家体验不可接受。
  • 为什么不用 KCP 替代 QUIC 做 HTTP/3:KCP 无标准化、无内置加密、无多路复用 Stream(需自己套 smux)、迁移能力不完整(无路径验证),不适合通用 Web 场景;QUIC 是 IETF 标准,浏览器原生支持,生态完整。
  • 为什么不无脑用 QUIC 做游戏实时同步:QUIC 的 Stream 抽象和 TLS 加密引入了额外开销,且 UDP 在部分运营商网络被限速;游戏实时同步对延迟的要求比 HTTP/3 更极端,KCP 的可调性更强。
  • 为什么不干脆把拥塞控制全关掉(所有人都 nc=1):单条流看是收益,全网都这么干就回到 1986 年——正反馈把链路灌满副本、goodput 归零。nc=1 的低延迟建立在「别人还在守规矩」这个前提上,它是搭便车,不是免费的午餐。
  • 为什么不靠无限加大路由器缓冲来避免丢包:缓冲越大,超过 BDP 的那部分全部变成排队延迟,一点吞吐都换不到(bufferbloat);而且基于丢包的拥塞控制必须等队列满了才收到信号,缓冲越大报警越晚、延迟坏得越深。少丢包和低延迟在这里是直接冲突的。
  • 为什么不让重传过的包参与 RTT 采样:收到 ACK 时分不清确认的是原包还是重传包(重传歧义),采样会被污染进而算出错误的 RTO。Karn 算法规定重传过的包不采样,RTO 只靠退避维持;KCP 的 ikcp_update_ack 同样遵循。

沉淀结论

  1. KCP 层次:UDP 之上的用户态 ARQ 库,非标准化,以带宽换延迟。六项改造:无 Nagle + 快速重传(2 跳过)+ 低最小 RTO(30/100ms)+ RTO 只 ×1.5 + 选择性重传 + UNA 捎带确认。
  2. QUIC 层次:IETF RFC 9000,标准化 L4,UDP 之上,集成 TLS 1.3,HTTP/3 跑在其上。
  3. QUIC 三大问题:队头阻塞(Stream 独立解决)、握手延迟(0-RTT/1-RTT 解决)、连接迁移(Connection ID + 路径验证解决)。
  4. 选型口诀:实时对战用 KCP(延迟极致可控)/ 移动弱网 HTTP 用 QUIC(标准化+迁移+0-RTT)/ 长连接推送用 TCP(稳定省带宽)。
  5. KCP 代价:带宽多 10%~20%,无加密,需自管连接与心跳,迁移能力不完整(无路径验证),非标准,UDP 有被运营商 QoS 限速风险。
  6. QUIC 代价:UDP 可能被封,CPU 开销高,0-RTT 有重放风险,实现复杂。
  7. KCP 工程要点:MTU 设 1350~1400 防 IP 分片放大丢包;丢包 >10% 上 FEC(10:3 换零重传恢复);延迟持续增大先看 ikcp_waitsnd 是否堆积;nc=1 不是免费的,它把拥塞成本转移给了别人。
  8. TCP 双窗口:rwnd 由接收方在 ACK 里明确告知,保护对端缓冲区;cwnd 由发送方靠丢包信号自己猜,保护中间网络与其他流。有效窗口 = min(cwnd, rwnd);TCP 控的是在途量,速率 = 窗口 / RTT 只是副产品。
  9. BDP 与 bufferbloat:BDP = 带宽 × RTT;窗口小于 BDP 吃不满带宽,大于 BDP 的部分全变成排队延迟。基于丢包的拥塞控制必须先填满缓冲区才知道退,与低延迟目标天然冲突——这是 BBR 改用带宽/RTT 拐点作信号的原因。
  10. TCP 保障的四件事:网络不崩(cwnd+AIMD)、多流公平(AIMD 收敛)、接收方不被淹(rwnd)、字节流正确有序(序号+ACK+重传)。四条里没有延迟——1986 NSFNET 拥塞崩溃(32kbps 崩到 40bps)是它的历史起点,端到端原则决定了只能由端点自我约束。
  11. 为什么今天倒向 KCP/QUIC:稀缺资源换位——带宽从最贵变最便宜(游戏包 20~100 字节、单玩家 <50kbps),而光速定的传播时延花钱买不到(上海↔洛杉矶实测 150~200ms)。低延迟只剩三招:缩短距离、减少 RTT 次数、不排队。TCP 在后两招上全面吃亏。
  12. 痛点是尾延迟不是平均延迟:TCP 三个机制专门生产长尾——最小 RTO 200ms、指数退避(200→400→800ms)、队头阻塞把单包坏运气放大成整流停顿。根本错配在于 TCP 给的是「严格有序字节流」,而游戏要的是「尽快拿到最新状态」,旧位置本就该丢。
  13. 演进五阶段:定型(1974~1988,RFC 793 + Jacobson 拥塞控制)→ 自建可靠 UDP(1996~2005,ENet/RakNet)→ 工程化(2011 KCP)→ 标准化(2012 gQUIC → 2021 RFC 9000)→ 拥塞控制换范式(2016 BBR)。三条主线:可靠性下移用户态、连接标识从地址变身份、拥塞信号从丢包变延迟/带宽。
  14. RTT 比丢包好在哪:丢包要求「队列先满才报警」,报警时延迟已经坏了,且大缓冲设备上报得更晚;RTT 在队列刚堆积就涨,且是连续信号(知道堆了多少,可微调)而非二值信号(只能砍半)。无线场景还能避免把信道误码丢包误判成拥塞。代价是竞争力弱——你退让、CUBIC 不退,带宽被吃走。
  15. BBR 做了什么:瞄准 Kleinrock 最优点(在途量 = BDP,带宽满且 RTT 最小)。测 BtlBw(10 个 RTT 窗口内交付速率最大值)与 RTprop(10 秒内 RTT 最小值),两者物理上不可同时观测故交替探测。四状态:STARTUP(×2.89 探带宽,3 轮不涨即停)→ DRAIN(排队)→ PROBE_BW(8 RTT 一轮,增益 [1.25, 0.75, 1×6])→ PROBE_RTT(每 10 秒压到 4 包约 200ms 重测)。两个关键动作:pacing 均匀发包(非 CUBIC 的整窗突发)、丢包不再自动降窗。结果:队列接近空,RTT 近物理下限 + 带宽吃满。坑:vs CUBIC 不公平、BBRv1 多流高估 BtlBw、PROBE_RTT 每 10 秒有 200ms 降速。
  16. BBR 与 KCP 方向相反可叠加:KCP nc=1 拒绝退让、把成本转给别人;BBR 主动少占、不填缓冲。KCP 砍掉「重传等待」延迟,BBR 砍掉「排队」延迟,故「QUIC + BBR」成移动端弱网标配。
  17. 丢包为什么发生:IP 只承诺尽力而为(不保证送到/顺序/不重复,且丢了不通知)。分两类:拥塞性(路由器队列满,tail drop 或 AQM 提前丢)与非拥塞性(无线信道误码、IP 分片丢片、缓冲溢出、QoS 策略丢弃、校验错)。丢包 ≠ 拥塞——TCP 一律当拥塞处理,所以在无线网络里因误码白降速,这正是 BBR 与 KCP 都在绕开的误判。
  18. 补可靠性只有两条路:ARQ(丢了再补,代价只在丢包时付,恢复至少一个 RTT)vs FEC(预先多发冗余,不丢也一直付带宽,恢复 0 RTT)。TCP 选 ARQ 因为 1980 年代带宽极贵;KCP 上 Reed-Solomon FEC 因为今天 RTT 比带宽贵。
  19. 发送方怎么知道丢了:网络不通知,只有两条路——超时(等不到 ACK,至少一个 RTO,兜底但慢)或重复 ACK / fastack(发现自己被跳过,约一个 RTT,快但尾包丢失时凑不出来)。代价差一个数量级:快速重传只把 cwnd 砍半(还留着),超时重传把 cwnd 打回 1 并退避。KCP 调 resend=2 与低 RTO 的本质就是扩大快速重传覆盖比例、把流量挡在 RTO 之外。
  20. 尾包丢失与 TLP:最后一个包丢了没有后续包产生重复 ACK,只能干等完整 RTO;游戏指令/RPC 几乎每次交互都踩。Linux 用 TLP(RFC 8985 RACK-TLP)在约 2×SRTT 后主动发探测包,把「只能等 RTO」变回「能走快速重传」。所以「TCP 尾包必等 200ms」是旧默认下的结论。
  21. RTO 下限为什么大:太小会伪重传(抖一下就白发一份 + 白砍 cwnd 一半)。RFC 6298 建议最小 1s,Linux 取 200ms(写死),KCP 普通 100ms、nodelay 30ms。Jacobson 公式在 RTT 60ms、抖动 5ms 下只需约 80ms——RTT 越小,下限浪费越大,手游与同城机房正是小 RTT 场景。另:Karn 算法规定重传过的包不参与 RTT 采样(避免重传歧义污染),KCP 同样遵循。
  22. 拥塞崩溃与 knee/cliff:不控会形成正反馈(丢包→重传→更堵→更多丢包),1986 NSFNET 从 32kbps 崩到 40bps,链路满载但 goodput 归零(throughput ≠ goodput)。目标是稳在 knee(吞吐刚满、队列未堆,BBR 瞄这里)而不越过 cliff(队列溢出、副本涌入、塌方);CUBIC/Reno 稳态就在两点之间那段,因为它必须等丢包才知道退。
  23. 拥塞控制非它不可的三个理由:瓶颈带宽未知且会变(只能试探)、正反馈会崩溃(1986 实证)、无中心分配器(公平只能靠共同规则)。流控 vs 拥塞控制的根本差异:rwnd 是你和对端两人之间的事、对方会明确报数;cwnd 是你和一路上所有陌生人之间的事、没人告知只能猜。
  24. 为什么不交给路由器管:端到端原则(核心简单无状态)、逐流状态爆炸(骨干数百万条流)、路径跨多个自治域没人说话算数、中间指令可伪造不能盲信。折中是只给弱提示:AQM/RED 提前随机丢一个包让信号早到(避免队列满时全局同步),ECN 用 IP 头一个 CE bit 替代「丢一个包」这种昂贵报警(DCTCP、BBRv2/v3 用它)。
  25. AIMD 为什么收敛到公平:乘性减按比例砍,占多的被砍绝对量更大;加性增给每条流加同样多,差距每轮压缩(80/20 → 40/10 → 50/20 → …)。加增加减保持初始差距,乘增乘减不收敛(Chiu & Jain, 1989)。公平只在同 RTT + 同算法下成立——RTT 短的天生抢得多(跨国流吃亏),算法不同也不公平。
  26. 重传状态模拟的三个结论(RTT 60ms、50Hz 发包):中途丢包 KCP 只赢约 20ms(阈值 3→2 省一个发包间隔),而且队头阻塞 KCP 一样有(单流有序,P4~P6 早到仍被扣住,只是扣得短);尾包丢失 TCP 旧默认 230ms、TCP+TLP 150ms、KCP 100ms、KCP+FEC 接近物理下限——这才是 KCP 的主战场;连丢三次累计等待 TCP 1400ms、KCP 普通 700ms、KCP nodelay 332ms,指数退避是长尾的直接算术来源。源码对应:nodelay=0 时 segment->rto 约翻倍、nodelay=1 时 += rto/2,退避记在每个 segment 自己的 rto 上,某包连丢不拖累其他包。
  27. 不能靠加大缓冲避免丢包:超过 BDP 的缓冲全变排队延迟、一点吞吐都换不到(bufferbloat),且基于丢包的算法要等队列满才报警,缓冲越大报得越晚。少丢包与低延迟直接冲突。 同理 nc=1 的低延迟建立在「别人还守规矩」上,是搭便车——全网都这么干就回到 1986。

记忆口诀

KCP 六改造:无Nagle立即发 / 快速重传2跳过 / 最小RTO 30~100ms / RTO只×1.5不翻倍 / 选择性重传非GBN / UNA捎带确认
KCP 结构:snd_queue待发 → flush搬进 → snd_buf才真发才重传 / 24字节头 / conv会话号不绑IP
KCP 工程:MTU 1350~1400防分片 / 丢包>10%上FEC / waitsnd堆积就限速 / nc=1挤别人带宽
QUIC:RFC9000标准L4 / UDP上集成TLS1.3 / Stream独立无队头阻塞 / 0-RTT握手 / ConnectionID+路径验证迁移
选型:FPS实时→KCP / 移动弱网HTTP→QUIC / 长连接推送→TCP
TCP双窗口:rwnd接收方喊别多了护缓冲 / cwnd发货方自己猜护网络 / 取min / 控在途量不控速率 / 速率=窗口÷RTT
BDP:带宽×RTT / 小了吃不满 / 大了纯排队(bufferbloat)/ 丢包信号必须先填满队列才知退
TCP保四件:网络不崩 / 多流公平 / 对端不淹 / 字节序正确 —— 唯独不保延迟(1986 NSFNET 崩溃是起点)
今天倒向UDP系:带宽变白菜(游戏包20~100字节)/ 光速买不到(沪↔洛150~200ms)/ 只剩缩距离、减RTT、不排队
尾延迟三凶:最小RTO 200ms / 退避200→400→800 / 队头阻塞把单包倒霉放大成整流停顿
演进五阶段:定型88 → 自建UDP 96 → KCP 11 → QUIC 12/21 → BBR 16;主线:下移用户态、地址变身份、丢包变延迟
RTT胜丢包:丢包要队列先满才报(晚了)/ RTT刚堆就涨(早)/ 连续可微调 vs 二值只砍半 / 不误判无线误码 / 代价:竞争力弱
BBR测两量:BtlBw取10RTT内速率最大 / RTprop取10秒内RTT最小 / 不可同时测故交替探 / 目标在途量=BDP
BBR四状态:STARTUP ×2.89探 → DRAIN排队 → PROBE_BW [1.25,0.75,1×6] → PROBE_RTT每10秒压200ms
BBR两动作:pacing均匀发不整窗突发 / 丢包不自动降窗
KCP vs BBR:KCP砍重传等待、拒退让 / BBR砍排队、主动少占 / 可叠加=QUIC+BBR
丢包五来源:队列满tail drop / 无线误码 / 缓冲溢出 / IP分片丢片 / QoS策略丢 —— 只有第一种是拥塞
两条补救路:ARQ丢了再补(只在丢包付、要一个RTT)/ FEC预先冗余(一直付、0 RTT)
检测丢包两条路:超时RTO(慢、兜底)/ 重复ACK即fastack(快、尾包丢时凑不出)
两路径代价差一档:快速重传cwnd砍半还留着 / 超时重传cwnd打回1还退避 —— 所以拼命别掉进RTO
尾包丢失:没后续包凑不出重复ACK / 只能等满RTO / TLP在2×SRTT主动探测救回来
RTO下限:太小伪重传(白发+白砍窗)/ RFC建议1s、Linux 200ms写死、KCP 30~100ms / RTT越小浪费越大
Karn:重传过的包不参与RTT采样,避免重传歧义
拥塞崩溃:丢包→重传→更堵正反馈 / 1986 NSFNET 32kbps→40bps / 链路满但goodput归零
knee与cliff:knee吞吐刚满队列未堆(BBR瞄这)/ cliff溢出塌方 / CUBIC稳态卡在两点之间
控制非它不可:带宽未知只能试探 / 正反馈会崩 / 无中心分配器靠共同规则
不交路由器管:端到端原则 / 逐流状态爆炸 / 跨自治域没人算数 / 指令可伪造;折中=AQM提前丢+ECN一个bit
AIMD收敛:乘性减砍多的更多、加性增加一样多 → 差距每轮折半 / 只在同RTT同算法下公平
模拟三结论:中途丢包KCP只赢20ms且照样队头阻塞 / 尾包丢TCP 230→KCP 100 才是主战场 / 连丢三次1400 vs 332
缓冲不能加大:超BDP全变排队 / 丢包信号要队列满才报 / 少丢包与低延迟直接冲突

内容来源

综合整理。主要参考:KCP 原始 README 与源码(github.com/skywind3000/kcp,含 ikcp.c 的 ikcp_flush/ikcp_parse_fastack/ikcp_update_ack 实现与 ikcp.h 常量定义)、KCP 与 ENet 性能对比(skywind3000.com/blog/archives/1628)、腾讯 WeTest 弱网测试数据(wetest.qq.com/labs/391)、kcptun 与 kcp-go 重传机制分析、KCP 在 4G/5G 移动网络中的应用研究(北邮学报)、KCP 生产参数调优与 FEC 实践资料、KCP Issue #93(延迟增大排查)、RFC 9000(QUIC 传输协议)、RFC 9114(HTTP/3)、RFC 9001(QUIC 中的 TLS)、QUIC 论文 QUIC: A UDP-Based Multiplexed and Secure Transport(Langley et al., 2017)、Chromium QUIC 设计文档、quiche(Cloudflare)与 msquic(Microsoft)实现文档。重传与拥塞控制部分另参考:Van Jacobson《Congestion Avoidance and Control》(SIGCOMM 1988)、Chiu & Jain《Analysis of the Increase and Decrease Algorithms for Congestion Avoidance in Computer Networks》(1989,AIMD 收敛性)、Kleinrock 最优工作点(1979)、RFC 6298(TCP 重传定时器与最小 RTO)、RFC 8985(RACK-TLP 丢包检测,含 Tail Loss Probe)、RFC 3168(ECN)、RFC 2309 与 CoDel/FQ-CoDel(AQM)、Karn & Partridge 重传歧义算法(1987)、Nagle《On Packet Switches with Infinite Storage》(RFC 970,拥塞崩溃早期讨论)、Gettys & Nichols《Bufferbloat: Dark Buffers in the Internet》(2011)。

表格与时间轴口径说明:本文「重传状态模拟」一节的所有时刻与延迟数字,是按各自算法(TCP Reno + TCP_RTO_MIN=200ms、KCP nodelay(1,10,2,1))在给定参数(RTT 60ms、抖动 2ms、50Hz 发包)下手工推演的结果,用于说明机理量级,不是实测数据;实际链路上 ACK 聚合、interval 对齐、内核定时器粒度都会带来偏移。

相关专题:本篇协议选型与 全栈极限低延迟 的接入寻址层(UDP + 自研可靠层)直接关联;底层 I/O 模型见 LVS 与 epoll;零拷贝优化见 零拷贝;UE 引擎在 UDP 之上自建 ARQ(Bunch/ACK/可靠队列)的同类实现见 NetDriver / Channel / Bunch。

自测:合上资料能说清楚吗?

网络为什么会丢包?丢包一定意味着拥塞吗?

参考答案

IP 层的契约只有「尽力而为」——不保证送到、不保证顺序、不保证不重复,而且丢了不通知(ICMP 只覆盖极少数情况且公网常被过滤)。丢包来源分两类:拥塞性——路由器队列满,尾丢弃(tail drop)或 AQM 提前丢;非拥塞性——无线信道误码 CRC 失败整帧丢(4G/5G/WiFi 的主要来源)、IP 分片中任一片丢导致整包报废、网卡 ring buffer / socket 缓冲溢出、运营商 QoS 策略性丢弃、校验和错误静默丢弃。

所以丢包不等于拥塞。TCP 把所有丢包一律当拥塞信号,在无线网络里会因为信道误码白白降速——这正是 BBR(改用带宽/RTT 拐点作信号)和 KCP(干脆允许 nc=1 关掉拥塞控制)都在绕开的同一个误判。

补可靠性有哪两条路?为什么 TCP 选了 ARQ 而 KCP 会上 FEC?

参考答案

ARQ(重传):发现丢了再补一份,代价只在丢包时付出,但恢复延迟至少一个 RTT。FEC(前向纠错):预先多发冗余包让接收方自己算回来,不丢也一直付带宽,但恢复延迟为 0(不需要往返)。

TCP 选 ARQ 是因为 1980 年代带宽极贵而丢包率不高,「按需付费」明显划算。KCP 上 Reed-Solomon FEC(10:3 之类)、QUIC 社区反复讨论 FEC,是因为这笔账今天反过来了:带宽变成白菜价,而那一个 RTT 是买不到的(光速定死传播时延)。FEC 是「以带宽换延迟」的极端形态——连重传都不等。

发送方怎么知道包丢了?两种检测路径的代价差别有多大?

参考答案

网络不通知,所以只有两条路:① 超时重传(RTO)——什么都没回来,等满 RTO;慢,但总能兜底,是最后防线。② 快速重传(重复 ACK / KCP 的 fastack)——后面更高序号的包已经确认了,说明它被跳过;快,约一个 RTT,但尾包丢失时凑不出重复 ACK,用不上。

代价差一个数量级:快速重传只把 ssthresh 减半、cwnd 减半(还留着,快速恢复);超时重传把 cwnd 打回 1、重回慢启动,并且 RTO 退避。所以「尽量走快速重传、别掉进 RTO」不只是省等待时间,它决定丢包后你还能不能保持发送速率。KCP 把 resend 设 2、最小 RTO 压到 30ms,本质都是扩大快速重传能覆盖的比例,把流量挡在 RTO 之外。

为什么尾包丢失特别贵?TLP 怎么救?

参考答案

恰好是这一批最后一个包丢了,后面没有包能产生重复 ACK,凑不出快速重传,只能干等一个完整 RTO。游戏指令、RPC 这类小批量请求-响应流量,几乎每次交互的最后一个包都踩在这个坑上。

TLP(Tail Loss Probe,RFC 8985 RACK-TLP)的解法:约 2×SRTT 后主动发一个探测包,把「只能等 RTO」重新变回「能走快速重传」。所以「TCP 尾包必等 200ms」是旧默认下的结论,新内核已缓解,但仍慢于 KCP 的低 RTO(RTT 60ms 场景:TCP 旧默认 230ms、TCP+TLP 150ms、KCP nodelay 100ms)。

RTO 为什么要设下限?Linux 的 200ms 什么时候才真正咬人?

参考答案

RTO 太小会伪重传(spurious retransmission):网络只是抖了一下、ACK 晚到几毫秒,你已经重发了——白花带宽,还白白把 cwnd 砍掉一半。太大则丢包恢复慢。所以设下限:RFC 6298 建议最小 1 秒(保守到极致),Linux TCP_RTO_MIN 取 200ms(基本写死在内核里),KCP 普通模式 100ms、nodelay 模式 30ms。

咬人的条件是小 RTT:Jacobson 公式 srtt + 4·rttvar 在 RTT 60ms、抖动 5ms 时算出来只需约 80ms,TCP 却被下限强行抬到 200ms,KCP 就用 80ms。RTT 越小,这个下限造成的浪费越大——而手游、同城机房恰好都是小 RTT 场景。

另有 Karn 算法:重传过的包不参与 RTT 采样(收到 ACK 时分不清确认的是原包还是重传包,即重传歧义,采样会被污染),RTO 只靠退避维持;KCP 的 ikcp_update_ack 同样遵循。

没有拥塞控制会发生什么?1986 年那次事故的机理是什么?

参考答案

会形成正反馈:发送方按自己想要的速率发 → 瓶颈队列堆积 → 队列满开始丢包 → ACK 收不到 → 超时重传 → 链路上包更多了但多出来的全是副本 → 更堵。真实数字:1986 年 NSFNET 主干从 32 kbps 掉到 40 bps,链路没坏,是所有人都在重传。

关键区分是 throughput ≠ goodput:崩溃时链路不空闲,它很忙——忙着搬运重复的包,链路利用率接近满而有效吞吐几乎归零。这就是拥塞崩溃(congestion collapse),也是 1988 年 Van Jacobson 那篇《Congestion Avoidance and Control》的直接动机。拥塞控制保护的是 goodput,不是链路利用率。

knee 和 cliff 分别是什么?谁工作在哪里?

参考答案

knee(膝点):吞吐刚好打满,队列还没堆起来,延迟仍是物理下限——BBR 瞄准这里(目标在途量 = BDP)。knee 与 cliff 之间:吞吐不再涨,多出来的全变排队延迟——CUBIC/Reno 稳态就在这段,因为它必须等丢包才知道退,而丢包要求队列先被填满。cliff(崖点):队列溢出、大量丢包、重传副本涌入,goodput 塌方——没有拥塞控制时的终点。

拥塞控制的全部工作:在不知道瓶颈在哪、也没人告诉你的前提下,把负载稳在 knee 附近,绝不走到 cliff。

为什么不把拥塞控制交给路由器?折中方案是什么?

参考答案

四个障碍:① 端到端原则——网络核心保持简单无状态,复杂性放到端点,这是互联网能长这么大的结构前提;② 状态爆炸——骨干路由器同时承载数百万条流,逐流保存速率状态并做公平调度,转发面吃不下;③ 路径不唯一——一条流沿途跨多个自治域,谁说话算数、谁保证一致;④ 信任问题——端点也不能盲信可伪造的中间设备指令。

折中是路由器只给极少提示,决定权仍在端点:AQM/RED(含 CoDel、FQ-CoDel)在队列刚开始堆积时提前随机丢一个包,让发送方早点收到信号,而不是等队列满了集体丢(避免全局同步);ECN(RFC 3168)不丢包,只在 IP 头打一个 CE 标记(一个 bit)说「我快堵了」,由接收方回传——用一个 bit 替代「丢一个包」这种昂贵报警,DCTCP、BBRv2/v3 都用它。

AIMD 为什么会收敛到公平?它的公平在什么条件下失效?

参考答案

乘性减按比例砍——占得多的那条被砍掉的绝对量更大;加性增又给每条流加同样多。差距每轮被压缩,收敛到均分(Chiu & Jain, 1989 的数学结论)。举例两条流 80/20:各 ×0.5 → 40/10(差距 60→30),各 +10 → 50/20;再来一轮 → 25/10 → 35/20(差距 15),不断折半。对比:加性增+加性减永远保持初始差距;乘性增+乘性减比例不变、不收敛。

失效条件:公平只在同 RTT + 同算法下成立。RTT 短的流每单位时间加得更频繁,天然抢得更多(RTT 不公平,跨国流天生吃亏);算法不同也不公平(BBR vs CUBIC)。而 KCP nc=1 干脆退出这个游戏——规则是给守规矩的人用的,所以它在可控网络里是收益、在公网大规模部署就是挤占(搭便车,前提是别人还在守规矩)。

RTT 60ms、50Hz 发包,中途丢一个包,TCP 和 KCP 各慢多少?KCP 有队头阻塞吗?

参考答案

中途丢包走快速重传,KCP 只赢约 20ms:TCP 要等 3 个重复 ACK,KCP resend=2 只等 2 个,省下的就是一个发包间隔(20ms)。那个真丢的包端到端约 TCP 150ms / KCP 130ms。

KCP 一样有队头阻塞——它是单流有序可靠:后面的 P4 P5 P6 早就到了,仍然被扣住等 P3 补上(分别被连坐 100ms / 80ms 量级)。KCP 只是把「扣住的时长」缩短,没有取消扣住。要真正不连坐,得靠 QUIC 的独立 Stream,或者把这类数据走不可靠通道(最新状态覆盖旧状态,旧的丢了就别管)。

所以 KCP 真正的战场不是快速重传路径,而是 RTO 路径:尾包丢失 TCP 旧默认 230ms vs KCP 100ms;连丢三次累计等待 TCP 1400ms(200→600→1400,×2 退避)vs KCP 普通 700ms vs KCP nodelay 332ms(70→175→332,×1.5)。指数退避是「TCP 生产长尾延迟」最直接的算术来源。源码对应:nodelay=0 时 segment->rto 约翻倍、nodelay=1 时 segment->rto += segment->rto / 2,且退避记在每个 segment 自己的 rto 上,某个包连丢不拖累其他包。

为什么不能靠加大路由器缓冲来避免丢包?

参考答案

两条都在往反方向走:① 超过 BDP 的那部分缓冲全部变成排队延迟,一点吞吐都换不到——这就是 bufferbloat,设备厂商为了「少丢包」把缓冲做大,换来的是延迟。② 基于丢包的拥塞控制必须等队列满了才收到信号,缓冲越大报警越晚、延迟坏得越深。

所以「少丢包」和「低延迟」在这里是直接冲突的目标,加大缓冲同时恶化了两件事里更贵的那件。正确方向反而是小缓冲 + AQM(提前丢/打标记),或者干脆换信号——BBR 用带宽/RTT 拐点,根本不等队列满。

KCP 在协议栈的哪一层?它解决了 TCP 的什么问题?

参考答案

KCP 是运行在 UDP 之上的用户态 ARQ 库,无 IETF 标准,通常称"L4.5"或"应用层可靠传输"。它解决 TCP 的实时性问题:TCP 拥塞控制以吞吐量为优先,RTO 最小 200ms,丢包时队头阻塞导致延迟飙升;KCP 通过无 Nagle + 快速重传(2 跳过即触发)+ 最小 RTO 低至 30ms + RTO 退避只 ×1.5 + 选择性重传 + UNA 捎带确认 + 可关拥塞控制,以多消耗 10%~20% 带宽换取更低、更稳定的端到端延迟。

QUIC 解决了 TCP 的哪三个核心问题?各自的解法是什么?

参考答案

① 队头阻塞:QUIC 在 UDP 上实现独立 Stream,每个 Stream 有独立流控与重传,一个 Stream 丢包不阻塞其他 Stream(TCP 字节流无法做到)。② 握手延迟:QUIC 把传输握手与 TLS 1.3 握手合并,首次连接 1-RTT,会话恢复 0-RTT(第一个包即携带应用数据)。③ 连接迁移:QUIC 用 Connection ID 标识连接,与 IP/端口无关,手机切换 WiFi/4G 后 IP 变化,CID 不变,连接无缝迁移不断开。

KCP 和 QUIC 各自的主要劣势是什么?

参考答案

KCP 劣势:带宽消耗高 10%~20%(激进重传);nc=1 时无拥塞控制约束,大规模部署对公网不友好;无标准化(无 RFC);无内置加密;需自行管理连接生命周期与心跳;conv 虽提供会话迁移基础但无路径验证,迁移完整性弱于 QUIC;UDP 可能被运营商 QoS 限速。QUIC 劣势:UDP 可能被防火墙/运营商限速或封锁;用户态加密 CPU 开销高于内核 TCP;实现复杂;0-RTT 存在重放攻击风险,需应用层幂等保护。

FPS 游戏实时同步和移动端弱网登录,分别选哪个协议?为什么?

参考答案

FPS 实时同步选 KCP:延迟极致优先,可接受多消耗带宽;完全可控,可针对小包高频场景调优;无需标准化互操作。移动端弱网登录选 QUIC:需要标准化与浏览器互操作;移动端网络切换频繁,Connection ID 连接迁移价值大;0-RTT 对首屏体验提升明显;内置 TLS 1.3 安全合规。

gQUIC 和 IETF QUIC 有什么区别?

参考答案

gQUIC 是 Google 2012 年的私有实验版本,协议细节由 Google 自定义,与 IETF QUIC 不兼容,已废弃。IETF QUIC(RFC 9000,2021)是经 IETF 标准化的版本,集成 TLS 1.3(RFC 9001),HTTP/3(RFC 9114)在其上运行,是现行标准。面试中提到 QUIC 默认指 IETF QUIC。

最近更新: 2026/9/10 11:38
Prev
全栈极限低延迟(物理层→应用层)