TCP 可靠性机制:确认、重传、滑动窗口
上一篇我们看清了握手和挥手——连接是怎么建起来、怎么拆掉的。但连接建好之后,真正的难题才开始:IP 层只负责尽力把包丢出去,不保证送到、不保证顺序、不保证不重复。也就是说,TCP 是在一条本质上不可靠的管道上,硬生生造出了一条"可靠的字节流"。
它是怎么做到的?很多人会含糊地答"靠确认和重传",但说不清这套机制具体怎么转。问题还是出在学习顺序:先记结论,没先看清一段数据从发出去、到被确认、到窗口往前挪一格,这个过程里每个字节、每个序号、每个缓冲区到底发生了什么。
所以这一篇仍然先机制、后设计:先把确认怎么算、重传何时触发、滑动窗口如何一格一格往前移动这套运转过程看清楚,再回头解释为什么要这么设计。 盯住"序号"和"窗口边界"这两样东西,整件事就透了。
一、先把舞台上的"零件"摆清楚
要看懂机制,先认识参与的几个量。TCP 把要发的数据看成一条连续编号的字节流——这是上一篇握手时同步 ISN 的延续,每个字节都有自己的序号。
发送端维护的关键变量:
SND.UNA:已发送但还没被确认的最小序号(窗口左边界);SND.NXT:下一个要发送的新数据序号;- 发送窗口:从
SND.UNA开始,允许在途的字节范围。
接收端维护的:
RCV.NXT:期望收到的下一个字节序号;- 接收缓冲区:暂存已收到(含乱序)但还没交给应用的数据;
rwnd:通告给对方的接收窗口(下一篇流量控制的主角,这里先知道它会约束发送窗口)。
记住一句话:发送端的世界被两个指针切成三段——左边是"已确认、可以丢弃",中间是"已发出、等确认",右边是"还没发、但窗口允许发"。整套可靠性机制,就是这三段的边界在不断移动。
二、确认机制:ACK 到底在确认什么
TCP 的确认是累计确认(cumulative ACK)。接收端回的 ack 字段不是"我收到了第 N 个包",而是"序号 N 之前的字节我已经全部连续收到了,请从 N 开始发"。
这点至关重要。假设接收端已经连续收到字节 1~1000,它会回 ack=1001。如果接下来 1001~2000 这段丢了,但 2001~3000 先到了——接收端不能回 ack=3001,因为中间 1001~2000 还缺着。它只能仍然回 ack=1001,表示"我还在等 1001"。
于是出现一个现象:每来一个乱序到达的报文段,接收端都会重复回同一个 ack=1001。这就是后面快速重传赖以工作的重复 ACK(duplicate ACK)。
累计确认用一个数字表达了"到此为止全连续",表达极其高效;代价是它无法精确告诉发送端"中间到底缺哪一段"。这个表达力的缺口,正是后来 SACK 选项要补的。
SACK(选择性确认)就是接收端在 ACK 里额外用选项字段带上"我还乱序收到了 2001~3000 这一段",让发送端知道缺口的精确位置,从而只补那一段,不必把后面已经到的也重发。
三、重传:发送端怎么发现"它没到"
发送端发出一段数据后,并不会无限等待。它有两套独立的触发机制来决定"该重发了"。
第一套——超时重传(RTO)。 发送端为在途数据维护一个计时器。如果在 RTO(重传超时时间)内没等到对应的 ACK,就认定这段大概率丢了,重发。
关键在于 RTO 不是写死的常数,而是根据实测往返时延动态算出来的。TCP 持续采样每个报文段从发出到被确认的时间,得到 RTT,再用一套平滑公式(维护加权平均 SRTT 和偏差 RTTVAR)算出 RTO,大致是"平均 RTT 加上几倍抖动"。网络稳定时 RTO 收紧,抖动大时 RTO 放宽,避免误判。
第二套——快速重传(fast retransmit)。 超时往往要等几百毫秒甚至更久,太慢。前面说过,丢包会导致接收端重复回同一个 ACK。于是 TCP 规定:发送端一旦连续收到 3 个重复 ACK(即同一个 ack 值出现了第 4 次),就不等计时器超时,立刻重发那个被指明缺失的报文段。
为什么是 3 个?因为网络里少量乱序很常见——一两个包乱序到达也会引发一两个重复 ACK,但不代表丢包。等到 3 个重复 ACK,乱序的可能性就低多了,判定为丢包比较稳妥。这是个工程上的经验阈值。
超时重传是"兜底",慢但一定能发现丢失;快速重传是"抢跑",在收到足够的重复 ACK 信号时提前补发,把发现丢包的延迟从一个 RTO 压缩到约一个 RTT。
四、滑动窗口:一格一格往前挪的全过程
现在进入这一篇的核心。如果发送端每发一段就停下来等 ACK(即停等协议),那链路大部分时间在空转——发一个段、干等一个 RTT、再发下一个。滑动窗口的本质,是允许一批数据同时在途,用"批量在飞"填满整个 RTT。
我们用一个具体例子走一遍窗口的移动。假设发送窗口大小是 4 个段(编号 1~4 先发),SND.UNA=1:
- 初始:窗口覆盖 1、2、3、4。发送端把这 4 段全部发出,
SND.NXT推到 5。此刻 1~4 都是"已发未确认"。 - 收到
ack=2:表示段 1 已确认。左边界SND.UNA右移到 2,窗口现在覆盖 2、3、4、5——注意右边界也跟着右移了一格,于是段 5 进入窗口、可以发送。这就是"滑动":左边被确认的腾出来,右边新数据补进来。 - 收到
ack=3:段 2 确认,窗口滑到覆盖 3、4、5、6,段 6 获准发送。 - 如此持续。每收到一个推进的 ACK,窗口就整体向右"滑"一格,旧数据出窗(可从发送缓冲区丢弃),新数据入窗(获得发送资格)。
把它想象成一条传送带上的取景框:框的宽度固定(窗口大小),框左边的箱子一旦被确认运走,整个框就向前推,框右边露出的新箱子立刻可以装运。数据流源源不断从框里经过,而框始终只盯着"在途的这一批"。
那如果中间丢了呢?接上面,假设段 2 丢了、段 3 和 4 先到。接收端因为缺 2,会对段 3、4 的到达都重复回 ack=2。发送端的左边界 SND.UNA 卡在 2 动不了——窗口无法右滑,新数据 5、6 即便在窗口内发出去了,也换不来窗口前进。直到 3 个重复 ACK 触发快速重传补发段 2,接收端收齐后才会跳着回 ack=5(因为 3、4 早缓存好了),窗口这才一下子向前滑动一大格。
窗口能不能往前滑,唯一取决于左边界
SND.UNA能不能推进,而它只被连续确认推动。一个早期报文段卡住,后面全到了也没用——这就是累计确认下"一损俱损"的窗口行为。
五、乱序与接收缓冲:为什么应用看到的总是有序流
接收端的职责,是把网络这一层的乱序、丢失、重复,全部消化掉,只给应用一条干净有序的字节流。
机制是这样的:接收端收到一个报文段,先看它的序号。
- 如果正好等于
RCV.NXT(正是我在等的),就交付,并把RCV.NXT向前推;如果缓冲区里之前还缓存了紧接其后的乱序段,一并连续交付、一路推进。 - 如果序号大于
RCV.NXT(来早了,前面有缺口),就先缓存在接收缓冲区,不交付给应用,同时回一个重复 ACK 催发送端补缺口。 - 如果是已经收过的重复数据(重传过头或网络重复),直接丢弃,但仍回 ACK。
所以应用层 read() 到的永远是连续的——乱序和重传的复杂性全被接收缓冲区吸收了。这也解释了一个现象:网络明明在丢包重传,应用却只是觉得"有点慢",而不是收到乱序数据,因为有序性是 TCP 的硬保证。
六、把三者拧成一股绳
到这里可以把机制串成一条主线了:
- 滑动窗口决定"一次能让多少数据在途",用批量在飞填满 RTT,解决效率;
- **确认(累计 ACK / SACK)**告诉发送端"哪些已经稳妥到达",推动窗口左边界前进;
- **重传(超时 / 快速)**负责发现并补上丢失的段,让卡住的左边界能重新推进。
三者缺一不可:没有窗口,效率退回停等;没有确认,窗口不知道何时该滑;没有重传,丢一个段窗口就永远卡死。它们共同把"不可靠的 IP 包尽力投递"变成了"可靠、有序、不重复的字节流"。
七、看懂机制后,再问:为什么这样设计
现在回答几个"为什么",会很顺。
为什么用累计确认而不是逐包确认? 逐包确认的 ACK 数量和数据包一样多,开销大且脆弱(丢一个 ACK 就要处理一个空洞)。累计确认用一个数字概括"到此全连续",既省 ACK,又天然容忍 ACK 丢失——后一个累计 ACK 会覆盖前一个的信息。
为什么需要窗口大小这个上限,而不是无限制地猛发? 因为接收端缓冲有限(流量控制,下一篇),网络承载也有限(拥塞控制,下下篇)。窗口正是这两个约束的落地载体——实际发送窗口取接收窗口 rwnd 与拥塞窗口 cwnd 的较小值。可靠性机制提供了"窗口"这个旋钮,流控和拥塞控制负责拧它。
为什么丢包代价这么大? 因为累计确认下,一个段卡住会冻结窗口前进。这正是高带宽长距离链路(所谓长肥管道)对丢包格外敏感的根源,也是 SACK、选择性重传不断被强化的动机。
八、和工程实践 / 后端开发的联系
把这套机制对上真实排障,知识立刻变得能用:
- 抓包看到大量
TCP Dup ACK和TCP Retransmission:说明链路在丢包,TCP 正在靠重传补洞。吞吐下降往往不是应用卡了,而是窗口被反复卡住、推进缓慢。 ss -ti看retrans、rtt、cwnd:能直接看到某条连接的重传次数、实测 RTT 和当前拥塞窗口,判断慢在哪一层。- 长肥管道吞吐上不去:高带宽、高延迟链路上,若发送窗口(受缓冲区或
rwnd限制)不够大,窗口里的数据填不满一个 RTT 的容量,带宽就用不满。这时需要开启窗口缩放选项、调大缓冲区。 - 重传超时拖慢请求:偶发丢包若没触发快速重传(比如丢的是窗口最后一个段,凑不齐 3 个重复 ACK),就只能等
RTO超时,表现为请求偶尔卡顿几百毫秒。这是尾部丢包的典型症状。
学习这一部分最容易踩的坑
1. 以为 ACK 是"确认收到了这个包"
TCP 的累计 ACK 确认的是"这个序号之前全部连续收到",不是单个包。想不通这一点,就理解不了重复 ACK 为什么会出现、快速重传为什么靠数它来触发。
2. 把"重传"和"乱序"混为一谈
后发先至的乱序是网络常态,本身不等于丢包。正因为存在乱序,快速重传才要等 3 个重复 ACK 而不是 1 个,避免把乱序误判成丢失。
3. 以为窗口大小就是发送方说了算
实际发送窗口是 min(rwnd, cwnd),分别受接收端能力和网络承载约束。只盯发送端缓冲区,会漏掉流量控制和拥塞控制这两个真正常见的瓶颈。
4. 忽视 RTO 是动态估算的
RTO 随实测 RTT 不断调整,不是固定值。误以为它是常数,就理解不了为什么同一份代码在不同网络下重传行为差别那么大。
总结
这一篇我们坚持先讲机制、再讲设计,要带走的是"序号 + 窗口边界"这条主线:
- TCP 在不可靠的 IP 之上,用确认、重传、滑动窗口三者协作造出可靠有序字节流;
- 累计确认用一个序号表达"到此全连续",高效但无法指明缺口,SACK 用来补位;
- 重传有两套:超时重传兜底(依赖动态 RTO),快速重传抢跑(依赖 3 个重复 ACK);
- 滑动窗口的本质是让一批数据同时在途填满 RTT,窗口能否前移只取决于左边界能否被连续确认推动;
- 接收缓冲区吸收乱序与重复,保证交给应用的永远是有序流;
- 实际发送窗口是接收窗口与拥塞窗口的较小值——这正是接下来流量控制与拥塞控制要拧的旋钮。
当你能在脑子里看到那条传送带上的取景框随 ACK 一格格前移、并说清它卡住时是哪个序号在拖后腿,这部分就真正属于你了。下一篇我们就来看,接收端是怎么通过通告 rwnd 来约束这个窗口的。
参考资源:
- 《计算机网络:自顶向下方法》
- 《TCP/IP 详解 卷一:协议》
- RFC 5681 - TCP Congestion Control
- RFC 2018 - TCP Selective Acknowledgment Options