
最近帮某公司的线上业务排查一个传输问题两台主机在同一个内网链路质量各项指标都很好可大文件传输的速率始终上不去。一开始怀疑磁盘 IO、怀疑网卡中断后来用监控脚本抓了一段 TCP 会话的原始参数发现发送端的拥塞窗口cwnd一直压在一个很低的水平链路带宽再大也无济于事。这个排查过程让我意识到很多工程师对 TCP 握手、重传、序列号这些概念张口就来可真要回答拥塞窗口当前是多少、它是怎么长大的、遇到丢包又是怎么回退的反而不知道该从哪里下手。这篇文章想把验证拥塞窗口的一套方法完整记录下来。它适合三类人一是做网络排查却总靠猜的开发者二是做网络协议课程实验的学生三是做性能优化时需要用数据说话的运维工程师。读完你可以直接把文中的脚本和实验步骤带回去在一台测试机上跑通亲眼看着窗口曲线涨落而不是停留在理论上。1. 拥塞窗口验证到底要验证什么1.1 拥塞窗口是发送端的未确认预算TCP 不会把数据一次性全丢到网络里而是有一个已发送但还没收到确认的上限这个上限就是拥塞窗口。你可以把它理解成餐厅服务员一次最多能端几个托盘每端出去一桌菜就要等其中一个托盘空出来收到 ACK才允许往厨房再端一个新托盘。如果这个上限很小哪怕厨房备菜再多、传菜通道再宽实际送出去的菜量也就那么多。这里有个容易混的点拥塞窗口的单位。在内核代码和ss输出里cwnd 很多时候以 MSS最大报文段长度为单位你看到cwnd:20并不代表 20 字节而是 20 个报文段。按常见以太网 MSS 1448 字节算实际大约是 28KB。很多人在验证时把单位搞错算出来的理论吞吐就会差得很离谱。真正决定 TCP 发送速率的不是某一个窗口而是三个限制条件取最小值拥塞窗口 cwnd发送端根据网络情况自我限制的未确认预算接收窗口 rwnd接收端告诉发送端我的缓冲区还能收多少发送缓冲区 sndbuf本机 socket 里还能排队多少待发数据验证拥塞窗口时最终要回答的其实是当前连接的实际发送速率究竟是被哪一个限制卡住了只有先把这个问题搞清楚后续优化才有的放矢。1.2 验证的就是慢启动、拥塞避免和快速恢复这三段生命期一条新 TCP 连接的窗口变化可以分为几个阶段慢启动阶段cwnd 从初始窗口Linux 默认 initcwnd 通常是 10开始每收到一个 ACK 就把 cwnd 加一个 MSS所以一个 RTT 周期内窗口基本会翻倍呈指数增长。拥塞避免阶段cwnd 超过慢启动阈值 ssthresh 后改为每个 RTT 只加一个 MSS呈线性增长用来小心试探链路剩余容量。快速恢复/重传阶段发生丢包时ssthresh 会被降到当前 cwnd 的一半cwnd 则视触发条件重置到 ssthresh 附近然后重新向上爬。所以验证工作不是看一眼数值那么简单而是要把上述生命周期完整复现先看到指数增长那段陡峭曲线再看到触达阈值后转为平缓再人为制造丢包看它按预期跌回去。只有这条曲线符合预期才能证明 TCP 拥塞控制在正常工作如果曲线和理论对不上那才是值得深挖的问题。1.3 验证前先确认的三个前置条件正式开跑之前有三个前置条件不满足后面采到的数据基本没有参考价值。第一连接要足够长。至少持续 30 秒以上太短的连接可能刚看完慢启动就结束了看不到拥塞避免阶段的平稳段。第二发送端要持续有数据推给内核。如果应用层写数据是间歇性的TCP 会进入 app-limited 状态这时 cwnd 再大也发不出数据采样结果会把应用没喂饱误判成窗口太小。第三实验环境要干净。避免同时跑其他大流量任务尤其不要在实验机上开着视频会议、同步盘这类东西干扰一旦混进去后面的曲线解读会非常费劲。判断发送端是不是 app-limited可以看ss -ti输出里有没有 app-limited 标记或者观察bytes_acked的增速是否稳定。只要这个标记出现就说明当前窗口演化不代表链路真实能力需要把发送循环改成密集推送再继续。2. 把 cwnd 从 Linux 内核里抓出来的几种方式2.1ss -tin一条命令抓窗口快照最快的方式是直接看ss输出的 TCP 状态详情。假设发送端是 192.0.2.10对端是 192.0.2.20 的 5201 端口可以这样查ss -tin dst 192.0.2.20 | head -8输出大致长这样State Recv-Q Send-Q Local Address:Port Peer Address:Port ESTAB 0 0 192.0.2.10:39010 192.0.2.20:5201 cubic cwnd:32 ssthresh:18 rto:204 rtt:1.02 rcv_space:14600 rcv_ssthresh:14600你需要关心的字段有几个cwnd当前拥塞窗口注意单位是 MSSssthresh慢启动阈值也是以 MSS 为单位rtt平滑往返时延后面换算理论吞吐时要用它rto重传超时时间丢包严重时这个值会变大ss适合快速确认当前状态但它是一次性快照你手动敲命令看到的只是某一瞬间的值。要画出完整的窗口变化曲线还得靠高频采样。2.2 应用内部用 getsockopt(TCP_INFO) 程序化采样比ss更精准的方式是在业务程序内部直接读取 socket 的 TCP_INFO 数据。这样做有几个好处不需要 root 权限不用解析命令行输出而且采样的进程和发送进程是同一个能保证采样时连接一定是活跃的。Python 里可以用getsockopt加上TCP_INFO选项取到内核的struct tcp_info然后按字段偏移解出 cwnd。下面是一个最小的读取函数import socket import struct TCP_INFO 11 def read_tcp_state(sock): raw sock.getsockopt(socket.IPPROTO_TCP, TCP_INFO, 256) return { cwnd: struct.unpack_from(I, raw, 80)[0], ssthresh: struct.unpack_from(I, raw, 76)[0], rtt_us: struct.unpack_from(I, raw, 68)[0], retrans: struct.unpack_from(I, raw, 36)[0], bytes_acked: struct.unpack_from(Q, raw, 120)[0], }这段代码的关键是字段偏移量在主流 Linux 内核的tcp_info结构里tcpi_rtt在偏移 68 字节处tcpi_snd_ssthresh在 76 字节处tcpi_snd_cwnd在 80 字节处tcpi_bytes_acked在 120 字节处。需要提醒的是这些偏移在内核版本升级后有过变动换内核版本后最好重新核对头文件不要长期依赖死偏移。有了这个函数再开一个线程每 50ms 或者 100ms 调用一次把结果按时间戳追加到 CSV 文件就能拿到一套完整的窗口演进数据。这是后续画图和定量分析的基础。2.3 用 tcpdump 做旁路交叉验证如果程序不方便改也可以直接用 tcpdump 抓包从序列号和确认号反推在飞行中的数据量。原理不复杂TCP 的累计 ACK 表示对端确认到了哪个序号而你本地最大已发送序号和这个 ACK 之间的差值就是已发送但未确认的数据量也就是 inflight。拥塞窗口本质上限制的就是这个 inflight所以抓包后的最大 inflight 近似等于当时的 cwnd 上限。抓包命令很简单tcpdump -nn -i eth0 tcp and host 192.0.2.20 -w tcp_session.pcap然后用 Wireshark 里的 TCP 分析工具或者tshark导出 seq 和 ack自己算差值。这个方法能交叉验证ss结果但解析成本高不适合大规模采样。我一般只在怀疑内核只在某个瞬间发生过一次窗口收缩被采样漏掉时才会翻包确认。3. 设计一个可复现的拥塞窗口观测实验3.1 基线场景单连接持续传输验证的第一步先跑一个最干净的基线场景一台发送主机一台接收主机一条 TCP 连接持续灌数据。我习惯直接用 Python 写发送端脚本因为能在同一个进程里嵌入采样线程比另开一个ss循环更方便。先写接收端它只需要快速收数据并丢弃绝不能让应用层读得慢而拖累接收窗口# server.py import socket s socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((0.0.0.0, 5001)) s.listen(1) conn, addr s.accept() total 0 while True: data conn.recv(65536) if not data: break total len(data) print(recv total:, total)发送端脚本则同时负责采样# client.py import socket import struct import threading import time TCP_INFO 11 def read_cwnd(sock): raw sock.getsockopt(socket.IPPROTO_TCP, TCP_INFO, 256) cwnd struct.unpack_from(I, raw, 80)[0] ssthresh struct.unpack_from(I, raw, 76)[0] rtt_us struct.unpack_from(I, raw, 68)[0] retrans struct.unpack_from(I, raw, 36)[0] return cwnd, ssthresh, rtt_us, retrans def sampler(sock, stop, out): with open(out, a) as f: while not stop.is_set(): cwnd, ssthresh, rtt_us, retrans read_cwnd(sock) f.write(f{time.time():.3f},{cwnd},{ssthresh},{rtt_us/1000:.3f},{retrans}\n) f.flush() time.sleep(0.05) def main(host192.0.2.20, port5001, outcwnd.csv): sock socket.create_connection((host, port)) stop threading.Event() t threading.Thread(targetsampler, args(sock, stop, out)) t.start() chunk bx * (16 * 1024) try: while True: sock.sendall(chunk) except (BrokenPipeError, ConnectionResetError): pass finally: stop.set() t.join() sock.close() if __name__ __main__: main()采样间隔设 50ms30 秒大概能采到 600 个点足够看到慢启动的陡峭曲线和拥塞避免的平缓曲线。注意 CSV 里同时记录retrans后面解读丢包实验时要对照它。3.2 模拟丢包观察窗口回退基线跑通之后第二步是人为制造丢包。Linux 上用tc的 netem 模块就能做到不需要额外硬件tc qdisc add dev eth0 root netem loss 3%这条命令会让 eth0 出方向的报文以 3% 概率随机丢弃。有个细节必须记住netem 的 loss 只对出方向生效所以要想让发送方向的 TCP 流量产生丢包必须把这条规则加在发送端的网卡上。如果加在接收端丢的是回包方向窗口回退效果会不一样。配好之后重新跑一次发送端脚本你会看到 cwnd 不再是一条直线向上的形态而是会在某个点突然下跌。正常情况下发生丢包后 ssthresh 会降到当前 cwnd 的一半cwnd 回退到 ssthresh 附近然后再慢慢增长。如果用的是 CUBIC 算法重新爬升的速度会相对快一些曲线呈锯齿状如果换到 Reno回退后的爬升会更保守。实验做完记得删掉规则tc qdisc del dev eth0 root如果你用的是 BBR 拥塞控制cwnd 的演化逻辑和经典算法不一样丢包时不一定立刻看到减半效果这是正常现象。想验证经典减半行为请先把拥塞控制算法切到 cubic 或 reno 再做。3.3 制造接收窗口瓶颈验证 min(cwnd, rwnd)很多人在验证时只盯着 cwnd却忽略了一个事实TCP 发送速率实际由 cwnd 和 rwnd 的最小值决定。为了证明这一点可以在接收端把接收缓冲区压小制造一个接收窗口瓶颈。在接收端临时设置很小的 rmemsysctl -w net.ipv4.tcp_rmem4096 4096 32768重新建立连接后发送端 cwnd 可能已经增长到很大但吞吐还是上不去。此时抓包的 ACK 里win字段会非常小或者ss里观察不到对应增长。这个实验做完后一定要把内核参数恢复默认值避免影响后续测试sysctl -w net.ipv4.tcp_rmem4096 87380 6291456这个实验的价值在于帮你建立直觉看到窗口大但速度上不去的现象时不能只归咎于拥塞窗口接收窗口和发送缓冲区都是候选瓶颈。4. 采样数据的处理和结果解读4.1 把原始采样整理成可看懂的曲线采到的 CSV 长这个样子1700000000.001,32,18,1.023,0 1700000000.051,34,18,1.010,0 1700000000.101,36,18,0.998,0第一列是时间戳第二列是 cwnd第三列是 ssthresh第四列是 RTT毫秒第五列是累计重传次数。直接用 Python 的 matplotlib 画 cwnd 随时间的变化即可import csv import matplotlib.pyplot as plt ts, cwnd, ssth [], [], [] with open(cwnd.csv) as f: for row in csv.reader(f): ts.append(float(row[0])) cwnd.append(int(row[1])) ssth.append(int(row[2])) plt.plot(ts, cwnd, labelcwnd) plt.plot(ts, ssth, labelssthresh) plt.xlabel(time) plt.ylabel(segments) plt.legend() plt.show()画图时记得把 y 轴单位统一如果 cwnd 是以报文段为单位的最好同时在图上标出换算成字节后的刻度或者在代码里乘上 MSS不然和带宽、BDP 换算起来容易出错。4.2 用 BDP 估算当前窗口到底够不够拿到曲线之后最常做的定量判断是窗口到底有没有大到足以打满链路这里要用到带宽时延积BDP的概念。BDP 链路带宽 × 往返时延。它表示一条连接在途数据最多能有多少如果拥塞窗口远小于 BDP那么无论链路带宽多大实际吞吐都会被窗口卡住。举个例子。假设链路是 100MbpsRTT 是 20ms链路带宽换算成字节100,000,000 / 8 12,500,000 字节/秒BDP 12,500,000 × 0.02 250,000 字节按 MSS 1460 字节算约等于 171 个报文段也就是说要使这条链路跑满cwnd 至少要到 171 个 MSS。如果你采到的 cwnd 长期只有 50约 73KB那么理论速率上限约等于 73KB / 20ms ≈ 29Mbps。这个数字一算出来问题就非常清晰了链路带宽再大跟你无关瓶颈在窗口。反过来如果 cwnd 已经接近甚至超过 BDP但实际吞吐还是上不去那就要去查接收窗口、发送缓冲区或者链路里是否存在隐藏的限速设备。4.3 一张现象速查表把常见现象、结论和下一步动作整理成表排查时对照着看会省很多时间采样现象可能结论下一步动作cwnd 快速翻倍无重传慢启动正常继续观测到拥塞避免阶段cwnd 突然下跌ssthresh 减半发生丢包快速恢复生效对照 retrans 时间点确认丢包时刻cwnd 很大但吞吐低于预期接收窗口或发送缓冲区受限抓包看 ACK 的 win 字段检查 sndbufcwnd 不增长RTT 持续升高链路排队拥塞查看中间设备队列降低发送速率cwnd 长期不增长且 retrans0应用层喂数据太慢确认 app-limited 标记改善发送循环这张表不是万能的但能覆盖八成验证场景。遇到表外的现象基本方向一定是先确认实际瓶颈是在 cwnd、rwnd 还是 sndbuf再往下追。5. 验证过程中容易踩的坑5.1 cwnd 只在发送端有意义这是我见过最多的一个误区。有人跑到接收端机器上去执行ss -i看到接收 socket 的 cwnd 很大或者很小就开始分析运行问题。实际上拥塞窗口是发送端的概念接收端维护的 cwnd 主要用于快速重传、选择确认等内部控制和发送速率没有直接关系。要验证为什么传输慢必须到发送数据的那一侧去采样。最简单的判断方法数据从哪台机器发出就在哪台机器看 cwnd。5.2 采样频率太低慢启动过程直接隐身慢启动阶段窗口是每 RTT 翻倍增长非常快。如果你用 1 秒采一次样本可能上一条还是 cwnd20下一条已经到 cwnd300中间的指数增长过程完全看不到。更糟的情况是如果连接只持续了几秒1 秒采样可能只采到两三个点基本等于没采。我的建议是采样间隔至少 50ms并且在连接建立前就启动采样线程否则会漏掉最开始的几个窗口值。5.3 发送缓冲区悄悄变成新的瓶颈拥塞窗口验证到一半有时会发现 cwnd 曲线完全正常、不低也不频繁回退可吞吐就是上不去。这时候别急着怀疑网络先看发送缓冲区。默认的tcp_wmem最大值可能只有几 MB如果 BDP 大于这个值内核会把实际可用的发送缓冲压住导致即使 cwnd 允许发送socket 里也没有足够多的数据排队。想区分这个瓶颈可以在采样里加一个notsent_bytes字段。它表示内核里已经排队但还没发送出去的字节数如果这个值长期为 0说明发送端应用或者在 socket 写数据时被阻塞了数据根本来不及进入发送队列那就不是 cwnd 的问题。加大tcp_wmem上限通常能缓解sysctl -w net.ipv4.tcp_wmem4096 16384 41943045.4 丢包实验把线上连接搞挂用 netem 做丢包实验时稍不注意会波及线上流量。最安全的玩法是在独立测试网段、虚拟接口或者网络命名空间里做不要在承载生产业务的网卡上直接敲命令。另外3% 丢包对 TCP 来说已经非常严重了可能出现窗口跌到很小甚至短暂停发的情况实验脚本要设置超时保护避免程序因为连接长时间挂起而卡死。还有一个细节丢包实验做完后立刻删 netem 规则同时检查tc qdisc show确认恢复。我见过有人实验做完忘了删后面几天所有经该网卡的流量都带 3% 丢包排查了很久才发现是规则没清干净。5.5 单点数据不能说明趋势cwnd 是一个随时间快速变化的量单个时间点的数值没有任何意义。比如你碰巧在一个拥塞回退刚发生的瞬间采样看到 cwnd18就得出结论窗口太小这完全是误判。正确的做法是至少采样 30 秒以上把整条曲线拿出来看分布中位数是多少最低点多少触顶时间占比多少。判断窗口大小是否正常看的是趋势和分布不是某个瞬间的读数。6. 拿到验证结果之后可以怎么调6.1 调初始窗口对短连接最有效如果你的业务以短连接为主每次连接只在慢启动早期就结束了那调大初始窗口收益会非常明显。Linux 下可以针对路由条目设置 initcwndip route replace default via 192.0.2.1 dev eth0 initcwnd 30 initrwnd 30这个操作只影响之后新建的连接已经建立的连接不会改变。做实验时要注意先跑一条旧连接的采样曲线作为基准再改参数再看新连接的曲线否则你会分不清窗口变化到底是参数生效还是网络环境波动。6.2 换拥塞控制算法而不是只调参数经典算法在丢包环境里的窗口回退非常保守高带宽高延迟链路上光靠调参数很难有明显改善。Linux 支持动态切换拥塞控制算法sysctl net.ipv4.tcp_congestion_controlcubic # 或者 sysctl net.ipv4.tcp_congestion_controlbbr这里有个容易踩的坑修改 sysctl 只影响新建连接已经存在的连接会继续保持旧算法。我见过有人改完配置立刻测原连接发现没效果就以为切换不生效实际上是没重新建立连接。验证算法切换是否成功最简单也是最直接的方式就是重连之后再看ss -ti输出里的算法名字。6.3 让发送缓冲区能覆盖 BDP有时候不是窗口不够大而是 socket 发送缓冲区不够大导致窗口根本没有发挥空间。前面提到的tcp_wmem三个值分别是最小值、默认值和最大值自动调整机制会在默认值和最大值之间动态浮动。如果 BDP 比默认值大不少可以把最大值调得更高比如 16MB 级别让内核有足够的空间扩大发送队列。调整后立刻重测对比bytes_acked的增速曲线。如果发送缓冲确实是瓶颈你会发现曲线在某一时刻之后明显变陡吞吐上限上了一个台阶。6.4 把验证脚本沉淀成回归工具验证拥塞窗口这件事最有价值的不是某一次实验的结果而是一套可复用的方法。建议把发送端采样脚本、接收端脚本、netem 丢包命令、CSV 解析绘图代码整理成一个目录固定下来。每次内核升级、网卡替换、拥塞控制算法调整之后都重新跑一遍基线实验把 cwnd 曲线存档对比。这样做的意义在于以后再有人反馈网络慢你不必凭感觉猜测直接问一句采过 cwnd 曲线吗就能让排查走上正轨。我自己后来处理类似问题时第一步永远是先采集窗口数据先把是不是拥塞窗口受限这个最常见原因排除掉再去追其他可能性。最后说一点个人体会。拥塞窗口验证听起来是个很理论的话题但真正把它跑通之后你会发现它其实非常工程化一条命令、一个脚本、一张曲线图就能把看不见的 TCP 行为变成可讨论的数据。建议你先拿一台测试机把上面的实验完整走一遍先看慢启动的陡峭上升再用 netem 丢包看它怎么跌下来。整个过程一个下午就能完成但对理解 TCP 传输的帮助比读十篇原理文章都大。以后遇到性能问题第一反应不是换设备、改配置而是先把窗口曲线抓出来只要你能拿数据说话排查就已经成功了一大半。