第三章的笔记写到第三篇,我的原计划是先把TCP整体的逻辑跑通再落笔,因为这一章后半段的任何结论,单独拿出来硬记都没用,必须和前面的可靠传输原理串起来。教材用大量篇幅讲了UDP和rdt,然后用几乎同样多的篇幅讲TCP和拥塞控制,真正刷下来的感觉是:前一半是基础框架,后一半是前者的实战应用。很多同学看到"拥塞控制"就怕,觉得又是算法又是状态机,我一开始也这样,后来才知道,只要把每一个状态转换背后的动机问清楚,这一章反而比第二章还好懂。
这篇分享覆盖的,是教材第三章从TCP报文段结构开始,一直到TCP拥塞控制结束的内容。换句话说,UDP、多路复用和多路分解、rdt那部分不在本篇范围,如果你刚读到第三章中间,可以先补一下前面的基础。本篇会用大量"为什么"来推送结论,不堆定义,目的是让你合上书之后,能自己把TCP的工作过程从头到尾推出来。
1. TCP报文段:在字节流上画坐标,而不是按包子打包
1.1 序号和确认号:字节流才是TCP的坐标系
很多人刚接触TCP头时,会被"序号"和"确认号"两个字段绕晕,因为TCP明明是分段发送的,为什么编号单位不是"段"而是"字节"?这正是TCP和普通可靠传输协议最本质的区别。TCP站在应用层的角度,把数据看成一条连续字节流,所以seq表示的是"这个报文段中第一个字节在整个字节流中的位置",ack表示的是"接收方期望收到的下一个字节位置"。
举个例子:假设A要发送5000字节给B,分成5个段,每段1000字节。第一个段的seq=0,第二个段seq=1000,第三个seq=2000,依次类推。B收到前两个段后,会回复ack=2000,意思是"我已经收到字节0到1999,期待你从字节2000开始发"。这和很多协议里"收到第几号包就回第几号包"的语义完全不同,也正因为编号落在字节上,TCP天然支持累计确认:哪怕只回一个确认号,也能覆盖之前所有连续收到的数据。
我刚开始学的时候有个误点:总觉得ack=2000就代表"收到了1至1999"(也就是序号2000前面的所有),后来发现自己默认把序号看成从0开始很顺,但实际抓包时看到的seq是不固定的。教材第一章就提过,双方握手时各自选择初始序号,不一定是0,所以真正要记的逻辑是:ack = 对方发来的初始seq + 已连续收到的字节数。用字节作单位的好处是,即使某个段丢了,后面到达的段也不会被立刻丢掉,接收方会把这些乱序但连续的段缓存起来,等缺口补上后再一次性交付给应用层。
1.2 标志位和握手:为什么三次就够,为什么挥手要四次
TCP报文段头里有一组标志位,最核心的是SYN、ACK、FIN。连接建立时,双方要同步初始序号,所以有了三次握手。第一次客户端发SYN=1, seq=x,第二次服务器回SYN=1, ACK=1, seq=y, ack=x+1,第三次客户端再回ACK=1, seq=x+1, ack=y+1。这里有个我当年没想明白的问题:为什么不能两次?教材给出的核心原因是防止失效的连接请求突然到达服务器,让服务器白白建立半开连接。如果是两次握手,一个在网络里滞留很久的旧SYN可能导致服务器误以为客户端要建立新连接,从而分配资源;三次握手让客户端再确认一次,服务器收不到第三次ACK就知道这次连接建立不成立,不会把资源耗在幻觉连接上。
至于挥手为什么需要四次,要从"全双工"这个特性出发。TCP连接里两个方向的数据流是独立的,关闭时必须各自独立结束。A发送FIN表示"A方向上不再有数据要发了",但B还可能继续向A发数据,所以B先用ACK回应这个方向的关闭,等自己的数据全部发完,再向A发送FIN,A再确认。四次挥手的本质是"两次单向关闭"的组合。实战中还会遇到同时关闭、以及加TIME_WAIT状态等问题,但理解到"双向独立关闭"这一层,后面那些状态机细节就都顺了。
我还想补充一个经常考但容易被忽略的语义:TCP连接的标识是四元组——源IP、源端口、目的IP、目的端口。很多同学只记"IP+端口",尤其在学Socket编程后会下意识认为是"两个套接字一一对应一条连接"。实际上,服务端可以同时和多个客户端保持不同连接,只要四元组里的元素不完全重复,每条连接就是独立的。
2. 超时与重传:让TCP在不可靠信道里建立自己的节拍
2.1 RTT估算:为什么不能只用平均值
TCP可靠传输的核心是"发送数据、启动定时器、超时重传"。但这个定时器设多少毫秒,直接决定效率:设太短,网络稍波动就会盲目重传;设太长,一次丢包就让吞吐率断崖式下跌。教材给出的方案是追踪SampleRTT,也就是"从发出某个段到收到它对应ACK所经历的时间",然后用指数加权移动平均来平滑:
EstimatedRTT = (1 - α) × EstimatedRTT + α × SampleRTT
α默认是0.125。这个公式的本质,和我们在性能监控里常说的EWMA一模一样,给新样本的权重很小,防止单次抖动带偏整体估计。只算平均还不够,因为网络延迟的波动幅度是随机的,平均偏移1毫秒和波动100毫秒对超时判断的影响完全不同。所以TCP在平均之外还维护一个偏差估计DevRTT:
DevRTT = (1 - β) × DevRTT + β × |SampleRTT - EstimatedRTT|
β默认是0.25。最终超时间隔取:
TimeoutInterval = EstimatedRTT + 4 × DevRTT
这个公式我很喜欢,它不绕弯子:用4倍偏差把不确定性兜住。你可以联想生活中约人见面,如果只是算"平均迟到5分钟",那你提前5分钟到往往还是被放鸽子;更好是"平均迟到5分钟+迟到波动4倍",才有把握准时等到人。
2.2 重传后的样本不能用来训练预测模型
这里有个非常关键的工程细节:如果一个报文段超时重传了,TCP不会用这个重传段后来的ACK去更新SampleRTT。原因是,我们收到ACK时,根本没法确定这个ACK是对第一次发送还是第二次发送作出的响应。如果拿重传后的ACK去算RTT,可能算出一个极其虚低或虚高的样本,污染整个估计。这就是Karn算法想解决的核心问题。不过教科书也提到,超时之后会启动指数退避:连续超时则把超时时间翻倍,而不是重传给一个固定值。原理和以太网退避类似,防止网络已经拥塞时,重传风暴继续踩踏已经脆弱的链路。
2.3 快速重传:不等超时的信号
超时机制固然可靠,但它有一个天然缺陷:等待是昂贵的。一个段丢了,发送方要憋到超时结束才重传,这一个RTT的损失在高速网络里十分明显。TCP的设计者从接收方的行为里发现了一个信号:如果接收方收到乱序段,会立刻对已收到的最后一个连续字节回复一个重复ACK。也就是说,发送方连续收到3个相同的ACK时,基本可以断定某个段是真的丢了,而不是简单的乱序。这时TCP立刻重传,不走超时流程,这个动作叫快速重传。
这里我要强调一下为什么是"3个重复ACK"而不是2个或4个。教材的解释是,一个重复ACK可能只是某个段乱序到达;两个重复也仍然可能是乱序程度稍深;连续3个就在"乱序"和"丢失"之间形成了足够强的置信度。可以理解成:一次敲门可能是恶作剧,两次敲门也可能是巧合,连续三次一致的信号,就该开门看看了。实际工程里,快速重传能把丢包恢复时间从"两个超时周期"压缩到"一个RTT以内",在丢包率较高但延时不长的网络上,对吞吐的影响是数量级的。
3. 流量控制:接收端视角的限速器和它的别名陷阱
3.1 接收窗口rwnd:从接收方的感受出发
学完可靠传输后再看流量控制,很容易误以为它和拥塞控制是一回事,其实两者面对的对象完全不同。流量控制解决的是"接收方来不及处理"的问题,和网络拥塞没有半点关系。接收方在TCP报文段头里有一个16位的接收窗口字段rwnd,它告诉发送方:"我现在的接收缓冲区还能装下多少字节"。发送方的基本约束是:
LastByteSent - LastByteAcked ≤ rwnd
换句话说,发送方把所有已发送未确认的数据量,控制在接收方当前可接收的范围之内。接收方的rwnd不是固定值。当应用层慢慢从缓冲区读取数据时,rwnd会逐渐变大;当应用层读得慢、缓冲快满了,rwnd就会变小。这个字段放在每个ACK里带回给发送方,相当于接收端在每一条反馈里同时通告"我已经收到哪里了,以及我还能吃下多少"。
我当年学这块时最大的错觉是,以为流量控制和可靠传输用的是同一个窗口。实际上,可靠传输的滑动窗口说的是"哪些数据可以发、哪些要等确认",而流量控制只管"别一次性塞爆接收方的缓冲区"。它们一个管顺序,一个管容量,完全不冲突。发送方真正受的最终约束,是这两个窗口的交集,也就是后文要说的min(cwnd, rwnd)。
3.2 零窗口和探测包:一次容易被忽略的边界状况
如果接收方连一个字节都收不下,会通告rwnd=0。此时接收方的确不能处理新数据,但这不意味着发送方就可以彻底闭嘴。因为发送方一旦不再发包,就可能收不到接收方后续发出的窗口更新,如果接收方在窗口更新包中也丢了,双方就死锁了。TCP的解法是:发送方即使看到rwnd=0,也要隔一段时间发出一个只有1字节数据的探测段。接收方收到后会强制回复一个ACK,并在ACK里带上当前最新的rwnd,从而打破死锁。
这个机制我在读书时几乎一眼带过,后来在模拟拥塞场景时才发现它的价值。如果你用netstat观察长连接,在接收缓冲区满时,仍然能看到少量1字节的探测包周期性出现,这不是协议出问题,而是TCP的自救机制。理解零窗口后,还能顺带解释一个面试里常见的问题:为什么TCP首部里rwnd只有16位,最大只有65535字节,现代网络动不动几MB的窗口怎么办?答案是TCP引入了窗口缩放选项,在握手阶段协商一个缩放因子,把字段实际能表达的范围扩大。能想到这一层,说明你是真的在理解协议而不是背字段。
这里还可以做一个小对比,把流量控制和拥塞控制放在一张表里强迫自己分清:
| 对比项 | 流量控制 | 拥塞控制 |
|---|---|---|
| 保护对象 | 接收方应用进程的接收缓冲区 | 路由器队列、整条网络路径 |
| 作用范围 | 发送端和接收端点对点 | 发送端到网络全局 |
| 反馈信号 | 接收方在ACK里通告的rwnd | 超时、重复ACK |
| 控制窗口 | rwnd | cwnd |
这张表我一直保留到读第四章,因为后面讲述的网络拥塞状态正是在流量控制之上再加一层约束,没有这张对比表,两个概念很容易滚进一个坑里。
4. 拥塞控制:网络没有红绿灯,丢包就是唯一信号
4.1 为什么不能只用流量控制拦住一切
如果把流量控制当成唯一限制,整个网络会遭遇一种典型的"局部理性、全局崩溃"。发送方和接收方之间的链路状态很好,接收方缓冲区很空,发送方就放开手脚猛发,可中间经过的路由器可能早已排队溢出。路由器丢包后,发送方看到的是超时或重复ACK,却不知道丢包发生在哪一跳,更不知道网络上还有多少其他连接在抢带宽。TCP面对这种"黑箱",没有中间节点给它发红绿灯信号,只能通过端到端的观察去推断拥塞程度,这就是所谓端到端拥塞控制。
教材在第三章专门拿出一节讲拥塞控制原理,核心结论是:丢包并不可怕,可怕的是当丢包率升高后,发送方如果继续按原速率发送,只能是让丢包更多、吞吐更差。一个在理想无拥塞情况下吞吐率随发送速率线性上升的网络,一旦超过拐点,继续增加负载反而会造成吞吐率下降。拥塞控制的本质,就是让发送方在"探测更多带宽"和"不要压垮网络"之间维持平衡。
4.2 AIMD:加性增,乘性减背后的动机
TCP拥塞控制的基本动作被概括为"AIMD":加法增加(AI)和乘法减小(MD)。为什么会选择这个组合,而不是"按比例增加、按比例减小"或"线性增加、线性减小"?关键在不对称性。
- 加法增加:拥塞窗口
cwnd每个RTT增加1个MSS。这样带宽探索是渐进式的,网络状态好时,慢慢抬高水位,不会因为一次盲目暴涨把链路打崩溃。 - 乘法减小:一旦检测到丢包,
ssthresh立刻降为当前cwnd的一半,甚至更强。之所以要乘性减,是因为要快速退出危险的拥塞状态。乘性减在一次拥塞中就把流量砍去一半,能迅速缓解网络压力,给其他排队的数据让路。
这就是典型的"慢启动、快反应"。现实中,如果你用脚本去压测某个服务,也会发现:稳定状态下一段一段地加并发,一旦出现超时错误,立刻把并发腰斩,往往比"慢慢降"更能挽救整体成功率。TCP的AIMD就是这种运维直觉的协议化。
需要留意的是,拥塞控制里两个重要丢包信号意味着不同级别的拥塞:超时的信号更严重,因为只有队列满溢,路由器完全丢弃长期排队的数据,TCP才会等不到ACK;而三个重复ACK意味着仍然有后续数据能返回,说明网络还能勉强消化流量。教材据此设计了两套恢复策略,超时后要回到慢启动,走最保守的路径;重复ACK则可以走快速恢复,保留一定带宽。这个差异在第五章里非常关键。
5. 拥塞窗口三态切换:从慢启动到快速恢复的完整手感
5.1 慢启动:指数试探和它的停止条件
拥塞窗口cwnd初值通常只有1个MSS。慢启动的意思是,每收到一个新的ACK,cwnd就增加1个MSS。你可能会奇怪,这哪里"慢"了?在一个RTT里,因为20个ACK会陆续回来,所以cwnd大概在一个RTT内翻倍,从1到2,2到4,4到8,这是指数增长,一点不慢。
那为什么叫慢启动?历史视角是:相对于一开始就发送一大窗数据,从1个MSS慢慢拉升已经是很有节制了。指数增长的目的,是快速找出当前链路能承受的带宽大致范围,因为刚开始连接时我们对该路径完全没有了解,如果线性探测,要花几十个RTT才能爬上去,太不值得。慢启动一直增长到cwnd到达一个阈值ssthresh为止。第一次连接时,ssthresh往往设得很大,比如64KB,所以慢启动会一直冲,直到出现丢包信号才停下来。
5.2 拥塞避免:在谨慎和效率之间摆动
一旦cwnd达到或超过ssthresh,TCP进入拥塞避免阶段。此时每个RTT,cwnd只增加1个MSS,也就是从指数变成线性。教材里常把这个阶段画成一条平缓上升的直线,配合慢启动的陡峭曲线,形成"先锐角拉升、再平缓爬坡"的视觉印象。我建议大家不要只在图上看,要亲手推一遍:如果MSS是1000字节,ssthresh是16000字节,那么在慢启动阶段会经历1、2、4、8、16单位这样的翻倍,进入拥塞避免后则变成17、18、19……每一步都只是上一个RTT加1。
这里有个我当年反复犯错的点:从慢启动切到拥塞避免,并不是因为网络拥塞,而只是到达了一个"我们觉得可能危险"的阈值。真正让流程发生跳变的,永远是事件:要么来了一个超时,要么来了三个重复ACK。
5.3 快速恢复:丢包后不回到起点的折中
当收到三个重复ACK时,TCP先做两件事:把ssthresh设为cwnd/2,再用快速重传把丢失的段补发出去。至于cwnd本身,Reno算法把它设为ssthresh + 3×MSS,然后进入快速恢复状态。之后的每个重复ACK,cwnd再加1个MSS,相当于"网络还能往回传数据,我就再挤一点带宽出来";直到收到一个新的ACK,确认丢失段已被补齐,再把cwnd降到ssthresh,回到拥塞避免继续线性增长。
快速恢复的核心思路是:多个重复ACK说明网络还没死透,丢包可能仅仅因为偶发拥塞,不必夸张地回到1个MSS重新慢启动。但如果是超时,那说明网络状态严重得多,TCP才会把cwnd重置为1,然后重新进入慢启动。这两种策略的对比,也正好呼应了第四章"超时意味着路径大概率已经严重拥塞"的结论。
5.4 一个手推的完整例子,把状态机变成直觉
为了把三态跑通,我常用一个手算例子,ssthresh初始值设成16个MSS,cwnd从1开始。RTT记为1个回合:
- 第1回合:cwnd=1
- 第2回合:cwnd=2
- 第3回合:cwnd=4
- 第4回合:cwnd=8
- 第5回合:cwnd=16,到达ssthresh,进入拥塞避免
- 第6回合:cwnd=17
- 第7回合:cwnd=18
假设第8回合开始时传来三个重复ACK,说明数据在第7回合发送过程中丢了。按照Reno逻辑,把ssthresh降为9,cwnd先设为12(ssthresh + 3),进入快速恢复;如果在恢复期间又收到一个新的重复ACK,cwnd继续加到13、14……等到新ACK到达,确认丢失段恢复,cwnd被降到9,重新回到拥塞避免,并从第9回合开始每回合加1。
这个例子非常简单,但我建议你一定自己动手按时间线把ACK数量算出来,而不是只看数字结论。因为真正的难点不是状态转换表,而是你能否意识到:cwnd的每一步变化都对应着一个或一批真实ACK的到达,而不是"时间到了就自动加"。很多同学在笔试里能背出状态转换图,可一遇到"如果网络延迟变成原来的3倍,慢启动增速会怎么变化"就懵,就是因为脑子里只有状态图,没有"每条ACK都是changing trigger"这个动态直觉。
如果把这一章的TCP拥塞控制理解为一个人的行为准则,那整章其实就讲了四句话:带宽要一点点试探;风险来了要果断收缩;不同的丢包信号代表不同程度的危险;每次调整都来自于对方的反馈,而不是自己的猜测。第三章的最后一节还讨论了公平性,多个TCP连接在带宽分配上如何达到近似公平,这是AIMD机制的宏观结果。学完这一章再去翻第四章路由选择,你会发现网络层解决的是"包往哪走",而传输层解决的是"包应该以多快的速率走"——两者一脉相承。
我在实际操练中最大的感受是,光靠读书是不够的,必须自己搭一个小实验环境,用两台虚拟机和一条限制带宽的网桥去观察cwnd的真实变化。当你在抓包工具里亲眼看到SYN之后cwnd按1、2、4、8的节奏爬升,又在一个RTT里因为重复ACK被腰斩,那种理解程度是任何文字都替代不了的。第三章内容多、公式多,但只要抓住"字节序号、RTT采样、接收窗口、丢包信号"四条主线,后面的拥塞控制也就是顺水推舟的事。