news 2026/10/16 7:04:30

TCP可靠传输与流量控制:确认重传、滑动窗口与拥塞控制详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TCP可靠传输与流量控制:确认重传、滑动窗口与拥塞控制详解

计网学到传输层,TCP的可靠传输和流量控制是绝大多数人绕不过去的两个硬骨头。它们不像下层协议那样只负责“发出去”,TCP的职责是把一段字节流完整、按序、可控地送到对端进程手上。这篇主要围绕可靠传输里的确认与重传、滑动窗口,以及流量控制里的接收窗口、持续计时器、糊涂窗口综合症展开,顺带把课程里最容易搞混的“流量控制”和“拥塞控制”一次性讲明白。不管是准备期末考试、面试,还是写网络程序时遇到窗口相关的怪异现象,这篇文章都值得你沉下心看完。

1. 可靠传输的本质:差错、丢包和乱序从哪来

1.1 校验和:把“收到但坏了”的段直接丢掉

可靠传输要解决的第一个问题是“错”。物理链路有噪声,设备可能转发出错,内存也可能瞬时翻转。TCP在报文段末尾放一个16位校验和,覆盖伪首部、TCP首部和数据部分,对端收到之后先算一遍,不一致就意味着这一段在传输过程中被改动了。

这里有个初学者容易卡住的点:接收方发现校验和不一致时,不会主动告诉发送方“你发错了”,而是直接把这个段丢弃,就当没收到。发送方怎么知道出问题了?靠后面要讲的超时重传。换句话说,TCP对“错”的处理方式,是把它转化为“丢”,再靠“丢”的机制兜底。

所以你会看到抓包时出现大量重传,未必是链路真的丢了包,也可能是某些中间设备把损坏段悄悄扔掉。这个“先丢弃,再靠超时补”的思路贯穿了整个可靠传输设计。

1.2 序号与确认号:字节流的坐标系

要解决“乱”和“丢”,必须给数据编上位置。TCP的序号和前一条消息里常见的“报文段编号”不是一个概念,它标记的是发送数据在字节流中的起始位置。比如发送方已经发了1000个字节,下一个段的序号就是1001,表明这个段的第一个字节是整体字节流里的第1001号。接收方的确认号则代表“我期望收到的下一个字节的序号”,也就是对端确认号是N,意味着N之前的所有字节都已按序收到。

举个例子:发送方依次发三个段,序号分别是1001、1201、1401,每个段携带200字节。接收方收到第一个后回ACK=1201;收到第二个后回ACK=1401;如果第三个丢了,回ACK还是1401。这里可以看到,TCP的确认是累积式的——一个ACK代表“这一号之前全收到了”,不用对每个段单独回复,效率很高,但也给后续的乱序效率问题埋下了伏笔。

1.3 停止等待协议的悲剧:链路大部分时间在空等

最开始的设计可以很幼稚:发一个段,等确认,确认到了再发下一个。这就是停止等待协议。它逻辑上没问题,性能上惨不忍睹。

算一笔账:假设链路带宽10Mbps,RTT是50ms,发一个1500字节的报文段。发送耗时约为(1500×8) / 10,000,000 = 1.2ms,一个完整收发周期需要1.2 + 50 = 51.2ms。信道利用率大约只有1.2 / 51.2 ≈ 2.3%,剩下97%以上的时间都在干等。

这么低的利用率当然不行,所以必须允许发送方连续发出多个段,让数据在链路上“排队飞”,也就是流水线传输。流水线带来了新问题:如果中间的某个段丢了,后续已经到达的段怎么处理?这就是连续ARQ协议里回退N步(GBN)和选择重传(SR)两个流派要回答的问题。TCP没有死板地套用某一个,而是把累积确认、超时重传、快速重传和SACK选择确认组装成了一套混合方案。

2. 可靠传输的四个关键组件:确认、超时、快速重传与发送窗口

2.1 累积确认:一个ACK能管一片数据

很多人看TCP交互过程会数“发了几个包、回了几个ACK”,其实TCP的确认粒度是字节,不是报文段。发送方收到ACK=2401时,它不需要关心2401之前到底收了多少个乱序段,只需要知道“对方要的下一个字节是2401”,之前全部视为有效。

累积确认的优势是协议状态简单、ACK开销小,而且能容忍一定程度的乱序。乱序的数据到了接收方,不会被丢弃,而是放进接收缓冲区,等缺失的段补齐后再把连续的字节流交给应用层。缺点是如果中间某个段真丢了,发送方往往要重传从丢的那个字节之后的所有数据,哪怕后面的段接收方都已经收到并存进缓冲区了。

我以前学习时一直没搞明白“既然接收方都收到了,为什么还要重传”这个问题。实际原因是TCP默认无法知道后面哪些段已经到达,除非开启SACK选项。SACK允许接收方在ACK里携带“我已经收到的非连续区域”,发送方看到了缺口在哪,就只需要补发缺口段,不用整段窗口全部重来。现代操作系统默认开启SACK,抓包时看TCP选项里的SACK选项就能确认。

2.2 超时重传与RTO:时间尺度上的博弈

发送方发完数据后,会挂一个重传计时器,如果到期还没收到对应确认,就重传。这个“到期时间”叫RTO,它的设定是整个可靠传输里最考手艺的部分。

RTO太小,包其实没丢,只是ACK在路上多绕了一会儿,就会触发多余的重传,白白占用带宽,甚至引发网络上更多拥塞。RTO太大,真丢包了要等很久才补发,对上层的表现为吞吐暴跌、卡顿明显。所以RTO必须动态跟随当前网络的往返时延RTT。

经典实现里会维护平滑RTT估计值SRTT和偏差值RTTVAR,大致按RTO = SRTT + 4×RTTVAR来算。这里有个经典陷阱叫“重传歧义”:发送方发了段A,超时后又重发了一次,然后收到了ACK。这个ACK到底是对第一次发送的确认,还是对第二次重传的确认?无法分辨。如果把这个样本算进RTT估计,可能会把RTT算得偏大或偏小,导致RTO失真。Karn算法给出的策略很粗暴:重传过的段的RTT样本不纳入统计,等一次没有重传的正常往返产生后再刷新。虽然简单,但确实是稳定性的基石。

实际上RTO还有个常识:计算出来的值如果太小会被下限钳住。RFC 6298建议不小于1秒,不过很多系统实现提供了可配置参数,真实抓包里几百毫秒的RTO也很常见。理解这一点,抓包时就不会觉得“这计时器是不是坏了”。

2.3 快速重传:三次重复ACK比计时器更快

超时重传的问题在于它永远在“亡羊补牢”,而且要等满一个RTO。对丢包敏感的应用来说,动辄几百毫秒甚至一秒的延迟很难受。于是TCP加了一个快速重传机制,让发送方不等计时器超时,而是根据连续收到的重复ACK来判断丢包。

正常通信时,如果只发生乱序,接收方每收到一个乱序段,会重复回复“下一个缺失字节”的确认,直到缺口补上。收到一次重复ACK很常见,可能是之前那个确认丢了,也可能是轻微的乱序;但要收到三次重复ACK,通常意味着后续至少三个段已经走完了一个往返,缺口依然存在。因此发送方看到连续三个对同一序号的重复ACK时,默认那个段已经丢了,立刻重传,并重置超时计时器。

为什么是三次而不是一次?如果一收到重复ACK就重传,网络里一些小的乱序会被误判为丢包,触发大量无效重传,严重时反而把链路搞得更差。三次是工程上的折中选择,不是数学上的最优解。这一点面试时经常被问,回答时要从“随机乱序与真丢的区分成本”出发。

2.4 发送窗口:可连续发送的最大序号区间

TCP发送方维护的发送窗口,是一个允许连续发送、但尚未确认的序号区间。假设窗口大小为2000字节,发送方已发出到序号3000,那么它还能继续发送序号3000到5000之间的数据。收到ACK后,窗口整体向前滑动,这就是“滑动窗口”名称的由来。

发送方内部其实把数据分成四类:已发送且已确认、已发送未确认、允许发送未发送、禁止发送。真正决定吞吐的是第二类和第三类之间那条“可发送边界”。窗口越大,越能充分利用链路带宽;窗口太小,发几个段就必须停下来等ACK。

窗口大小并不是发送方自己拍脑袋定的,而是由接收方的通告窗口rwnd和网络拥塞窗口cwnd共同约束。实际生效的发送窗口取两者中的较小值:

发送窗口 = min(接收方通告窗口rwnd,拥塞窗口cwnd)

流量控制管的正是rwnd这一项,而cwnd属于拥塞控制的内容,后面专门讲。这里先记住,接收方通过TCP首部里的16位“窗口”字段,持续告诉发送方“我这边还能接收多少字节”,这个字段在开启窗口缩放后还能以2的幂次扩大。

3. 流量控制:把发送速度交给接收方的缓存现状来定

3.1 rwnd的一切都围绕接收缓冲区

流量控制解决的是“发送方发太快,接收方应用读不过来”的问题。接收方TCP有一个接收缓冲区,数据到达后先放在里面,应用层进程慢慢地读。如果发送方不管不顾地狂发,缓冲区会被填满,新到的数据无处可放,只能丢弃。

避免这个场景最直接的办法,就是让接收方把剩余缓冲空间告诉发送方。TCP每个报文段都携带“窗口”字段,这个值就是rwnd。应用每从缓冲区读走一些数据,剩余空间增大,rwnd随之变大;应用不及时读,rwnd就逐渐缩水,甚至变成0。

举一个可视化例子:接收方缓冲区总大小65535字节,当前里面堆了30000字节还没被应用读走,那么rwnd约等于35535字节。发送方收到这个通告后,就会把未确认数据量控制在35535字节以内。这不是协议“建议”,而是强制约束——发送方不得违反,否则接收方会丢弃超量到达的数据。

3.2 窗口变成0以后的经典死锁

如果接收方根本来不及读数据,rwnd会降到0,发送方收到窗口为0的通告后必须停止发送。到这里看似安全,可问题出在“恢复通知”上。

假设接收方应用终于读走了一些数据,缓存空出2048字节,于是它发了一个ACK,里面带有“rwnd=2048”。这个ACK如果丢了,发送方毫不知情,还在傻等窗口更新;接收方以为自己已经通知到位,也在等发送方继续发数据。两边互等,链路进入死锁状态。TCP面对这个死局的招数叫持续计时器。

发送方一旦收到rwnd=0的通告,就启动持续计时器,到点之后主动发一个只有1字节数据的窗口探测段。这个探测段的目的不是传业务数据,而是逼接收方回复当前的窗口值。如果缓存还是满的,接收方回一个“窗口还是0”的确认,发送方重置计时器再等;如果缓存已经腾出空间,接收方就会在确认里给出非零rwnd,发送方立刻恢复发送。

这个机制看起来简单,实际工程里却很关键。抓包时看到发到一半突然停下来,隔几秒才有一个很小的探测包,通常就是从零窗口恢复的过程。

3.3 糊涂窗口综合症与小包风暴

假设接收方缓存只剩下1字节,它下一个通告说“我能收1字节”。发送方看到窗口非0,真的发一个1字节的报文段过去。这个段的数据只有1字节,但TCP首部20字节,IP首部20字节,到以太网还要加14字节帧头,链路开销高得离谱。这种双方“互相迁就”导致大量小报文段的现象,叫糊涂窗口综合症。

对症下药需要两头配合。接收方做到“延迟通告”:只有当剩余缓存足够大(比如达到一个MSS或者一半缓存)时,才更新非零窗口,避免把极小的可用空间立刻广播出去。发送方则可以用Nagle算法:数据凑不满一个MSS时,先把第一个小段发出去,后续小数据在本地攒着,等收到前一个小段的ACK后,再把攒下的数据拼成一整段发出去。

这两套机制单独看都很聪明,合在一起却常常闹矛盾。经典场景是远程登录式交互:发送方开启Nagle,想攒满再发,接收方开启延迟确认,想攒两个段一起回ACK,双方都在“攒”,结果一次按键要等200毫秒才能看到回显。所以很多实时交互程序会禁用Nagle(设置TCP_NODELAY),甚至要求接收方调整延迟ACK策略。如果你在写网络程序时发现小请求响应莫名变慢,先别急着怀疑网络,看看是不是这两个机制在打架。

4. 亲手验证:从抓包到问题速查

4.1 用Wireshark看懂窗口字段和重传标记

可靠传输和流量控制的许多结论,光看书容易一知半解,抓一次包就清清楚楚。最简单的方式是开两个终端,一个用tcpdump抓包,另一个发起大文件下载:

tcpdump -i eth0 -nn -s 0 -w tcp_window.cap tcp port 80

抓完用Wireshark打开文件,重点看TCP头部信息:

  • Window Size value:这是接收方通告的原始窗口值,注意它不一定等于真实窗口,还需要看窗口缩放因子。
  • Window scaling factor:如果三次握手时协商了Window Scale选项,真实窗口 = 原始窗口值 × 2^缩放因子。比如Window Size value是2048,scalefactor是6,那么真实通告窗口是2048 × 64 = 131072字节。
  • tcp.analysis.retransmission:Wireshark会根据序列号与ACK的推进关系,标记出疑似重传的包。看到这种标记时,可以点开看是超时重传还是快速重传触发。

我建议把“往返时延”和“窗口值缩小”这两个时间点对照起来看:当发送速度逼近接收方处理上限时,接收方通告窗口会像温度计一样连续下降;降到0之后,再恢复时通常能看到1字节探测段。

4.2 本地复现:让接收程序故意不读数据

如果手边没有现成的大流量服务器,自己写两个极简脚本就够了。我在本地实践时用的是Python的socket,接收端收到数据后故意sleep,让操作系统接收缓冲区被填满:

import socket, time sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(('0.0.0.0', 9000)) sock.listen(1) conn, addr = sock.accept() while True: data = conn.recv(65536) if not data: break time.sleep(1) # 模拟应用层读取极慢

发送端用一个循环不停发数据。运行后观察抓包:一开始发送方窗口正大光明地推进,很快接收方的首部窗口字段会一路降到0,随后发送方不再发业务数据,只剩周期性的零窗口探测包。这个过程就是把前面第3.2小节的死锁与恢复机制,用最小代价完整复现了一遍。

这类实验不需要多么专业的网络环境,我做过多次,只要注意绑定的端口不被占用、数据量足够大,基本都能在十几秒内看到理想效果。

4.3 常见问题速查表

学习可靠传输与流量控制时,有几个问题反复被问到,我整理成了一张速查表,建议复习或面试前过一遍。

问题核心答案
为什么只靠超时重传会让吞吐变差超时要等一个RTO,这段时间发送方一无所获,高RTT场景下浪费尤其明显,所以才有快速重传补位
快速重传为什么要三次重复ACK一次重复ACK很可能是乱序或ACK丢失,三次以上才大概率代表真丢包,需要平衡误判成本和补发延迟
累积确认为什么会造成多余重传缺失段补齐前,接收方无法确认后续乱序段,发送方可能重传已经收到的数据,SACK可以缓解
rwnd变成0后发送方会永久停止吗不会,持续计时器会触发1字节窗口探测,索要最新的窗口值,从而恢复发送
Nagle与延迟ACK相互卡顿怎么办交互式应用可设置TCP_NODELAY关闭Nagle,并视情况调整接收端延迟ACK策略
流量控制和拥塞控制的区别在哪流量控制防止接收方缓存溢出,拥塞控制防止网络中间节点过载,前者由rwnd限制,后者由cwnd限制

这个表看起来简单,每一行背后都有可展开的细节。如果哪一行看完了仍然觉得“哦是这么回事但不知道为什么”,建议回去翻对应的章节,把它拆成例子推演一遍,比硬背强得多。

我这里还有个很现实的提醒:学习可靠传输时,我踩过一次最大的坑就是把“序号”当成“报文段编号”来理解,导致看抓包时怎么都对不上逻辑。直到自己画了一张字节流坐标图,把数据块标注成连续序号,确认号永远指向“下一个期望字节”,思路才彻底理顺。学协议最好的方式就是拿真实抓包数据对着理论一点点验证。你在自己的抓包里看到连续三次重复ACK触发快速重传时,一定会比任何教材都记得牢。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 7:03:49

CTF寒假入门路线:从环境搭建到新手赛完整指南

很多新手在寒假前都会问同样一个问题:CTF到底该怎么学?市面上零零散散的教程不少,但真正能让人从零开始、按节奏走完一个假期的路线图却很少。我自己的看法是,寒假这种整块时间,是入门CTF最好的窗口期,没有…

作者头像 李华
网站建设 2026/10/16 7:03:42

Spring Boot到微服务架构:大厂Java面试高频原理与工程实践拆解

最近几个月,好几个同事和朋友都在准备互联网大厂的Java岗跳槽,来来回回问我同一类问题:Spring Boot到底要准备到什么深度,微服务架构又从哪入手。问的人多了,我发现大多数人不是不会写代码,而是摸不清面试官…

作者头像 李华
网站建设 2026/10/16 7:03:27

剧本杀后台管理系统:从选题到落地的毕业设计完整指南

1. 为什么选剧本杀后台管理系统作为毕设选题1.1 剧本杀行业的真实业务痛点前阵子帮一位正在找毕业设计题目的A同学做选题评估,他前后换了四个题目:先是做图书管理系统,被导师说太老套;又试了在线考试系统,发现网上现成…

作者头像 李华
网站建设 2026/10/16 7:03:07

删除有序数组的重复项:双指针原地去重算法详解

前阵子帮人做模拟面试,十场里至少有三场会遇到这道题:给定一个升序排列的数组,要求原地删除重复元素,让每个元素只出现一次,最后返回去重后数组的长度。很多人嘴上说简单,白板上一写就露馅——要么忘了“原…

作者头像 李华
网站建设 2026/10/16 7:01:39

hCaptcha验证码过不去?从环境排查到风控原理的实战指南

你有没有遇到过这种情况:打开一个正常网站,还没看到正文,先撞上一道 hCaptcha 验证。点一下复选框,转圈,转圈,最后提示连接被阻止;或者刷出一张九宫格,让你挑出所有红绿灯&#xff0…

作者头像 李华
网站建设 2026/10/16 7:01:38

基于MPU9250的四元数姿态解算:磁力计标定与梯度下降融合

简介:面向STM32嵌入式开发者与姿态测量应用,这份资源基于正点原子阿波罗F429开发板读取MPU9250数据,完成基于最小二乘的椭球矫正后,使用Madgwick四元数算法融合加速度计、陀螺仪和磁力计数据,实时输出航向角&#xff0…

作者头像 李华