TCP的拥塞控制与连接管理是传输层里最绕、也最容易被面试官翻牌子的两块内容。第九篇把它们放在一起讲是有意的因为一个TCP连接从生到死就是靠这套控制逻辑串起来的三次握手把会话建起来拥塞控制决定这个会话能跑多快四次挥手再把会话干干净净地收尾。无论你是准备考研面试还是在生产环境里排查“服务突然变慢”的疑难杂症这两块都是绕不开的基本功。这篇文章我不会只贴教科书上的状态图。拥塞窗口那根指数爬升的曲线、握手挥手过程中每个状态迁移背后“为什么非得这样”我都会拆开讲最后再附上我在抓包和调参过程中踩过的几个坑。有些细节教科书不写但实际排查问题的时候能救命。1. 先搞清楚拥塞控制在解决什么问题很多同学把拥塞控制和流量控制混为一谈这是第一个要纠正的误区。流量控制解决的是“发送方发太快接收方接不住”的问题它是端到端的只涉及两端协商通过接收窗口rwnd来完成。而拥塞控制解决的是“整个网络中间的路由器撑不住了”的问题——链路带宽有限、路由器缓存有限一堆主机同时往网络里灌数据中间节点处理不过来就开始丢包丢包又触发重传重传让网络更拥塞恶性循环。打个比方。流量控制是两个人说话对方听不过来你就放慢语速拥塞控制是几十个人挤在一个屋里同时讲话屋里空气变得浑浊每个人都听不清只能扯着嗓子喊结果更吵——这时候正确的做法是每个人自觉地降低音量。TCP解决拥塞的抓手是一个新的窗口拥塞窗口cwnd。真正的发送窗口是这两个窗口的较小值实际发送窗口 min(拥塞窗口 cwnd, 接收窗口 rwnd)这个min特别关键。接收窗口是对方告诉你的“我能收多少”拥塞窗口是你自己根据网络状况推测的“网络能扛多少”两者取小才能同时照顾到对端和中间链路。很多人在理解上栽跟头就是因为只盯着拥塞窗口忘了还有接收窗口这个下限的存在。拥塞控制的核心思想就是通过一个不断动态调整的cwnd探测网络的承载上限然后收敛到接近这个上限的水平稳定传输。整套机制由四个算法协同完成慢启动、拥塞避免、快重传、快恢复。下面逐个拆。2. 拥塞控制四个核心算法逐个拆解2.1 慢启动指数增长快速探路连接刚建立时发送方并不知道网络的承载能力所以不能一上来就猛灌而是从很小的cwnd开始试探。传统教材里的初始值是1个MSS最大报文段长度不过实际Linux内核从较新版本开始默认初始cwnd已经是10个MSS左右还有不少云厂商做过调优。这里先按教科书逻辑讲大家理解的是机制不是那个具体的初始数字。慢启动的执行规则每经过一个RTT往返时延cwnd翻倍。如果cwnd 1 MSS第一个RTT后变成2第二个RTT后变成4第三个变成8……看起来是“慢”启动实际上是指数级增长一点都不慢。之所以叫“慢”只是因为起点低像从零开始逐渐加码而不是上来就占满带宽。慢启动不是无限翻倍的。我们有一个阈值叫sshresh慢启动门限初始值一般设为对方窗口或者一个较大的值。当cwnd增长到超过sshresh时就切换进拥塞避免阶段。另外只要发生超时重传说明网络已经撑不住了发送方就会把sshresh砍半同时把cwnd重置为初始值重新走慢启动。2.2 拥塞避免线性爬坡保持克制进入拥塞避免阶段后cwnd不再翻倍而是每经过一个RTT只增加1个MSS也就是“加法增大”AIMD里的AI。从指数增长变成线性增长目的是在接近网络容量上限时小心试探避免一下就冲破瓶颈把路由器打爆。那这个阶段会不会一直涨下去不会。当cwnd增长到某个点网络最终会丢包。丢包有两种触发方式超时重传或者收到重复ACK触发快重传。两种方式对应的降速策略不同这就引出了后面两个算法。这里有一个值得注意的点TCP的拥塞控制本质上是“丢包反馈”机制——它不知道网络到底还有多少余量只能通过有没有丢包来判断自己是不是冲过头了。所以网络越拥塞越要主动退让这跟现实中的交通管制思路是一样的。2.3 快重传不等超时收到3个重复ACK就重传传统超时重传有个问题超时时间通常设得相对保守要大于一个RTT如果只是丢了一个包发送方要傻等很久才重传白白浪费窗口。快重传就是为了解决这个“等太久”的问题。它依赖的是接收方对乱序数据的反馈。假如发送方发了1、2、3、4、5五个包其中包3丢了接收方收到包4、包5后因为期望的序号还是3号所以会连续返回对2号包的确认即重复ACK。当发送方收到3个重复的ACK时基本可以断定包3丢了于是不等超时计时器触发立刻重发包3。为什么偏偏是3个而不是1个因为网络中数据包乱序到达是常有的事仅仅一个重复ACK可能只是正常的乱序不能说明丢包。等3个重复ACK相当于接收方连续三次表达“我只缺这个包”证据足够充分了误判的概率大大降低。2.4 快恢复降速但不归零避免白白浪费带宽快重传发生之后发送方意识到网络已经有拥塞的苗头但不能像超时那样完全归零重新慢启动——因为3个重复ACK本身说明还有不少数据包成功到达对端网络的状况还没到彻底堵死的地步。所以快恢复的策略是“降半速继续跑”ssthresh cwnd / 2 cwnd ssthresh然后把拥塞避免模式打开让cwnd从砍半后的值开始线性增长。这套策略在教科书上对应的是TCP Reno版本而更早的TCP Tahoe在快重传之后会直接cwnd归1重新慢启动代价是吞吐量断崖式下降现在已经很少用了。四个算法配合起来完整的拥塞窗口变化过程大致是这样连接建立后从初始cwnd开始指数翻倍触达ssthresh后转线性爬升一旦遇到超时ssthresh砍半、cwnd归1重新走慢启动如果遇到的是3个重复ACK只砍半接着走拥塞避免。整个过程就是“探测—拥塞—退让—再探测”的循环这个循环就是TCP能长期稳定跑满带宽而不把网络打挂的关键。触发事件cwnd调整ssthresh调整进入的模式慢启动未达阈值无丢包每RTT翻倍不变慢启动cwnd超过ssthresh每RTT 1 MSS不变拥塞避免超时重传重置为初始值如1 MSScwnd/2慢启动收到3个重复ACK快重传设为cwnd/2有的实现为cwnd/23MSScwnd/2拥塞避免3. 连接管理三次握手为什么非得是三次3.1 三次握手的完整流程与状态迁移TCP连接建立的经典三步我先把序列号写清楚方便后面对照着看客户端发送SYN报文随机初始化自己的序列号seq x进入SYN_SENT状态。服务器收到后回复SYN ACK报文确认号ack x 1同时初始化自己的序列号seq y进入SYN_RCVD状态。客户端收到后再发一个ACK报文确认号ack y 1此时双方都进入ESTABLISHED状态连接建立完成。三次握手最朴素的目的是让双方都确认“我能收到你的数据你也能收到我的数据”。第一次握手服务器确认了客户端的发送能力和自己的接收能力第二次握手客户端确认了自己的发送能力、接收能力以及服务器的发送、接收能力第三次握手服务器确认了客户端的接收能力和自己的发送能力。到这里四件事都确认齐了。从状态迁移的角度看服务端的SYN_RCVD状态是个关键中间态客户端可能永远不发第三次ACK这时服务器要依靠超时重发SYNACK超过一定次数才能释放半开连接。实际生产环境中大量SYN_RCVD状态的堆积往往意味着有人故意不完成握手——这就是后面要说的SYN洪泛。3.2 为什么两次握手不行历史失效连接的坑教科书必考题。假设只用两次握手会出现什么情况考虑一个场景客户端第一次发的连接请求报文段在网络上卡了很久客户端等不到回应于是超时重发了一个新的连接请求这次成功建立了连接、传输了数据、正常关闭。一切看起来挺好。问题出在那个卡了很久的旧连接请求报文段上——它并没有消失而是在网络里“游荡”了一圈之后终于到达了服务器。服务器一看是连接请求以为客户端又想建立新连接于是回复确认并分配资源进入了连接建立状态。但实际上客户端根本不打算再建这个连接于是服务器的资源就这么白挂了服务器一直在那儿傻等客户端发数据。三次握手怎么解决这个问题如果客户端后续真正希望建立连接它会回应新的SYNACK如果这是一个迟到的历史请求客户端识别出这个ACK对应的序不符合当前期望就会发送RST复位报文段把这条连接中止掉。所以三次握手的本质是让双方在建立连接前交换一次“序列号”的确认让历史遗留的失效请求暴露出来并且被终止。两次握手做不到这件事。这里顺带提一个进阶点三次握手过程中前两次报文不能携带数据这是为了防止攻击者利用握手阶段不完成连接却不断往服务器塞数据来耗尽资源。第三次握手时因为客户端已经确认了对端的接收能力ACK报文是可以携带数据的所以实际传输的效率不会因此受影响。3.3 SYN洪泛与常见防护思路三次握手有一个天然的死穴服务器在收到SYN后就分配资源进入SYN_RCVD状态等待第三次ACK。如果攻击者伪造海量SYN报文并且从不发送最终的ACK服务器的半连接队列会被占满正常的客户端连接请求反而进不来——这就是SYN洪泛攻击。实际防护思路常见有这么几类限制半连接队列长度、缩短SYN_RCVD超时时间、SYN Cookie技术不预先分配资源把连接状态编码进SYNACK的序列号里等收到第三次ACK再真正分配资源。这些方案在面试时能说出原理会是很加分的细节。4. 连接释放四次挥手和躲不开的TIME_WAIT4.1 四次挥手的完整流程TCP连接是全双工的两个方向的数据传输互相独立所以关闭连接时两个方向必须分别关闭。流程如下主动关闭方假设是客户端发送FIN报文携带seq u进入FIN_WAIT_1状态表示“我的数据发完了不再发新数据”。被动关闭方收到FIN后回复ACKack u 1进入CLOSE_WAIT状态。客户端收到这个ACK后进入FIN_WAIT_2状态。这个阶段很关键服务器虽然不会再收到客户端的新数据但它自己可能还有数据没发完所以可以继续往客户端发数据这种状态叫半关闭。服务器把剩余数据发完后发送FIN报文seq w进入LAST_ACK状态。客户端收到FIN后回复ACKack w 1进入TIME_WAIT状态。服务器收到ACK后进入CLOSED状态。第2和第3步之间可能隔很长一段时间不是连续的这是四次挥手区别于三次握手的本质原因——中间隔着一段“被动方继续传输剩余数据”的时间。很多人问“为什么挥手不能合并成三次”其实就是因为服务器的数据发送能力必须在收到FIN之后保留一段时间不能立刻跟着ACK一起回一个FIN。4.2 TIME_WAIT为什么必须等2MSL主动关闭方在发出最后一个ACK之后不会立即进入CLOSED而是要等待2MSL报文最大生存时间。这个问题几乎所有面试官都会追问理由有两个。第一防止最后一个ACK丢失。如果这个ACK丢了被动关闭方会超时重发FIN主动关闭方需要在这个时间里能再次回应。如果主动方提前关了重发的FIN没人理会被动方得不到ACK就永远卡在LAST_ACK无法正常关闭。第二让本连接中所有“残余报文”在网络中彻底消失。一个数据包在网络里最多存活一个MSL两个MSL能保证发送方向的数据报和接收方的ACK都从网络上消失不会干扰后续使用相同四元组IP、端口的新连接。从运维实践的角度看高并发短连接场景下服务器上TIME_WAIT状态的连接会大量堆积。TIME_WAIT状态下连接占用的端口不能立刻复用如果短连接吞吐极大就可能碰到“端口不够用”的问题。常规解法是开启SO_REUSEADDR让新连接能复用处于TIME_WAIT的端口或者调整内核net.ipv4.tcp_tw_reuse相关的参数仅对主动发起连接的客户端方向有效再不行就改长连接池策略减少新建连接频率。这些坑写代码的时候不容易暴露一上生产压测就全冒出来了。5. 实战与面试高频问题速查5.1 用Wireshark观察拥塞控制的行为我在实际排查慢速传输问题时习惯用Wireshark的**TCP Stream Graphs → Time-Sequence GraphStevens**来观察。那个图的斜率变化非常直观地反映了拥塞窗口的动态斜率陡峭上升的阶段对应慢启动的指数增长斜率变缓的阶段对应拥塞避免的线性爬升突然出现的水平线往往就是等待重传或经历了超时。如果看到图中出现周期性的“爬坡—断崖—爬坡”基本可以判断网络存在频繁丢包拥塞控制一直在反复退让。这时候不要急着调TCP参数先确认是物理链路丢包还是中间网络设备限速否则调了也白调。5.2 面试最常追问的几个细节为什么快重传收到3个重复ACK就重传而不是等超时因为超时等待周期一般远大于一个RTT会严重浪费吞吐3个重复ACK是融汇了“可靠性”和“及时性”的一个折中。握手和挥手过程中SYN和FIN报文消耗序列号吗消耗。SYN和FIN各占一个序列号这也是为什么第三次握手的ack是y1第四次挥手的ack是u1。服务器大量CLOSE_WAIT是什么原因通常是应用程序收到FIN后没有正确调用关闭接口导致内核发了ACK但迟迟不发FIN通俗讲就是代码里忘了关socket。我排查过不少线上“连接数飙高”的问题最后定位都是业务代码里某个分支没有走到close。5.3 几个可以落地的调优心得生产环境调TCP参数我踩过的坑有几个可以说说。一是不要把tcp_slow_start_after_idle这类参数一刀切。它默认开启会让空闲后的TCP连接重新走慢启动对突发短连接场景不太友好。但如果你的场景是高频心跳这类长连接小流量把它关掉有时能明显减少传输延迟。不过别一上来就改先看监控数据再动手。二是如果服务端大量TIME_WAIT优先从减少短连接入手而不是一味缩短TIME_WAIT超时。只要把连接做复用端口占用指数级下降。之前帮一个同学排查他的压测服务把连接池开上之后TIME_WAIT从几万直接掉到几百这就是最省力的解法。三是在公网环境下拥塞控制的行为很难精确复现因为中间路径上的路由器策略、丢包模式都不可控。本地用两台Linux机器直接互连通过tc netem模拟丢包和延迟是验证拥塞控制行为的有效手段。写个脚本让两个网卡之间产生随机丢包再抓包对比有无拥塞控制时的吞吐差异这种实验做完一遍对TCP机制的理解比背十遍书都牢。最后一个建议把min(cwnd, rwnd)这个公式贴在工位上。很多高延迟问题追根溯源既不是网络带宽不够也不是路由器拥塞而是接收窗口太小或者被频繁缩减。先搞清楚限制你的是哪一边再去做优化比一上来就堆参数靠谱得多。