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. 初始:窗口覆盖 1、2、3、4。发送端把这 4 段全部发出,SND.NXT 推到 5。此刻 1~4 都是"已发未确认"。
  2. 收到 ack=2:表示段 1 已确认。左边界 SND.UNA 右移到 2,窗口现在覆盖 2、3、4、5——注意右边界也跟着右移了一格,于是段 5 进入窗口、可以发送。这就是"滑动":左边被确认的腾出来,右边新数据补进来。
  3. 收到 ack=3:段 2 确认,窗口滑到覆盖 3、4、5、6,段 6 获准发送。
  4. 如此持续。每收到一个推进的 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 ACKTCP Retransmission:说明链路在丢包,TCP 正在靠重传补洞。吞吐下降往往不是应用卡了,而是窗口被反复卡住、推进缓慢。
  • ss -tiretransrttcwnd:能直接看到某条连接的重传次数、实测 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 来约束这个窗口的。

参考资源