1. 内容整体设计与思路拆解1.1 从“丢包谁负责”说起先聊一个基础问题网络传输中数据包丢了怎么办很多刚接触网络编程的同学以为“TCP会重传”所以一切丢包都交给协议栈兜底。这个理解方向没错但远不够。TCP的可靠性不是某一两个机制的单打独斗而是一整套从“发送前准备”到“发送中监控”再到“发送后校验”的闭环体系。这套体系的核心目标可以拆成三句话不让数据丢、不让数据乱、不让网络崩溃。前两句对应校验和、序列号、确认应答、重传机制后一对应我们今天的主角之一——拥塞控制。而流量控制虽然管的是“发送方别撑爆接收方缓冲区”但它同样服务于“不丢数据”这个大目标。所以我在本文里的拆解思路是先讲底层基石序列号、ACK、校验和再讲流量控制滑动窗口最后重点展开拥塞控制慢启动、拥塞避免、快重传、快恢复末尾再附上实际排查经验。这样从“保证不丢不乱”到“保证不把网络堵死”一条线串下来逻辑就通了。1.2 为什么TCP要把可靠性和拥塞控制绑在一起这里必须强调一个容易被忽略的事实TCP的可靠性是建立在“网络是会出问题”这个前提下的。如果网络永远不丢包、不乱序、不拥塞那么TCP只需要发出去就算完事。但现实中路由器缓存有限、链路带宽有限、接收方内存有限三个“有限”叠加在一起必然导致数据包被丢弃或者延迟。于是TCP的设计者面临一个抉择是简单地“丢了就重传”还是“想办法少丢一点”答案是后者。因为无脑重传会带来一个恶性循环网络已经拥塞了发送方还拼命重传只会让拥塞更严重。所以TCP必须同时具备两个能力一是准确感知网络状态二是根据网络状态动态调整发送速率。这就是拥塞控制诞生的原因。流量控制与拥塞控制的区别也在这里。流量控制管的是“本地接收窗口”接收方剩余缓冲区大小防止发送方把接收方内存打爆拥塞控制管的是“网络容量”链路带宽、路由器缓存防止发送方把中间网络设备打爆。两者一个管两头一个管中间共同决定了TCP的发送窗口大小。1.3 适合谁看这篇文章如果你是网络专业的学生、刚入行的后端开发、或者正在排查线上接口慢问题的运维同学这篇文章都值得从头读一遍。我会尽量避开枯燥的RFC原文用“发送方视角”来讲清楚每条规则背后的真实考量。中间涉及的具体参数比如初始窗口、ssthresh、RTO计算都是Linux默认行为实操时可以直接对照验证。2. 核心细节解析与实操要点2.1 序列号与确认应答可靠性的第一块砖TCP把数据切成一段一段的“报文段”segment每一段都携带一个序列号。接收方收到后会回一个确认号ACK表示“我已经收到你发来的第N个字节请继续发第N1个”。这个机制看着简单但有两个关键细节值得展开。第一个细节是ACK的“累计确认”特性。TCP的ACK不是逐段确认而是确认“最长的连续字节流位置”。比如发送方发了序列号1、1001、2001三个段接收方只收到了1和2001那么它回ACK时会确认到1001的位置而不是分别确认。这个设计的好处是减少ACK包数量坏处是如果中间某段丢失即使后面的段都到了发送方也意识不到“后面的段已经收到”只能从丢包位置开始全部重传。这就是Go-Back-N重传策略的由来也是TCP在可靠性和效率之间做的第一次妥协。第二个细节是接收方的“延迟确认”行为。Linux默认开启延迟ACKdelay ack收到数据后不立即回ACK而是等最多40ms看有没有顺带的数据包要反向发送如果有就捎带确认能省一个包。这个优化在局域网里几乎无感但在跨地域长链路上会影响吞吐量计算后面排查吞吐问题时我会再提到它。实操层面的建议是在抓包分析时不要只看有没有ACK包要重点看“ACK号是否在推进”。如果连续抓了多个包ACK号纹丝不动基本可以断定接收方没收到新数据问题在传输路径而不是接收方应用。2.2 校验和与数据完整性TCP头部有一个16位的校验和字段覆盖虚头部、TCP头部和数据部分。发送方计算校验和填入字段接收方重新计算并比对不一致就直接丢弃。表面看这是防止链路错误导致的数据损坏但实际查错时校验和失败的概率极低——现代网络的物理链路错误率已经非常低更多时候校验和异常是网卡驱动bug或者内核协议栈异常导致的。这里说一个我实际踩过的坑某次线上服务出现偶发性数据损坏业务层校验发现读到的响应内容里有几个字节被翻转了。排查了一个礼拜从应用代码一路追到交换机端口最后发现是网卡的TCP校验和卸载功能checksum offloading在特定流量下计算错误。关闭该特性后问题消失。所以如果你们的业务对数据完整性极度敏感比如支付回调或文件传输建议在关键路径上关闭校验和卸载用CPU计算换取确定性。2.3 超时重传与快速重传两种丢包响应姿势一旦发送方发现某个数据段丢了就需要重传。怎么发现丢了两种途径一种是超时——发出去后一段时间没收到对应的ACK另一种是“连续收到三个重复ACK”——说明后面的数据都到了就缺中间这段触发快速重传。超时重传的时间叫RTORetransmission Timeout它的计算很讲究。TCP每次发送时都会测量RTT往返时间然后加权得到平滑RTTSRTT和偏差RTTVAR最终RTO SRTT max(1, 4 × RTTVAR)。看不懂公式没关系记住结论即可RTO是动态的网络波动越大RTO越大防止误判丢包。但RTO又不允许无限增大Linux里默认上限120秒超过就放弃重传并断开连接。快速重传的设计动机是“别再傻等超时了”。接收方收到乱序段时会立即重复确认自己期望收到的序列号连续三点相同ACK发送方就知道那一段丢了。相比超时重传可能等几百毫秒甚至几秒快速重传能在几个RTT内完成恢复对交互式应用意义重大。实操中有一个判断技巧抓包时如果看到“连续的重复ACK”但发送方一直没响应说明快速重传没有触发。原因可能是接收方的重复ACK数量不够三个或者是发送方的重复ACK阈值被调整过。这类问题通常发生在“双向路径不对称”的网络里需要结合两端配置一起看。3. 流量控制滑动窗口的价值与极限3.1 接收窗口的语义不是说发就能发TCP的每个报文段头部都有一个“窗口”字段表示“我还能接收多少字节”。发送方在未收到新ACK前最多发出“已确认字节数 窗口大小”的数据。这个窗口是接收方剩余缓冲区的实时快照也是流量控制的核心载体。举个具体例子接收方窗口为64KB发送方已经发出50KB尚未确认那么它最多还能发14KB。如果窗口变成0发送方就必须停下等待接收方发出窗口更新消息。但这里有个经典坑如果接收方的窗口更新消息丢失了发送方会一直等待接收方也在等待发送方发数据两边陷入死锁。TCP的解法是“零窗口探测包”persist timer发送方在窗口为0时定期发一个1字节的探测包迫使接收方回复当前窗口大小从而打破死锁。这个机制的启示在于流量控制不是简单的“发多少、停多少”而是要考虑控制消息本身的可靠性。类似的问题在很多分布式系统中都存在TCP的这种“探活超时重试”思路值得借鉴。3.2 窗口大小与吞吐量的换算很多同学对TCP吞吐量的理解停留在“带宽越大越快”其实不对。在不考虑拥塞控制的理想场景下单连接的理论吞吐量上限是接收窗口大小 / RTT举个例子如果接收窗口是64KBRTT是100ms那么理论吞吐量上限就是 64KB / 0.1s ≈ 640KB/s。哪怕链路带宽是万兆一个连接也跑不出这个数字——因为TCP的发送模式是“等ACK、再发送”窗口决定了你在一个RTT内最多能发多少数据。这就是为什么长肥管道高带宽、高延迟网络下需要启用“窗口缩放”Window Scaling选项。TCP头的窗口字段只有16位默认最大表示65535字节。跨洋链路的RTT动辄200ms65535字节的窗口即使理论值也只有约320KB/s完全无法利用高带宽。启用窗口缩放后窗口可以达到GB级别吞吐量才提得上去。Linux默认开启这个选项net.ipv4.tcp_window_scaling 1如果是自研TCP栈或者老系统需要手动确认。3.3 糊涂窗口综合征小包陷阱当接收方应用读取数据非常慢时它的可用缓冲会只剩下一点点空间比如几十字节。按照正常逻辑它会把窗口更新为“几十字节”发送方收到后就会发一个几十字节的小包。双方来回折腾网络里充满了小包有效载荷占比极低这是典型的糊涂窗口综合征Silly Window Syndrome。TCP的处理策略很有意思接收方在窗口小于MSS最大报文段长度的一半或者小于某个最小值时把窗口声明为0拒绝发送发送方同理在窗口增长到足够大之前也不发送小包。简单说就是“宁可等一等也不发碎包”。在实际应用中如果你的抓包显示大量小于MSS的数据包可以先检查接收方应用的读缓冲区是否配置合理而不是急着调TCP参数。应用层一次只读几字节协议栈再优化也白搭。4. 拥塞控制TCP最精彩的自我调节机制4.1 拥塞窗口与慢启动从试探到加速如果说流量控制管的是“接收方的肚子有多大”拥塞控制管的就是“网络的咽喉有多窄”。TCP用拥塞窗口cwnd表示自己当前推测的“网络允许通过的数据量”。实际发送窗口取“接收窗口”和“拥塞窗口”的较小值两者任何一个成为瓶颈发送速度就会被限制。慢启动是这个游戏的第一步。新连接建立时cwnd初始值很小Linux默认10个MSS即约14.6KB然后每收到一个ACKcwnd增加一个MSS。这个增长是“指数”的1、2、4、8、16……理论上发送速率会在几轮RTT内迅速提高。但指数增长不可持续所以TCP引入ssthresh慢启动阈值当cwnd达到ssthresh后慢启动结束进入拥塞避免阶段。“慢启动”这个名字很容易误导人它其实启动很快只是初始值低意味着在任何拥塞迹象出现之前TCP会十分激进地抢占带宽。这也解释了为什么短连接比如一个HTTP请求实际传输数据不多却可能把链路占满——短连接频繁经历慢启动每个连接都在指数增长窗口。4.2 拥塞避免线性增长与乘法缩减进入拥塞避免后cwnd不再指数增长而是每经过一个RTT增加1个MSS。这个“线性增长”的设计意图是接近网络容量后TCP希望用温和的增速去逼近最大值而不是像慢启动那样鲁莽地翻倍。一般认为当发生丢包时网络容量已经被超过所以TCP必须“知错就改”——这就是乘法缩减。经典拥塞控制算法Reno中超时重传后的处理逻辑是ssthresh cwnd / 2cwnd 1重新慢启动快速重传后的处理逻辑是ssthresh cwnd / 2cwnd ssthresh然后进入拥塞避免快速恢复。注意这个细节超时意味着可能连ACK都丢了网络状态非常糟糕所以cwnd直接归1从最保守状态重来而快速重传说明还有ACK返回网络可能没那么糟所以cwnd只砍半保留一部分吞吐。这里给大家一个记忆锚点乘法增减Multiplicative Decrease才是TCP公平性的关键。不管当前cwnd多大发生丢失后一律减半或归1。这保证了当多条TCP流竞争同一瓶颈链路时谁也不会饿死谁最终能收敛到近似公平的状态。而线性增长Additive Increase则保证了探测剩余带宽的节奏是缓慢谨慎的。两者一快一慢合起来就是TCP著名的AIMD原则。4.3 快重传与快恢复把恢复时间压下去在Reno算法里快重传触发后直接进入快恢复ssthresh置为当前cwnd的一半cwnd也降到ssthresh然后随着后续ACK的到达逐步线性增长。为什么这里不重新慢启动因为“三个重复ACK”已经说明接收方还在正常工作网络只是暂时丢了一个包没必要把发送速度降到起点。快恢复的核心原理是“蓄水”收到重复ACK时TCP认为有一个数据段已经离开网络被接收方收到了所以即使cwnd没变网络里实际在途数据减少了一段发送方可以补发一个新数据段。这种“假装拥有一个临时窗口”的做法能让发送方在等待重传的这段时间里继续推进数据而不是干等。需要说明的是快恢复对乱序特别敏感。如果网络路径上存在偶发的乱序比如多路径传输接收方会提前返回重复ACK发送方误判为丢包而触发快重传和快恢复白白降低吞吐。现代内核中接收方可以通过检查是否有“更新的数据到达”来区分是乱序还是真丢包但这个机制不是所有网络栈都默认开启。如果你们的监控里看到不必要的重传优先检查网络设备的负载均衡策略是否过于激进。4.4 现代拥塞控制算法的选择从Reno到BBR传统算法Reno、Cubic都以“丢包作为拥塞信号”——换句话说它们必须先把链路填满到丢包为止才知道极限在哪。Cubic在Linux默认使用多年它在高带宽长距离网络中表现好但遇到“缓冲膨胀”场景会出问题路由器缓冲区很大时包不会立刻丢而是排队等很久导致RTT飙升但发送方毫无察觉。我曾在某跨地域数据同步项目里遇到一个典型问题链路利用率看起来很高发送窗口跑满但任务耗时反而增加了。排查后发现链路上某台网络设备缓存非常深TCP的cwnd一直涨到很高才开始丢包期间大量数据积压在路由器里排队RTT从30ms恶化到800ms。传统拥塞控制算法对这种情况基本无能为力。BBR的提出就是针对这类问题。它不把丢包当作唯一信号而是周期性地探测“链路的最大带宽”和“最小RTT”然后用这两组数据直接计算发送速率。BBR的优点是能跑满带宽而不过度排队在长肥管道和深缓存网络下提升尤其显著。但BBR对队列深度为零的链路并不友好且和旧算法共存时存在公平性问题。我的建议是默认场景保留Cubic或BBR跨运营商长链路优先尝试BBR设备侧缓存较大的内网环境可以针对性地保留Cubic。修改方式很简单Linux下执行sysctl -w net.ipv4.tcp_congestion_controlbbr前提是内核版本够新4.9。5. 实操过程与核心环节实现5.1 抓包验证滑动窗口和重传行为纸上谈兵讲再多不如抓一次包来得直观。我推荐用tcpdump在收发两端同时抓包然后导入分析工具对时间线。这里给一个最容易复现的验证流程在发送端启动一个用TCP发送固定大小文件的程序比如dd配合nc。同时在两端执行tcpdump -i eth0 tcp port 5001 -w tcp_test.pcap。传输结束后用分析工具过滤器筛选“TCP Retransmission”和“Dup ACK”事件。观察重传是有规律的单点还是集中在某个时间窗口。我第一次做这个实验时最容易犯的错误是只在发送端抓包——因为发送端只能看到自己发出的包和返回的ACK无法确认丢包究竟是链路丢的还是接收方主动丢的比如接收缓冲区满了。后来改成两端同时抓包对照很多问题一眼就清楚了。5.2 Linux内核参数与拥塞控制的相互关系看完原理一定有人想知道那我随手改改内核参数能不能让网络变快这里先把参数分个类直接影响窗口的参数net.ipv4.tcp_window_scaling、net.ipv4.tcp_rmem / tcp_wmem接收/发送缓冲区范围影响重传与超时的参数net.ipv4.tcp_retries1、tcp_retries2、tcp_syn_retries影响拥塞与调度策略的参数net.ipv4.tcp_congestion_control、net.core.default_qdisc以tcp_rmem为例它包含三个值最小、默认、最大缓冲区大小。接收窗口的实际值会在这三个值之间动态调整。如果你的业务是低延迟交互型缓冲区不宜设置过大——窗口越大一次突发丢包后需要重传的数据越多恢复越慢。如果你的业务是大文件传输加大tcp_wmem可能提升吞吐但前提是链路没有拥塞内存能扛得住。我个人的调优顺序是先确定业务类型短连接多还是长连接多、时延敏感还是吞吐敏感然后只动两三个参数分别压测。绝不一次性把十来个参数都改了——因为缓冲区、窗口、重传互相耦合全部改动后出了问题根本没法回推是哪个参数导致的。5.3 延迟ACK对RTT测量和吞吐量的干扰这是个容易被忽略的坑。延迟ACK会让发送方测到的RTT偏大进而让RTO偏大。一个典型场景发送方每发送一个包就等ACK但接收方故意等40ms才回复发送方测到的“往返时间”其实包含了接收方的等待时间。如果发送方一次只发一个包比如窗口很小那么这个40ms延迟会极大地压低吞吐量。解决办法有几个让发送方窗口足够大使得一个RTT内能发出多个包这样延迟ACK只影响最后那个包的确认时间或者调整接收方的延迟ACK策略Linux下可以通过tcp_quickack来关闭延迟确认再或者在应用层写数据时使用push标志促成立即ACK。实际操作中我建议先通过抓包确认延迟ACK是否主导了RTT再去想办法改因为不是所有场景都需要关闭它——毕竟延迟ACK能显著减少反向包的带宽消耗。5.4 验证拥塞控制算法效果的一个小实验假设你在两台服务器之间传一个大文件已经确认两端的内核版本、链路时延接下来可以这样验证不同拥塞控制算法的差异记录baseline使用默认cubic测出传输耗时、平均吞吐、重传统计。改成bbr重跑同一文件同样的文件大小、同样的并发数测出耗时和重传统计。再用tcpdump统计两次实验里的Dup ACK次数和重传次数。如果链路RTT高、缓存深BBR在吞吐上的提升通常非常明显。但要注意控制变量中间如果有负载均衡设备或者安全设备设备上若有TCP优化功能会干扰你的结论。所以我每次做这种对比时都会提前确认链路中间没有透明代理否则数据测出来只能自己心里有数不敢往外拿。6. 常见问题与排查技巧实录6.1 抓包看到大量重传但业务没感知有些场景下抓包发现TCP重传很多但业务层延迟并没有恶化。这种情况常见于无线网络或者弱网环境部分重传是低优先级优化策略比如尾部丢包后快速恢复业务有重试或双通道机制在兜底。此时不必紧张重点看重传是否集中、是否有持续堆积。判断方法很简单观察Senders Sequence发送方序列号曲线是否持续上升。如果序列号虽然偶有跳变说明重传过但整体推进是均匀的网络状态并没有严重恶化。反之如果序列号长时间停滞再突然跳一大截很可能是发送窗口耗尽、排队严重。后者的典型原因是链路拥塞或者对端的接收能力下降。6.2 吞吐量上不去先算再调我曾经接收一个“云服务器传文件只能跑5MB/s”的排查需求链路带宽是200Mbps怎么看也不对。抓包发现接收窗口只有64KBRTT有150ms。用前面提到的公式一算64KB / 0.15s ≈ 437KB/s5MB/s已经远超理论值了——显然中间还有别的连接或者代理在加速。这里想提醒大家一切吞吐问题都要先拿“窗口/RTT”这个公式估算理论下限。如果当前速率已经接近公式值问题就在窗口大小或者RTT上如果远低于公式值再考虑中间链路丢包、限速等因素。很多调优文章推荐直接改大缓冲区这个做法在RTT已经很高的情况下确实有效但它掩盖了真实瓶颈——接收方的读速度和应用层消化能力。6.3 周期性吞吐抖动慢启动在背锅一个常见现象长连接传输的吞吐曲线呈现锯齿状。这是因为TCP走到丢包后cwnd被砍半然后线性增长直到再次丢包。Cubic虽然设计了“平坦区”来缓解这种锯齿但它本质上仍受制于丢包驱动。如果你们的传输任务允许可以把一次大的传输拆成多个并行连接利用多条TCP流的叠加来平滑整体吞吐。代价是会多占用带宽、增加额外的ACK开销在公网环境要谨慎使用。6.4 常见问题排查速查表现象可能原因排查手段解决方向长时间停发数据窗口被声明为0等待窗口更新抓包看窗口字段是否长时间为0检查接收方应用读取速度、增大接收缓冲区频繁快速重传乱序或真丢包用两端双向抓包对比序号检查中间设备负载均衡策略必要时关闭透明代理优化吞吐远低于带宽窗口小或RTO过大算理论吞吐量对比实际值启用窗口缩放、调整接收/发送缓冲区、降低RTT高带宽下RTT飙升拥塞窗口过大路由器缓存堆积监控RTT趋势与cwnd变化尝试BBR或限制连接数握手耗时长网络路径有丢包抓包看SYN重传次数排查中间链路安全性、考虑修改tcp_syn_retries6.5 关于CWND可视化的一点感悟以前调试TCP性能时我最头疼的就是看不到cwnd的实时变化。Linux里确实可以通过ss -ti命令查看当前连接的部分TCP信息但字段太曲折实时性也不够。后来我养成了“两分钟的观察习惯”找一个性能窗口期连续记录当前连接的发送速率。每5秒记录一次持续两分钟。如果速率曲线像锯齿很大概率是丢包驱动型拥塞控制在起作用。这种方式比任何仪表盘都直观。特别是在排查周期性波动问题时它能帮你把“怀疑对象”快速锁定到拥塞控制策略上而不是CPU、磁盘之类的应用瓶颈。6.6 一个关于参数调整的个人建议参数调整是很多人最容易上头的地方。曾经见过一个同事为了“优化”一个短连接业务把tcp_tw_reuse、tcp_tw_recycle全部打开结果出现了严重的连接异常。原因是tcp_tw_recycle依赖时间戳在NAT环境里会导致来自同一IP的多个内网用户被误判为旧连接直接丢掉SYN。虽然这个功能在较新内核里已经移除了但这类教训值得记着TCP参数是针对全连接的不是针对单个业务的。你为了一个业务改全局参数可能把其他业务的网络行为全部带偏。总结起来TCP的可靠性传输是“机制层层叠加、参数互相耦合”的复杂问题。没有哪个单独配置能把所有问题都解决但同时理解这些机制之后你可以做到“看到一个现象就能推演出背后大概发生了什么”——这种能力比任何参数模板都值钱。我在自己的项目里吃过不少亏也踩过不少坑。如果你现在正被某个诡异的“网络问题”折磨可以试试按这个顺序排查先看两端是否启用了窗口缩放再算理论吞吐然后抓包看重传和Dup ACK最后才考虑调参数。顺序反了容易把自己绕进去。
阅读完成 · 觉得有帮助?