ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TCP可靠传输与流量控制:滑动窗口、重传机制与RTO算法全解析

TCP可靠传输与流量控制:滑动窗口、重传机制与RTO算法全解析

很多同学学到“TCP 可靠传输与流量控制”这一篇时,第一反应往往是“又要背一堆机制和参数”。确实,这一篇是计网里知识点最密集的章节之一,但如果你只知道“超时重传”“滑动窗口”这六个字,考场上照样容易翻车。我带过不少学生,也接手过很多实际网络排查任务,今天这篇文章就想把这里面的门道一次讲透:可靠传输到底在可靠什么,流量控制到底在控制什么,以及怎么把这些内容串成自己的理解,而不是零散背诵。

这篇内容主要面向正在学计算机网络课程的同学,尤其是准备期末或考研的人;同时也适合那些工作中被 TCP 相关问题困扰的开发或运维同学。你不需要提前掌握特别多的协议细节,只要跟着思路走,我会把“为什么这样设计”讲清楚,再给一套可以拿去直接用自查的方法。文章会围绕字节序号、确认机制、重传策略、超时计算、接收窗口和滑动窗口这几个大块展开,中间穿插实际例子和容易踩的坑。

1. 可靠传输第一课:为什么 TCP 非要自己解决这么多问题

先明确一个底层前提:IP 层的服务模型是“尽力交付”,不是“保证交付”。数据包在网络上传输时,可能被路由器丢弃,可能因为链路震荡而迟到,也可能因为网络拓扑变化而重复到达。TCP 活在 IP 之上,却要向应用层提供“稳定的、有序的、不重复的字节流”,这就意味着它必须自己把 IP 带来的乱七八糟情况全部收拾干净。

1.1 可靠性的四个维度:不丢、不重、不错、不乱

很多教科书喜欢把可靠传输归纳成“让接收方得到和发送方一致的数据”,实际拆开看,需要同时对付四类威胁。

威胁表现TCP 对应的手段
损坏数据位翻转、校验失败校验和 + 接收方校验后丢弃坏段
丢失报文段得不到确认定时器驱动超时重传
重复同一数据多次到达序号去重,窗口只接收符合预期的字节
乱序后发先至序号重组,缓冲区暂存

这四个目标不是独立实现的,它们是同一套机制在多个层面的相互配合。比如“不重”和“不乱”都由字节序号负责,但真正落地执行时,接收方的滑动窗口缓冲区也在起重要作用。后面我会展开讲。

1.2 为什么 TCP 还要再做一次端到端校验

有人会问:现在的链路层基本都有 CRC 校验,TCP 再算一遍校验和是不是有点多余?我自己的经验是,这个校验绝不能省。链路层的校验只能说明这一段链路上数据没有损坏,但报文要经过多台路由器和交换机,中间任何设备的转发逻辑都可能存在隐患或异常,TCP 的校验和覆盖了整个报文段,甚至在计算时还会把源 IP、目的 IP、长度等“伪头部”信息加进去,等于在接收端重新做了一次端到端完整性确认。这在某些低误码率但高复杂度的网络环境里,往往就是抓住偶发性传输问题的最后一道保险。

提示:TCP 的校验和虽然计算很简单,但它承担着“最终兜底”的角色。排查业务数据偶发异常时,如果应用层没做完整性校验,第一件该怀疑的就是 TCP 校验和有没有在实际传输中暴露问题。

1.3 纠错 vs 重传:为什么 TCP 选重传而不是前向纠错

数据传坏了,理论上可以用纠错码现场修复,但 TCP 没有选择这条路,而是要求接收方丢掉坏数据、发送方重新传一遍。道理很简单:网络信道的情况千变万化,纠错码的修复能力有限;而且 TCP 是多路复用、长时间连接的可靠流协议,重传的成本是带宽,前向纠错的成本是不断叠加冗余比特。对于文件传输、网页请求这类场景,重传显然更可控。这也是“宁可等一个 ACK,也不让应用层收到脏数据”的设计思路。

2. 序号与确认号:字节流里的“地址系统”长什么样

TCP 不是把网络层的数据报当作独立单元来管理的,它把应用层连续交付的一长串数据看成一条连续的字节流,然后给每一个字节编一个号。这个编号体系,就是可靠传输的根。

2.1 字节编号与报文段序号的关系

一个报文段头部里的 seq(序号),表示的是“这个报文段携带的第一个字节的编号”。比如某个报文段的 seq=3001,长度是 1000 字节,那么它携带的是字节 3001 到 4000。注意,这里讨论的是数据字节编号,不包含 TCP 头部长度。

连接刚开始的时候,双方需要协商初始序号。现代 TCP 实现不会固定从 0 开始,而是选择一个随时间变化的初始序号,避免历史连接中残留的报文段被误认为是当前连接的有效数据,同时也降低被第三方猜测序号的风险。这也是为什么抓包时经常看到 seq 是一个很大的随机初始值。

2.2 确认号表示的到底是哪一个字节

这是最容易混淆也最常考的点。TCP 的确认号不是“最后一个已收到字节的编号”,而是“期望收到的下一个字节的编号”。假如接收方已经正确处理了字节 3001 到 4000,它在回应报文里要填 ack=4001,而不是 4000。

我举一个更具体的例子:

发送方发出一个 seq=5000、长度 1000 字节的报文段,接收方如果全部收到,会回复 ack=6000。这个 6000 的含义是:“直到编号 5999 的字节,我这边都收到了,下一个请从 6000 开始发。”

这个“下一个期待位置”的表达方式,看似只是差一位,实际语义完全不同。很多人在计算题里就是因为把 ack 当成了“最后收到的字节号”,结果算出来的窗口位置和重传起点全都错位。

2.3 累积确认与重复 ACK 的来历

TCP 的确认是累积的,意思是确认号一旦到达 N,就意味着“序号在 N 之前的字节全部确认成功”,不需要对每个报文段单独确认。这大大减少了 ACK 报文的数量。

但这种设计也带出一个特性:如果中间某个报文段丢了,接收方只能不断重复回复“我期待的是那个缺失字节”,这就是重复 ACK。假设发送方发了 seq=1001 和 seq=2001 两个报文段,接收方收到了 seq=2001,却还在等 seq=1001,那么它会持续回复 ack=1001。发送方连续看到多个相同的 ack=1001,就能推测出 1001 段很可能丢了。后面讲的快速重传,正是建立在这种重复 ACK 的语义之上。

注意:ACK 是可以捎带的。如果接收方正好也要向发送方发送数据,它可以把自己的数据报文段的 ack 字段一起填上,这样就不需要单独发一个空 ACK,省一个报文,也省一段传输时间。这是优化手段,但实际使用中要注意“延迟确认”的副作用,稍后会提到。

3. 重传机制:从停等协议到滑动窗口的三级跳

理解重传,先要从最简单的停等协议开始。停等协议是“发一个、等确认、再发下一个”,逻辑简单,但效率非常糟糕。如果只看课本公式,很难有体感;我把具体数值摆出来你就明白了。

3.1 停等协议的效率有多低

假设链路带宽 1Gbps,往返时延 RTT 为 200ms,要发送一个 1000 字节的报文段。发送这 1000 字节本身只需要 1000×8 / 1e9 = 8 微秒左右,但一个完整的“发送+确认”周期要等一个 RTT,也就是 200ms。算下来,链路利用率还不到 0.004%,绝大部分时间线路都在空等。

这个计算揭示了可靠传输的一个核心思路:想要高效率,就不能一个一个地发。TCP 选择用滑动窗口把多个报文段“流水线”式地连续发出,让确认在当前传输还没结束时陆续回来。这样一来,链路被报文填满了,带宽才算真正用上。

3.2 回退 N 帧:连续发送但丢一个就全重来

回退 N 帧(Go-Back-N)是滑动窗口早期形态。发送方可以连续发送窗口内的多个报文段,接收方只按预期顺序接收,一旦发现某个序号没按顺序出现,就把后面到达的乱序段丢弃。发送方超时后,会把丢失报文段之后的所有数据全部重发一遍。

这种策略接收方缓存压力极小,但代价是如果窗口很大、链路比较长,一次丢包可能触发大量无用重传。我在测试环境里见过最典型的场景:在 RTT 较大的链路里丢一个包,结果后续一窗口的数据都跟着重传,带宽浪费特别明显。所以实际 TCP 并没有把“回退 N 帧”作为唯一策略,而是混合使用其他优化手段。

3.3 选择重传与 SACK:哪里丢了补哪里

选择重传(Selective Repeat)的思路就聪明得多:接收方把乱序到达的报文段先缓存起来,只对缺失部分发出重传请求;发送方也只重发真正丢失的报文段。TCP 里的标准扩展是 SACK 选项,接收方通过 SACK 块告诉发送方“我已经收到了哪些不连续区间”,发送方从而精确地填补空洞。

我在实际抓包分析时,特别推荐关注 SACK 字段。如果一个连接反复出现多个 SACK 块,说明当下网络乱序或丢包严重;如果只有零星的单块 SACK,多半是偶发拥塞。这个判断经验在定位“应用层偶发超时”时非常有用。

三种机制可以简单对比如下:

机制接收方缓存重传单位带宽利用率实现复杂度
停等只存一个单个报文段极低最低
回退 N 帧不缓存乱序包从丢失处开始全窗口重传丢包时浪费大中等
选择重传 / SACK缓存乱序包只重传缺失部分高较高

3.4 快速重传:不等超时,靠重复 ACK 判断丢包

超时重传是最基础的兜底,但超时等待往往比较久。TCP 加入了快速重传机制:如果发送方连续收到 3 个相同的重复 ACK,就直接判定那个序号对应的报文段丢了,不等定时器超时,立即重传。

这里有个细节值得展开:3 个重复 ACK 不是随便定的。网络中因乱序产生一两个重复 ACK 属于常见情况,如果只出现 1 次重复,可能只是后发先至,不值得大动干戈;连续到了第 3 个,才能比较有把握地说“大概率是真丢了”。当然这不是绝对判定,有些特殊网络环境下乱序严重也会触发快速重传,不过相比之下,这仍然比重传整个窗口划算得多。

4. 超时时间 RTO 的算法:为什么不能拍脑袋定个值

重传机制的另一个关键参数是超时时间(RTO)。这个值如果设太小,网络还没到重传的时候就被误判,导致大量重复报文;如果设太大,丢包后长时间干等,用户就会感受到明显卡顿。所以 RTO 必须动态跟随网络状态。

4.1 基于 RTT 采样与平滑估计

TCP 会持续测量报文从发出到收到确认的时间,得到 RTT 样本。但单次 RTT 抖动太大,不能直接作为 RTO,需要做平滑处理。经典的估算方式由 RFC 6298 定义:先更新平滑后的 RTT 估计值,再更新偏差值,最后用这两个量算出 RTO。

我用公式给你捋清楚:

  • SRTT = (1 - α) × SRTT + α × RTT(α 常取 1/8)
  • RTTVAR = (1 - β) × RTTVAR + β × |SRTT - RTT|(β 常取 1/4)
  • RTO = SRTT + 4 × RTTVAR

最后一个式子里的 4 倍偏差,是为了给超时留足够余量,宁可比真实 RTT 稍微大一点,也不要因为网络抖动而疯狂重传。这个思路很像日常生活中的“时间管理”——给出一个平均耗时,再额外加一定缓冲,避免每次都在临界点翻车。

4.2 Karn 算法:重传样本的“污点”问题

RTT 采样有一个非常隐蔽的坑:如果一个报文段已经超时重传了一次,后来到达的 ACK,到底是针对第一次发送还是针对重传包的确认?你无法确定。

如果把这个含混的 ACK 用于 RTT 计算,算出来的 RTO 会整个失真。解决办法就是 Karn 算法:发生重传的那次报文段,它的 RTT 样本直接丢弃,不参与平滑计算;等后续正常传输出现新的、没有歧义的 RTT 样本后,再恢复更新。这相当于给统计样本做了清洗,保证估计器始终基于可靠数据。

4.3 指数退避:反复超时的时候别再硬冲

当网络严重拥塞时,TCP 如果每次都按原超时时间傻等,重传报文可能会加剧拥塞。标准做法是:每次超时重传后,RTO 按指数翻倍,比如 200ms 超时,下次 400ms,再下次 800ms。这不是摆烂,而是给网络缓解挤出一段重建秩序的时间。

我在实验环境里见过很多同学看抓包文件时疑惑:“为什么等超时的时间越来越长?”原因就在这里。TCP 不是每次都用同一把尺子量时间,而会根据失败次数主动拉大间隔,这就是指数退避。

提示:在实际排障中,如果抓包看到一条连接反复重传并且 RTO 持续翻倍,通常意味着网络侧拥塞已经比较严重,不是简单的单包丢失。这时候排查思路要从“链路丢包率”转向“带宽瓶颈与队列溢出”。

5. 流量控制:滑动窗口的秘密全在接收方的那句“我还能收多少”

可靠传输依靠重传兜底,但重传是事后补救。如果发送方发得太快,接收方缓冲区被塞满,再强调重传也没意义。因此 TCP 必须有一种机制让接收方实时告诉发送方“我现在还能收多少”,这就是流量控制。

5.1 rwnd 就是接收方的“内存余量”

TCP 报文段头部有一个“窗口”字段,这个字段承载的是接收窗口(rwnd),表示接收方当前还能接收的字节数。发送方在发送数据时,要遵守接收方通告的 rwnd,不能无限往外灌。

这很像两个人配合搬箱子:一个人负责搬,另一个人负责放。负责放的人不断告诉搬的人“我这块空地还能堆多少箱”,搬的人哪怕力气再大,也不能超过空地大小硬塞。空地满了,就得停下来等对方腾出空间。TCP 的流量控制就是这种动态协作。

5.2 窗口边界如何移动

滑动窗口可以用三个区域来理解:

  • 已发送且已确认的字节,窗口左边界已经越过;
  • 已发送但未确认的字节,还在“在途”状态;
  • 可以发送但还没发送的字节,是接收窗口允许的新数据。

每收到一个累积 ACK,左边界就向右推进;接收方腾出缓冲区后,通过新报文段里的窗口字段更新右边界。一个 TCP 连接的“窗口”不是固定大小,而是双方通过一次次报文字段实时协商出来的。

5.3 零窗口死锁:一个容易被忽略的经典问题

如果接收方缓冲区耗尽,它会通告 rwnd=0,发送方就必须停止发送。问题来了:当接收方终于腾出空间时,会发送一个窗口更新报文告诉发送方“窗口变成 N 了”。这个窗口更新报文如果丢失,发送方没有数据可发,自然也收不到任何 ACK;接收方则在那边等数据,双方都认为对方“应该继续”,于是形成死锁。

TCP 的解决方法是持久定时器(persist timer)。发送方在收到零窗口通告后,不会完全沉睡,而是周期性发送一个 1 字节的窗口探测报文,问接收方“窗口到底打开了没有”。接收方无论窗口是否打开,都会回应这个探测报文,并附上最新的窗口值。这样即使窗口更新丢了,发送方也能通过探测尽快发现新窗口。

5.4 流量控制与拥塞控制,别混为一谈

流量控制解决的是“发送方与接收方之间”的速度匹配问题,核心变量是接收窗口 rwnd。而拥塞控制解决的是“网络中间设备”是否过载的问题,核心变量是拥塞窗口 cwnd,这个话题会在下一篇文章展开。

这两个概念连名字都很像,但出发点完全不同。我在辅导同学时经常发现,有人把“滑动窗口变小”全部归因于接收方缓冲不够,其实中间链路拥塞也会压小数据发送节奏。判断时先看是 rwnd 在变小,还是 cwnd 在调整,方向对了,排障才不容易跑偏。

6. 学习与排障中的高频误区:这些坑我见得太多了

最后这部分,我专门把从作业、考试和实际排查中反复出现的问题列一列。如果你能避掉下面这几个坑,这一章的理论框架基本就算立住了。

6.1 误区一:把确认号当成最后收到的字节序号

这个前面强调过,但真的值得再敲一次。既然确认号表示“期望的下一个字节序号”,那么 ack=6000 对应的就是“0 到 5999 都收到了”。很多计算题故意在 6000 和 5999 上做文章,选错答案的往往就是这么把语义弄反的。

自己写计算的时候,我建议强制套这个句式:“ack = N 表示 N 之前的字节都没问题,下一个期待从 N 开始”。每写一个 ack 就先喃喃一遍,比死记公式管用。

6.2 误区二:以为窗口就是“一次能发多少个报文段”

窗口的单位是字节,不是报文段个数。我收到过不止一次这样的提问:“窗口是 8192,报文段是 1000 字节,那是不是能发 8 个?”听起来像那么回事,但 TCP 真正管理的是字节序号区间,报文段只是把字节装起来的运输容器。窗口计算、ACK 推进、重传定位,全建立在字节序号上,而不是“第几个报文段”上。

6.3 误区三:收到快速重传触发条件就盲目触发重传

快速重传的“3 个重复 ACK”是常用的经验阈值,但不是唯一判断依据。实际实现里,TCP 还要结合当前拥塞状态和发送窗口大小来决定是否立即重传。比如乱序严重的网络下,重复 ACK 也可能被误判为丢包,这时 SACK 信息能帮助发送方更准确判断空洞位置。理解机制时要记住:阈值只是手段,识别真实丢包才是目的。

6.4 一个可以当场自测的抓包样例

我给出一个极简的场景,你可以自己推演一遍:

  • 发送方发:seq=1001,len=1000
  • 发送方再发:seq=2001,len=1000
  • 接收方收到前一个段,丢失了第二个段,它会回复 ack=2001
  • 发送方超时或收到重复确认后,应该重发 seq=2001 的段

这里面最容易错的点是:接收方虽然已经收到了 seq=1001 的 1000 字节,但确认号不是 1001,而是 2001。因为它期待的下一个字节是 2001。也就是说“我确认了你发到 2001 以前所有的字节”,这正好体现累积确认和字节级管理的精妙之处。

我在带学生做这个推演时,常用一条规则帮大家理清思路:先把所有字节号按顺序画成一条坐标轴,标出每个段的起始序号和长度,再画一个“接收指针”指在期望的下一字节上。一旦指针位置找对了,整个窗口移动、重传起点都会变得非常清晰。

我自己实际操作中的经验是:学这一章,不要先背 RTO 公式和窗口长度默认值。先把“seq 表示起始,ack 表示期望下一字节”这条主线刻在脑子里,再去看重传和流量控制的实现,会发现所有机制都像齿轮一样咬合在一起。如果你手边有抓包工具,可以找一个本地建立的 TCP 连接,观察一个普通的下载过程的 seq 和 ack 变化,再对照着文章里的窗口机制看一遍。比起背十遍幻灯片,一次真实抓包带来的理解会深刻得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进