
做网络性能调试这些年我最常说的一句话是不要相信感觉要相信抓包。很多看起来像带宽不够的问题实际查下来往往卡在TCP拥塞窗口也就是 cwnd 上。cwnd 是 TCP 给自己画的那条“在途数据上限线”决定了每个往返能往链路上扔多少数据也直接决定了吞吐能跑多高。可这条线到底怎么涨、怎么跌、什么情况下会卡住光盯着吞吐率根本看不出来必须直接去验证 cwnd 的变化过程。这篇文章我会用两套 Linux 网络命名空间搭一个可控测试环境通过 tc netem 设置延迟和丢包用 ss、tcpdump、iperf3 组合观测把慢启动、拥塞避免、快速恢复和超时重传几个典型场景下的 cwnd 变化完整跑一遍。整个过程不依赖云主机或多台物理机一台 Linux 机器就能复现。适合刚接触 TCP 调优的运维同学也适合折腾网络协议栈、想验证自己改过参数的开发者。读完你至少能回答三个问题cwnd 到底怎么涨丢包时它怎么跌ss 输出的 cwnd 字段到底靠不靠谱。1. 拥塞窗口验证为什么值得专门做一轮1.1 先搞清 cwnd 在 TCP 里到底管什么TCP 的发送窗口并不是一个独立概念实际发送窗口是min(cwnd, rwnd)。rwnd 是接收方通告的流量控制窗口告诉发送方“你最多发这么多我缓冲区装得下”cwnd 则是发送方根据网络拥塞状态自己定的负载上限告诉系统“现在链路看起来能扛住这么多数据”。如果 cwnd 太大突发数据会把中间链路设备的缓存打满随即开始丢包丢包又引发重传和窗口收缩吞吐反而崩掉。如果 cwnd 太小每个 RTT 只能发出去一小点数据剩余时间全在等 ACK链路利用率极低。这也是拥塞控制算法被称为“带宽探测算法”的原因它本质上是通过缓慢放大发送窗口去试链路的可用容量试到拥塞信号再收手。Linux 默认的 CUBIC 拥塞控制在最常见的稳定场景里大致遵循这套逻辑慢启动阶段每个 RTT 让 cwnd 翻倍cwnd 达到 ssthresh 之后进入拥塞避免每个 RTT 大约只增加一个 MSS收到 3 个重复 ACK 触发快速重传ssthresh 更新为当前 cwnd 的一半cwnd 也降下来如果触发超时重传说明链路可能严重拥塞cwnd 直接回落到 1重新走慢启动。1.2 验证 cwnd 不是在验证一个数字而是验证一套行为很多人会把“验证 cwnd”理解成看一眼 ss 输出的 cwnd 值是多少。实际上单独一个数字没有意义有意义的是它随时间变化的轨迹。比如你把初始窗口 initcwnd 从 10 改成 32想知道效果怎么样不能只看最终吞吐而要看连接建立后的第一个 RTT 内是不是真的发出了 32 个数据段再比如你把tcp_slow_start_after_idle关掉之后空闲连接重新恢复发送时 cwnd 是否还保留着原值再比如切换拥塞控制算法之后窗口是持续爬升还是围绕某个阈值震荡。这些都属于行为验证需要把 cwnd 采样成一条曲线而不是取一个快照。还有一点容易被忽略有时候你做完优化吞吐确实提高了但 cwnd 根本没有按你预期的方式变化反而被接收窗口 rwnd 卡住了。也就是说提升未必来自你的优化点。不验证 cwnd你连功劳属于谁都分不清。1.3 别拿丢包率和重传次数代替窗口判断很多监控面板上只有tcp_retrans、drop这类结果指标它们能告诉你“网络已经出事了”但很难告诉你“网络正在出事的路上”。cwnd 是发送端行为的前置原因而丢包和重传是拥塞发生之后的结果。看 cwnd 的好处在于你可以在链路真正打满之前就看到窗口增长速度异常比如明明带宽延迟积很大cwnd 却早早停在了一个低值不再上涨。这种问题用带宽和丢包率都不容易定位直接用 ss 看 tcp_info 里的 cwnd 和 ssthresh 最直观。2. 搭建一套能反复折腾的验证环境2.1 为什么用网络命名空间而不是多台机器要验证拥塞窗口最怕两种情况一是环境不可控物理链路上什么背景流量都有实验结果没法归因二是环境太重每次测试都要申请设备、接网线改一次参数等半天。用 Linux 网络命名空间加 veth 接口可以在一台机器内部模拟出两个独立主机而且 netem 可以随便在虚拟接口上注入延迟和丢包改配置、重跑实验都非常快。你在生产环境里不敢乱做的操作在这个环境里可以反复折腾。我的建议是先把结果做稳定再去物理环境验证能省掉大量弯路。建环境只需要几条命令ip netns add sender ip netns add receiver ip link add veth-s type veth peer name veth-r ip link set veth-s netns sender ip link set veth-r netns receiver ip netns exec sender ip addr add 10.0.0.1/24 dev veth-s ip netns exec receiver ip addr add 10.0.0.2/24 dev veth-r ip netns exec sender ip link set veth-s up ip netns exec receiver ip link set veth-r up ip netns exec sender ip route add 10.0.0.0/24 dev veth-s src 10.0.0.1 ip netns exec receiver ip route add 10.0.0.0/24 dev veth-r src 10.0.0.2做完之后先 ping 一下ip netns exec sender ping 10.0.0.2通了就意味着两台“虚拟主机”已经互连。后面所有验证都在这个隔离环境里做不怕影响外部网络。2.2 netem在出方向上制造延迟和丢包netem 是 tc 的一个 qdisc作用在接口的出方向。这里有个容易踩的坑如果放在veth-s上影响的是 sender 发往 receiver 的数据包放在veth-r上影响的是 receiver 发往 sender 的包也就是 ACK 方向。我们验证的主要是发送端也就是 sender 的 cwnd所以丢包必须加在 sender 的出口上ip netns exec sender tc qdisc add dev veth-s root netem delay 20ms loss 1%如果只想给数据方向加延迟这么配就够了。但实际网络里 ACK 返回也有延迟所以想让 RTT 更接近真实场景最好在 receiver 方向也加一段延迟ip netns exec receiver tc qdisc add dev veth-r root netem delay 20ms这样数据包出 sender 时延迟 20msACK 出 receiver 时也延迟 20ms一来一回 RTT 大约就是 40ms。需要清空配置时用tc qdisc del dev veth-s root和tc qdisc del dev veth-r root。netem 的 loss 参数是按包概率随机丢不是按连接丢。这个特性后面会影响实验结果如果丢包概率太高TCP 还没形成足够的重复 ACK 就直接走超时重传了如果概率太低可能跑完整个测试也只丢了一两个包看不出窗口变化。2.3 ss、tcpdump、iperf3 的分工验证 cwnd 需要三个工具配合各看不同的层次工具观测内容适用场景ss -tin当前连接的内核 tcp_info包括 cwnd、ssthresh、rtt、重传次数直接看发送端窗口状态最常用tcpdump数据包序列号、ACK 号、窗口字段、时间戳还原交互细节确认事件发生的具体时机iperf3带宽、重传率、延迟抖动等宏观指标产生稳定的持续流量验证最终效果ss 要加-i参数才会输出 tcp_info 里的窗口信息。实际采样时我会用一个循环脚本把时间戳和窗口值一起记录下来while true; do echo --- $(date %H:%M:%S.%N) ---; ss -tin | grep -E 10.0.0.1:.*10.0.0.2; sleep 0.2; done0.2 秒的采样间隔对肉眼观察足够如果嫌不够细可以改成 0.05 秒但要注意终端打印本身会有延迟。iperf3 的启动命令也很简单一端起服务一端打流ip netns exec receiver iperf3 -s ip netns exec sender iperf3 -c 10.0.0.2 -t 15这里有个关键点iperf3 客户端是 sender因为 cwnd 是发送端的行为你把打流方向搞反了看到的窗口就是反方向的。3. 实操验证慢启动和拥塞避免时的窗口增长3.1 先跑基线干净链路下看 cwnd 和 ssthresh开始之前先不加 netem 延迟和丢包跑一个干净链路下的基线。你会发现慢启动完成得非常快快到普通轮询根本抓不住细节。因为 veth 链路本身的 RTT 可能只有几十微秒一个连接从建立到把窗口打满可能还不到一毫秒。所以为了观察慢启动的逐 RTT 增长必须先把 RTT 拉大。我们在两个方向分别加 20ms 延迟让 RTT 稳定在 40ms 左右然后再启动 iperf3。40ms 一个来回窗口增长过程就有了可观测的时间跨度。基线数据大概是这样10:00:01.120 cwnd:10 ssthresh:1000 rtt:0.040 10:00:01.160 cwnd:20 ssthresh:1000 rtt:0.040 10:00:01.200 cwnd:40 ssthresh:1000 rtt:0.040 10:00:01.240 cwnd:80 ssthresh:1000 rtt:0.040ssthresh 初始值在没有任何丢包时往往很高Linux 默认可能达到几百甚至上千个 MSS。所以在一个干净链路上慢启动可以一路翻倍到接收窗口允许的上限根本不会触发“到 ssthresh 就减速”的机制。这也解释了一个常见误区很多人以为慢启动一定会过渡到拥塞避免实际上只有当 ssthresh 足够低或者链路有拥塞信号时拐点才出现。3.2 验证 initcwnd把初始窗口改成 4、8、10 会怎样初始窗口是每个 TCP 连接在慢启动开始时的 cwnd 值。Linux 默认通常是 10也就是 10 个 MSS约 14600 字节。这个值不是内核参数直接写的而是路由属性所以要改的话得用 ip routeip netns exec sender ip route replace 10.0.0.0/24 dev veth-s src 10.0.0.1 initcwnd 4修改之后必须重新建立 TCP 连接才生效。你可以把 iperf3 服务端停掉重启或者换一个端口然后重新抓 ss10:01:00.000 cwnd:4 ssthresh:1000 rtt:0.040 10:01:00.040 cwnd:8 ssthresh:1000 rtt:0.040 10:01:00.080 cwnd:16 ssthresh:1000 rtt:0.040这个实验的意义不在于比较 4 和 10 哪个快而在于让你确认“修改确实写进了路径选择逻辑”。我在实际测试里遇到过改完 route 但连接没重建结果看了一整屏 cwnd:10还以为配置失效了。初始窗口这种参数只对新建连接有效改完必须重连这是第一个要注意的细节。3.3 找到从慢启动进入拥塞避免的拐点前面说了默认 ssthresh 很高直接跑根本看不到拐点。想让 cwnd 爬到 ssthresh 附近然后放缓最简单的方法是先人为制造一次丢包把 ssthresh 打下来再让连接重新经历慢启动。具体操作分两步。先在 sender 的 veth-s 上加 5% 丢包跑几秒 iperf3让连接出现重传此时内核会把 ssthresh 更新成丢包时 cwnd 的一半左右。然后删掉丢包配置重新建立连接这一次 cwnd 就会从初始窗口开始一路翻倍爬到残留的 ssthresh 附近然后进入每次只增加一两个 MSS 的慢速增长阶段。假设之前的丢包把 ssthresh 压到了 32重新建连后你会看到类似这样的序列时间点cwndssthresh阶段判断第一个 RTT1032慢启动第二个 RTT2032慢启动第三个 RTT3232到达阈值第四个 RTT3332拥塞避免第五个 RTT3432拥塞避免从 cwnd 等于 ssthresh 之后增长率会明显放缓这就是慢启动到拥塞避免的拐点。4. 实操验证丢包、快速恢复和超时把窗口打下来4.1 轻丢包触发快速重传快速重传不只有 cwnd 折半把 sender 的丢包率设在 1% 左右同时保持 RTT 40ms然后跑 iperf3。cwnd 足够大之后只要丢一个包接收方就会连续收到三个重复 ACK触发快速重传。用 tcpdump 抓包的时候你会看到发送端收到第三个重复 ACK 之后立刻重传了丢掉的包这本身不稀奇。关键是同时打开 ss 看 cwnd 和 ssthresh 的变化。CUBIC 的快速恢复并不是简单地把窗口对半砍一刀ssthresh 会更新为丢包时 cwnd 的一半cwnd 则先降到这个值附近然后随着重复 ACK 的确认逐步恢复。我实际抓到的典型序列是这样的10:02:00.120 cwnd:200 ssthresh:200 10:02:00.140 cwnd:100 ssthresh:100 10:02:00.160 cwnd:105 ssthresh:100 10:02:00.180 cwnd:110 ssthresh:100这里有个经验如果丢包率过低或者 cwnd 还太小快速重传可能根本不会被触发。因为触发条件需要接收方产生足够多的重复 ACK窗口太小的时候丢一个包只能干等到 RTO。所以做快速重传实验前提是先让 cwnd 涨到几十以上。4.2 重丢包触发 RTOcwnd 回到 1 的代价有多大把丢包率直接拉到 10% 以上情况会完全不同。TCP 可能连三个重复 ACK 都凑不齐因为后续的数据包也丢了接收方的确认一直无法推进最终触发 RTO 超时重传。超时重传对窗口的惩罚极为彻底ssthresh 降为当前 cwnd 的一半cwnd 直接回到 1。这意味着下一条数据要重新从慢启动开始先以 1 个 MSS 的窗口发送等一个 ACK再变 2 个 MSS再等一个 ACK。在 RTT 为 40ms 的链路上想把窗口重新爬到 100至少需要好几个 RTT这段时间里吞吐几乎为零。ss 里最明显的信号是10:03:00.000 cwnd:1 ssthresh:80 10:03:00.040 cwnd:2 ssthresh:80 10:03:00.080 cwnd:4 ssthresh:80看到cwnd:1的时候基本可以确认发生了 RTO。配合 tcpdump 看时间戳通常能找到一个超过 RTO 的“静默窗口”后续跟着一个从旧序号开始的重传包。4.3 快速恢复和超时重传的状态对照把两类事件放在一起对比窗口行为差异非常清晰触发场景触发条件ssthreshcwnd 变化是否重新慢启动快速重传收到 3 个重复 ACK约为原 cwnd 的一半降到一半左右随后随 ACK 回升否超时重传RTO 到期约为原 cwnd 的一半直接回到 1是这个差异对实际业务影响很大。比如很多短连接请求握手之后要发送的数据可能只有几十 KB如果中途触发一次超时cwnd 被打回 1那么后续几个 RTT 里都恢复不了速度整个请求的延迟都会被拖垮。这也是为什么很多传输优化会把初始窗口调大或者关闭空闲连接后的慢启动重置目的就是尽可能避免让后续请求再次经历从 1 开始爬坡的过程。5. 验证维度扩展结合拥塞控制算法看 cwnd 行为5.1 把 CUBIC 换成 BBR 后观察什么窗口验证如果只停留在默认算法上会漏掉一个重要视角不同拥塞控制算法对 cwnd 的操作逻辑完全不同。CUBIC 是典型的“丢包反馈型”窗口增长不受 RTT 影响太大丢包之后会产生明显的锯齿状曲线。而 BBR 的核心思想是主动测出带宽和最小 RTT然后以略高于瓶颈带宽的速率发送不依赖丢包来降速。所以在 BBR 连接里你看到的 cwnd 常常不是一条上下乱跳的锯齿线而是一个相对稳定、接近带宽延迟积两倍左右的上限值。切换算法可以用命名空间里的 sysctlip netns exec sender sysctl -w net.ipv4.tcp_congestion_controlbbr如果内核没有加载 BBR会提示错误先执行modprobe tcp_bbr再试。重启 iperf3 之后同样用 ss 观察你会看到窗口行为与 CUBIC 明显不同即使在有一定丢包的链路上cwnd 也不会动不动对半砍吞吐曲线更加平稳。5.2 算法验证场景适合什么样的测试脚本对比实验里脚本设计比命令本身更重要。我的建议是固定四类变量固定净荷大小、固定 RTT、固定丢包率、固定并发流数。每次只改拥塞控制算法这一项然后记录 cwnd、ssthresh、吞吐量和重传次数最后画在同一张表里对比。一个简单的做法是分别把 CUBIC 和 BBR 的测试结果存成两个文件再用 grep 提取关键指标ip netns exec sender ss -tin | grep -E cwnd|ssthresh|bytes_acked需要注意的是BBR 对发送速率控制依赖于 pacing 机制如果网络接口上没有合适的 qdisc实际表现会打折扣。对于纯窗口验证来说CUBIC 因为控制逻辑更“规则”更容易复现出教科书式的曲线所以新手验证建议还是从 CUBIC 开始把 BBR 放在第二步作为对比项。6. 验证 cwnd 过程中的常见问题与排查技巧6.1 ss 看不到 cwnd或者一直为 0最可能的原因是没有加-i参数普通ss -tn不带 tcp_info看不到窗口信息。其次是抓错了命名空间cwnd 属于发送端必须进到 sender 命名空间里执行 ss你人在宿主机上看到的是宿主机自己的连接状态。还有一种情况是连接状态不对比如只建立了 TCP 连接但完全没有发送数据这时候 cwnd 虽然存在但很多 ss 版本不会把它打印出来或者显示为 0。让连接有持续流量最简单的方式就是跑 iperf3只要数据在流动cwnd 就一定会持续更新。6.2 netem 配置了丢包但快速重传就是不出现这个问题我遇到过不止一次。排查方向主要有两个。一是丢包位置放错了方向。如果你的 netem 加在 receiver 的 veth-r 上它影响的是 ACK 方向。ACK 丢包会迫使发送端等 RTO而不是触发快速重传表现完全不对。二是丢包率设置得太早太高。如果从连接一建立就开始 10% 丢包cwnd 很可能一直停留在几个 MSS 的级别连三个重复 ACK 都凑不齐。更合理的做法是先让 cwnd 涨上去再在流量运行到中途时注入丢包。tc 的 qdisc 支持动态修改ip netns exec sender tc qdisc change dev veth-s root netem delay 20ms loss 2%把这条命令放在后台任务里延迟三秒执行让 iperf3 先跑一段等 cwnd 比较大了再丢包快速重传就会稳定出现。6.3 cwnd 看着正常带宽却上不去的隐藏瓶颈这种问题最迷惑人因为 cwnd 一直在涨ssthresh 也正常可吞吐就是上不去。这时候要把视野从 cwnd 挪到 rwnd 上发送窗口是min(cwnd, rwnd)如果接收方的 rwnd 很小cwnd 再涨也没用。举一个例子假设 RTT 是 10ms接收窗口被应用限制在 64KB那么理论最大吞吐大约是 64KB 除以 0.01 秒也就是 6.25MiB/s约 50Mbps。链路带宽再高应用层的窗口不放开吞吐上限就被钉死在这。验证方法很简单用 ss 看 rwnd 字段如果它一直保持在一个很小的固定值问题就不在拥塞控制而在接收缓冲区的设置。6.4 一个经常用到的兜底习惯窗口和带宽对表验证 cwnd 时我习惯先把带宽延迟积算出来再和实际观测值对表。公式很简单带宽延迟积等于带宽乘以 RTT。比如 100Mbps 链路RTT 是 40ms先换成字节100Mbps 约等于 12.5MB/s乘 0.04 秒得到约 500KB。如果 MSS 按 1460 字节算那么需要 cwnd 至少达到 343 个 MSS也就是约 343 个包在途才能把这条链路打满。如果实际观测的 cwnd 远低于这个数链路一定跑不满但这不是 cwnd 的错而是限制条件出现在别处比如 rwnd、应用发送缓冲区或者瓶颈带宽本身比预期低。用这套逻辑排查能省掉很多无效抓包。最后说一个我这些年养成的习惯每次改完参数不急着看带宽先开几个终端左边看ss -tin中间看 tcpdump右边跑 iperf3把时间戳对齐了再去解释。很多看似诡异的性能问题最后都落在一句话上窗口没有按你预想的方式变化。协议栈不会说谎会出错的往往是没对齐的观察窗口和没有做交叉验证的结论。验证 TCP 拥塞窗口这件事花半小时搭好环境之后能给你省下几天的排查时间。