2. 深入理解拥塞控制的核心算法搞清楚了理想模型再看实际算法就轻松多了。TCP的拥塞控制经过几十年的演进大体上形成了四个经典阶段慢启动、拥塞避免、快重传、快恢复。这四个阶段并不是独立割裂的而是同一个状态机里的不同状态根据网络反馈相互切换。2.1 慢启动从试探到指数增长很多人对慢启动这个名字有误解以为它是慢慢增加速率的。恰恰相反慢启动的增速非常激进——指数级增长。原理很简单连接刚建立时我对网络一无所知所以先用一个很小的拥塞窗口cwnd出发通常是2到10个MSS不同操作系统默认值不同Linux的初始cwnd通常是10个MSS。每收到一个ACKcwnd就增加1个MSS。这个增长规律是每经过一个RTT往返时间cwnd就翻一倍。举个例子假设连接刚开始cwnd10 MSS第1个RTT后收到10个ACKcwnd变成20第2个RTT后收到20个ACKcwnd变成40。三个RTT后cwnd已经不是40而是80。这就是指数增长数据量像滚雪球一样迅速增大。但网络不可能无限容忍这种增长所以慢启动有一个开关——慢启动阈值ssthreshslow start threshold。当cwnd达到ssthresh时就停止指数增长转入拥塞避免阶段改为线性增长。我实测过一个有意思的现象在局域网内建立大文件传输连接慢启动阶段通常只持续2到3个RTT就撞到了ssthresh整个过程极快。但在高延迟的卫星链路上RTT接近600ms慢启动阶段会占掉连接总耗时的相当比例这就是为什么新连接在长肥网络long-fat network上开局总是显得慢热。BDP一大初始窗口和慢启动速度就成瓶颈了。提示Linux里可以通过ip route show查看当前路由的初始cwnd配置也可以通过修改initcwnd和ssthresh参数调整慢启动的激进程度。局域网调大initcwnd往往能明显提速但对公网链路这属于危险操作容易触发丢包。2.2 拥塞避免线性增长带来的温和试探进入拥塞避免阶段后cwnd不再是指数增长而是每个RTT只增加1个MSS。从图形上看这不叫增叫爬。为什么这么保守因为在慢启动阶段已经推高了cwnd继续指数增长会让网络措手不及。线性增长本质上是一种温和平缓的探测策略每一个RTT增加一点点数据量如果网络没意见没有丢包就继续增加一旦有丢包立刻大幅后退。拥塞避免的经典实现是加性增乘性减AIMDAdditive Increase Multiplicative Decrease。加性增就是上面说的每个RTT加一个MSS乘性减就是一旦发生拥塞cwnd直接乘以一个小于1的系数比如经典的Reno算法直接减半。这里有个容易被新手忽略的细节拥塞避免说的每个RTT增加1个MSS不是真的每个RTT修改一次cwnd。实际实现是按ACK累计的。经典Linux实现里每次收到ACK都会把cwnd增加1/cwnd个MSS这样累计一个RTT的ACK后正好增加1 MSS。这种做法比定时更新更平滑也更容易实现。AIMD的另一个特点需要特别注意它假设丢包就是拥塞。这对传统有线网络成立但对无线网络就不太准确。Wi-Fi信号抖动、弱网环境的随机丢包也会让TCP误判成拥塞导致cwnd频繁减半。这个问题催生了一系列改进算法后文会细讲。2.3 快重传与快恢复不用等超时的丢包修复如果数据包丢了TCP怎么发现最初只有依赖超时重传发送方发出数据后启动定时器如果在RTO重传超时时间内没收到ACK就重传。但超时的代价太沉重了——等待RTO期间发送方完全停摆对实时性要求高的应用来说是致命的。于是提出了快重传Fast Retransmit机制。快重传的基本逻辑是接收方每收到一个失序报文段就立即发送一个重复的ACK指出期望的下一个序号。发送方如果连续收到3个相同的重复ACK即可认为该报文段已经丢失立刻重传不必等超时。为什么连续3个重复ACK就判定丢包因为如果是正常的数据乱序网络里常见接收方也会产生重复ACK但通常只有1到2个后续数据到达后就会恢复正常序号。连续3个重复ACK说明后续多个报文都已到达唯独丢失了中间的那个这种情况几乎可以确定是丢包。快恢复Fast Recovery与快重传配合使用。传统Reno算法的处理方式是一旦符合快重传条件cwnd先减半然后进入拥塞避免而不是从慢启动的初始值重新开始。这比直接回到慢启动从头再来高效得多——毕竟网络刚刚还在正常传输只是丢了一个包。快恢复的完整过程值得写清楚检测到3个重复ACK后ssthresh设为当前cwnd的一半cwnd也减半收到重复ACK时cwnd临时加1个MSS用于填充网络中因丢包产生的空洞当收到新的ACK非重复ACK说明丢失的数据已经补上cwnd退回到ssthresh值进入拥塞避免阶段。这个机制的价值在于不用白等RTO回退幅度也小对吞吐量的保护明显。2.4 演进中的版本从Reno到BBR经典的Reno算法虽然简单清晰但存在场景局限。比如在跨入高带宽长RTT链路时Reno的丢包即拥塞假设过于粗糙——它分不清丢包是因为拥塞还是无线链路误码。而且Reno在丢包后cwnd减半的恢复速度偏慢吞吐量振铃现象严重。于是出现了NewReno改进点是本地重传中的快恢复处理Reno在快恢复期间如果又收到重复ACK可能会退出恢复阶段NewReno则会持续跟踪当前重传的数据直到所有丢失报文都补上在同一拥塞窗口内处理了多个丢包时表现更好。再往后是Vegas的雏形它尝试用RTT的斜率变化预测拥塞在丢包前就提前降速。这种预防式思路很诱人但Vegas部署量一直不大原因是对RTT滤波参数敏感实际网络环境的噪声很容易掩盖趋势信号。当前Linux默认使用的CUBIC算法它的核心思想是采用三次函数作为cwnd增长曲线丢包后cwnd大幅下降随后在凹阶段快速恢复接近上一次拥塞窗口时转为凸阶段缓慢逼近探测新的容量上限。CUBIC与RTT无关的特性使其在高速长RTT网络的利用率远高于Reno这也就是它在数据中心和骨干网络上能站稳脚跟的原因。近年大热的BBRBottleneck Bandwidth and Round-trip propagation time则彻底换了个思路不再把丢包当作拥塞的直接信号而是不断探测瓶颈带宽和最小RTT用两者的乘积即BDP时延带宽积来确定合适的发送速率。在混有随机丢包的弱网上BBR的吞吐量往往能超过CUBIC数倍但它也有一个著名的争议点——重传率偏高导致它在某些拥塞场景下对CUBIC流不太公平。4. 连接管理三次握手与四次挥手的边界问题跟大家聊完了拥塞控制的方方面面连接管理也到了不得不说的分量。TCP面向连接的特性讲究的是可靠地建立、可靠地拆除三次握手和四次挥手就是这套规则的具象化。4.1 三次握手到底解决了什么问题很多初学者觉得三次握手重复——为什么不是两次其实问题被简化了如果只有两次握手服务端在收到SYN后就认为连接建立立即分配资源。但SYN在网络里可能被延迟重放服务端就会建立一堆“孤悬”的连接浪费资源。三次握手保证了双方都确认了彼此的收发能力。设计上还有一层含义三次握手能同步双方的初始序列号ISN。TCP的可靠传输依赖确认号和序列号对齐如果两边各说各话数据校验就是一团乱。三次握手实际上将ISN的交换嵌入了握手过程中做到“我发你收、你发我收”双向往返确认。三次握手的报文可观测性很强。用tcpdump在服务端抓包能看到完整的SYN、SYNACK、ACK三段式。我强烈建议新手在这个阶段就养成抓包看状态变化的习惯比死记状态机有效得多。关于握手包里的几个字段我补充几个实用细节SYN包的序列号是随机生成的不允许从0开始否则容易被预测引发序列号攻击。每个TFOTCP Fast Open连接的SYN里还带有Cookie这是为了在不额外消耗RTT的前提下完成握手。TFO客户端缓存的是服务端签发的Cookie第二次连过来直接带数据。握手阶段双方都会通告MSS、窗口大小含窗口缩放因子这些参数直接影响后续传输的效率和能支撑的最大窗口。4.2 SYN队列与握手半连接服务端的高并发埋伏服务端收到SYN时并不会立刻为连接分配完整传输控制块而是先放进半连接队列SYN队列完成握手后再转入全连接队列accept队列。这个两段式设计是为了防SYN Flood攻击但也引入了很多运维痛点。SYN队列长度由net.ipv4.tcp_max_syn_backlog控制全连接队列大小和backlog参数与net.core.somaxconn相关。当应用层accept不够快时全连接队列满新连接依然能完成三次握手SYNACK照发但收到ACK后进入的不是accept队列而是被直接丢弃。表现到客户端就是TCP连接建立“卡住”tcpdump能看到SYN被响应了但业务连不上。处理这种队列溢出的问题我的排查顺序一般是先看ss -lnt输出里的Send-Q和Recv-Q如果Recv-Q接近Send-Q说明有积压再到服务端ss -tn看有没有大量SYN_RECV或ESTABLISHED但实际空闲的连接。别一上来就调大tcp_max_syn_backlog要充分理解业务特征是短连接还是长连接再动手。另外值得一提的细节Linux 4.4之后如果SYN队列溢出除SYN Cookie之外还有SYN队列重哈希等机制但默认效果依然有限。打到大流量攻击时直接开启net.ipv4.tcp_syncookies1往往是最快的止损手段。4.3 四次挥手与TIME_WAIT的老大难连接拆除比建立更精细。TCP是全双工的每一方向必须单独关闭于是有了四次挥手。主动关闭方发出FIN然后可能收到ACK、对端FIN、再回复ACK才算完成。序列就是FIN、ACK、FIN、ACK外加可能的数据段。关闭过程的特殊点在于TIME_WAIT状态主动关闭方发送最后一个ACK后需要等待2MSLMaximum Segment Lifetime最大报文段生存期后才真正销毁连接。标准建议是2分钟Linux上的默认值通常是60秒可以通过net.ipv4.tcp_fin_timeout调整。为什么要等2MSL两个原因保证最后的ACK能到达对端。如果这个ACK丢了对端会重发FIN而旧连接的报文还可能在网络中滞留2MSL足以覆盖网络中最大报文段的生存时间让可能的旧包彻底消亡。让连接双方的所有报文都消失避免复用相同四元组时旧连接迟到的报文污染新连接。TIME_WAIT带来的实际问题集中在高并发短连接服务上每秒建立成千上万连接再关闭服务端会积累大量TIME_WAIT状态端口。可用的四元组资源被残留连接占用新连接反而无法建立。遇到这种场景优先从业务层缩短连接持有时间、提升连接复用而不是盲目调小tcp_fin_timeout。后者虽然能缓解TIME_WAIT压力但可能因报文延迟导致更隐蔽的数据错乱。我对比过几种方案的效果使用长连接池比调tcp_tw_reuse更稳妥。Linux的tcp_tw_reuse只对出站连接生效可以复用TIME_WAIT状态的连接发起新连接但实际线上效果受限于端口分配和PAWS机制调整前务必观察是否存在异常重传。4.4 状态迁移中的常见坑连接状态是TCP的骨架排障时看懂状态就等于看懂了当前局面。以下是我常会遇到、也常被问到的几种状态当前状态触发场景常见排查方向SYN_SENT客户端发出SYN未收到SYNACK路由不通、服务端SYN队列满、防火墙丢弃SYN_RECV服务端已接收SYN并回复SYNACK客户端可能没收到SYNACK或收到后ACK被丢弃ESTABLISHED正常连接数据正在传输关注RTO重传率、接收窗口FIN_WAIT_1/2主动关闭方发出FIN等待对方关闭对端进程未退出时可能长期停留CLOSE_WAIT对端FIN已到本地未调用close典型的应用层忘记关闭连接TIME_WAIT主动关闭方等待2MSL短连接密集时数量巨大其中CLOSE_WAIT堆积是最常被忽视的问题。服务端收到对端FIN后内核进入CLOSE_WAIT需要应用层显式调用close才会发出FIN。如果业务代码里有资源异常或线程泄漏连接一直不关闭CLOSE_WAIT就会变成“僵尸连接”并最终耗尽fd。排查CLOSE_WAIT没有银弹只能从应用层找原因看看哪些连接没有被close以及为什么没有被close。3. 拥塞控制的实际观测从理论到命令行验证纸上谈兵没意思。学习拥塞控制最好的方式是把真实连接拉出来解剖。好在Linux提供了很方便的观测入口我常用的方法是三个工具配合ss看状态tcpdump抓报文Wireshark做图形化分析。3.1 用ss观察TCP窗口与拥塞状态ss -tin输出里会包含cwnd、ssthresh、rtt和拥塞算法。这是一个实际抓到的输出片段格式化后展示关键字段State Recv-Q Send-Q Local Address:Port Peer Address:Port ESTAB 0 0 192.0.2.10:34567 198.51.100.20:443 cubic rto:204 rtt:12.5/3.75 ato:40 mss:1448 cwnd:32 ssthresh:21 bytes_acked:1234567 segs_out:8901让我解读一下关键字段cubic是当前拥塞控制算法rto是重传超时毫秒rtt:12.5/3.75表示当前RTT平均值和波动值cwnd:32说明目前拥塞窗口为32个MSS约46KBssthresh:21说明当前处于拥塞避免阶段因为cwnd已经超过ssthresh。如果cwnd小于ssthresh可以判断连接正处在慢启动阶段。注意这里的cwnd数值是MSS个数还是字节数跟系统实现有关。主流发行版上ss显示的cwnd是MSS单位要看实际能发送多少数据需要乘以MSS。最高效的验证方法是ss -ti配合同步下载大文件观察cwnd变化。如果你看到大量连接长期处于cwndssthresh且二者很小比如只有10-20通常意味着网络丢包严重瓶颈不在应用而在链路质量。3.2 用tcpdump还原拥塞事件tcpdump抓包可以还原出拥塞控制的完整时间线。抓住一段重传事件的典型命令是sudo tcpdump -i eth0 -nn tcp and host 198.51.100.20 -w /tmp/tcp_cong.pcap然后打开pcap重点看几个标记重复ACKDuplicated ACK如果看到同一个ACK号反复出现说明接收方在催数据链路已经出现乱序或丢包。快速重传TCP Retransmission在重复ACK之后紧跟的同一序列号重传包标记为spurious retransmission的还可能是误判。超时重传TCP Retransmission without ACK长时间得不到ACK后重传的更大的间隔往往伴随RTO指数退避。我在本地环境做过一次模拟实验用tc netem人为注入5%的丢包率再看TCP反应。结果非常直观cwnd经历了一次断崖式下降ssthresh随之下调后续的窗口增长变缓带宽利用率明显降低。这个实验放大了拥塞控制对丢包的“敏感度”比直接看教科书里的曲线图直观得多。顺手说一句tc命令本身也是网络工程师的实用工具构造实验场景必备# 模拟5%随机丢包 sudo tc qdisc add dev eth0 root netem loss 5% # 恢复 sudo tc qdisc del dev eth0 root3.3 Wireshark里最值得看的三个视图图形化分析时Wireshark有三个视图是我每抓必看的TCP Stream Graph Time-Sequence Graph (Stevens)横轴时间纵轴序列号。曲线上升的斜率就是吞吐的直观反映出现明显平台期说明发送方在等待ACK重传点会有序列号回退的“倒勾”。TCP Stream Graph Round Trip Time GraphRTT散点图能反映网络排队状况。RTT持续波动上升说明缓冲区排队加重接近拥塞前兆。Statistics TCP Stream Graph Throughput吞吐量随时间的变化能直接看到AIMD的锯齿状波形。锯齿越深说明cwnd波动越剧烈对实时应用越不友好。如果要快速扫一个pcap里有多少重传包打开Statistics TCP里的Retransmission标签直接给出重传次数分布。这个数据结合协议分析几乎能定位所有传输层性能故障。5. 常见问题与排障心得理论聊透了最后直接把我这些年排过的坑列出来。每一条都对应一个真实场景不是纸面推导。5.1 高延迟低带宽下的“假拥塞”有次帮一个做远程数据同步的团队排障稳定复现的现象是远程站点传输速度上不去ping的RTT大约200ms丢包率极低低于0.1%。从表面看链路很好但业务的传输速率只有预期值的五分之一。抓包后发现RTO间歇性飙升大量RTO重传。进一步看RTT统计平均值才200ms但RTO计算里增加了抖动余量偶尔的RTT尖峰导致RTO被推高到2秒以上。发送方长时间得不到ACK连接被“误判”成拥塞cwnd一路掉到个位数。这类问题的根因不是拥塞而是RTT抖动改进方向是调整RTO计算参数或者结合业务特性改用BBR算法BBR能区分排队延迟和传播延迟对高延迟链路的适应度更好。实际改动后传输速率翻了好几倍。5.2 接收窗口被“缩零”导致发送冻结有一次排查一个上传卡死的案例发送方不断发数据但业务进度不动。抓包发现接收方通告窗口变成了0发送方进入“坚持定时器”Persist Timer轮询窗口状态。产生窗口缩零的常见原因有两个接收方应用层不读数据导致接收缓冲区占满或接收方内存不足通告窗口异常收缩。排障时的直接手段是查看发送方ss -tin输出里的rcv_rtt和接收方进程的读写阻塞情况但最终要落到业务代码是否及时消费数据上。如果只是临时恢复可以用sysctl -w net.ipv4.tcp_window_scaling0临时关闭缩放观察变化但这不是根治手段。5.3 TIME_WAIT堆积导致端口耗尽线上出现过一次典型的连接建立超时故障客户端服务日志大量报“connect timeout”系统里socket数量飙升。用ss -tan看TIME_WAIT状态的连接数达到好几万本地端口被占满。当时时间比较紧张先临时打开了tcp_tw_reuse注意这只影响出站连接降低了影响然后从应用层将“每次请求建立新连接”改为连接池复用。后者才是治本手段连接池大幅减少了新建连接频率TIME_WAIT自然下降。5.4 丢弃重复SYN的防火墙规则让连接“时通时不通”有些防火墙规则对新建连接的SYN做了限速比如限制每秒允许的SYN次数。一旦应用短时间发起大量连接后续SYN就被丢弃客户端表现为间歇性连接失败。此时tcpdump在客户端能看到SYN发出却没有SYNACK服务端抓包又完全看不到被丢弃的SYN——因为丢包发生在中间设备上。处理方法要么调高防火墙的连接速率限制要么减少短连接频率。对于高并发网关类应用建议直接使用长连接别跟防火墙规则较劲。5.5 网卡队列溢出与软中断丢包这类问题属于“假丢包”范畴抓包能看到TCP报文已经到达网卡但协议栈没来得及处理导致重传。常见原因包括网卡多队列分配不均或软中断处理不过来。排查命令是# 查看网卡队列丢包统计 ethtool -S eth0 | grep -E rx_dropped|rx_fifo_errors # 查看软中断分布 cat /proc/softirqs | grep NET如果发现rx_dropped持续增长可以考虑调整网卡队列数、设置RSSReceive Side Scaling或开启RPSReceive Packet Steering把软中断分散到多个CPU核上。这类问题在纯网络层的教科书里很少提到但实际排障中高发。6. 一点个人体会做了这么多年网络相关的开发与排障我最大的感受是拥塞控制和连接管理不是背几个算法名称就能驾驭的它们是在真实的链路、真实的队列、真实的丢包里一点点打磨出来的“妥协艺术”。教科书上每一个简洁的公式背后都是对网络复杂性的深度妥协。比如RTO的计算要处理时钟粒度、抖动估计、重传歧义快恢复的cwnd调整要考虑多个丢包同时发生的场景CUBIC的曲线参数要兼顾公平性和激进性——这些细节单独看都简单组合起来才见功力。对入门者我的建议很朴素先亲手抓一次包亲眼看到慢启动的指数增长曲线再人为制造一次丢包看到快重传的响应你就能把书上的状态机牢牢焊在脑子里。对老手我的提醒是别轻信任何“默认参数”。sysctl里的几十个TCP参数每一个都是网络环境与业务模式交互后的折中只有理解了它们的含义才能在关键时刻做对取舍。希望这篇能帮到你。下回遇到拥塞或者连接状态异常记得先看数据再谈理论。