资讯动态

TCP可靠传输:从重传机制到流量控制与拥塞控制的协同

发布时间:2026/10/18 3:25:54 来源:尧图企业网站定制
1. 先把“可靠传输”和“两个控制”的关系说清楚聊到“TCP如何保证可靠性传输”很多人的第一反应是“不就是丢了重传吗”。这个答案不能算错但离生产环境里的真实情况差得很远。我做网络相关的工作这些年最大的体会是TCP的可靠性不是靠某一个单独机制撑起来的而是靠一套互相牵制的“窗口协议”和“确认协议”共同作用的结果。其中流量控制和拥塞控制恰好是把可靠性和传输效率绑在一起的两根缰绳。1.1 “可靠”不是“绝对不丢”而是“有序不重不漏”TCP对外承诺的可靠性用一句话概括向应用层提供一个可靠的字节流管道。这句话包含三层意思第一字节不会丢丢了会补第二字节不会乱先发的先到第三字节不会重复。注意这和很多人理解的“一条连接从头到尾每个包都百分百按时到达”并不一样。IP层本身是尽力而为的丢包、乱序、重复都有可能发生TCP要做的是把这些“不可靠”抹平再交给上层一个看起来像本地文件流一样干净的数据流。这就意味着可靠性至少需要解决四个子问题怎么知道少了数据怎么知道什么时候补怎么避免重复补怎么防止补的时候把网络打得更惨。前三个问题靠序号、确认、重传、去重来解决第四个问题靠流量控制和拥塞控制来解决。这篇文章会把它们拆开讲重点放在后两个“控制”上。1.2 两个“控制”管的是完全不同的两类瓶颈流量控制和拥塞控制经常被放在一起提但它们的出发点完全不同。我用一个日常场景来解释流量控制管的是收件人的信箱大小。发件人不能不管不顾地把一卡车信塞进一个小信箱信箱满了就得停一下等收件人把信取走腾出空间再继续发。这里制约你的是“接收方处理能力”。拥塞控制管的是邮局之间的公路状况。公路可能因为太多人同时寄信而堵死此时即使收件人的信箱还很空发件人也不应该继续猛发否则只会增加排队、延迟和丢包。这里制约你的是“中间网络路径的处理能力”。所以你会看到TCP连接里实际上有两个独立的窗口变量一个是接收窗口rwnd由对端通过TCP头里的窗口字段通告过来另一个是拥塞窗口cwnd只存在于发送端本地代表发送端自己对网络状态的估计。真正能发出去的数据量是这两者的较小值。只看其一都会栽跟头。2. 序号、确认、重传、去重可靠性不是一句“重传”就能概括的很多人抓包时会犯一个毛病看到“TCP Retransmission”就认定网络丢包严重。实际上TCP的重传机制只是可靠性链条的最后一环前面必须有“序号”和“确认”这两块基石。2.1 序号和确认号到底是怎么数的TCP的序号不是按“第几个包”计的而是按字节流位置计的。假设发送方一次性往连接里写了1000字节起始序号是100那么这1000字节会占用序号100到1099接收方确认时返回的ACK号是1100意思是“我期待下一个字节是序号1100”。这样设计的好处是即使一个包在IP层被拆成不同大小的分片接收方也能精确知道字节流里的哪个位置发生了缺失。这里有个容易被忽略的细节SYN和FIN标志位也会各占一个序号所以你会看到三次握手的第二次握手对端返回的ACK号往往是“发起方ISN 1”而不是“ISN 0”。抓包时看到这种1别以为算错了这是RFC约定好的。另外TCP序号是32位的虽然会回绕但正常传输场景下现代TCP通过时间戳PAWS机制可以区分“新包”和“旧包重放”所以不用担心序号撞车导致数据错乱。2.2 超时重传和快速重传一个包丢了发送方凭什么知道发送方知道数据丢失靠两种信号第一种是超时。发送方给每个已发送但未确认的段启动一个重传定时器超时时间RTO不是拍脑袋定的而是根据历史RTT样本动态计算的。经典算法是先算出一个平滑RTT再加上4倍RTT偏差作为余量。问题是网络抖动越大RTO就越保守一旦真超时往往说明链路已经出了大问题此时TCP会直接判断网络拥塞并把拥塞窗口一把拉回初始状态。第二种更常用是快速重传。接收方一旦发现某个字节没到而后续字节到了不会干等而是连续回发ACK号指向“缺失位置”的重复ACK。发送方收到3个重复ACK时基本可以断定那个段丢了——注意是“大概率”不排除只是乱序。快速重传的高明之处在于它不需要等待RTO超时往往几十毫秒内就能触发重传同时还能保留一个相对温和的拥塞控制响应。为什么是3个重复ACK而不是1个、2个这是工程经验。因为单个重复ACK很可能是由轻微乱序造成的链路本身并没有丢包立刻重传反而制造额外流量。等到3个重复ACK出现基本说明后面好几个包都已经到达且与缺失段无关乱序的可能性大大降低。2.3 校验和与去重兜底但不一定能救命TCP头部有一个16位的校验和覆盖TCP头、数据和伪头部。它用来做什么检测传输过程中是否出现位翻转。但是必须诚实地说校验和很弱16位意味着每65536次传输可能有一次校验错误检测不出来而且它只能发现“有错”不能纠正“哪里错”。实际网络中真正靠它拦住的数据错误远比想象中少大部分异常最终会以“序号空洞”的形式暴露出来。去重就更有意思了。接收端收到重复段时会根据序号的连续性直接丢弃重复部分。去重和重传是一对孪生兄弟发送方可能在超时后重传了一个其实已经到达的段接收方必须有能力识别“这包的数据我已经有过了”从而只确认、不重复提交。这也是为什么TCP从设计上把“字节流”作为核心模型——应用层根本不需要关心底层到底传了几遍只需要关心最终拿到手里的字节是不是完整连续。3. 流量控制接收方的缓冲区告急如何反向压制发送方流量控制在所有TCP教科书里都是“接收窗口”那一章的重点但真正在生产环境踩过坑的人会发现它远不止“字段公式”这么简单。3.1 接收窗口的本质接收方缓冲区里还剩多少地方TCP头里有一个16位的窗口字段后来通过窗口缩放选项扩展到了最大约1GB级别。这个字段的核心含义是接收方目前可用的接收缓冲区大小。发送方收到ACK时会同步读取ACK包里的窗口字段然后更新自己对接收窗口的认知。实际发送窗口的计算就是实际可发送字节数 min(拥塞窗口, 接收窗口) - 已发送未确认字节数这里必须强调一个常见误区很多人以为接收窗口只会影响“一次能发多少个包”其实它影响的是“发送方最多能在未被确认的情况下发出多少字节”。如果接收方应用层读得很慢缓冲区逐渐被占满通告窗口就会不断缩小发送方发着发着发现窗口归零只能停下来等待新的窗口更新。3.2 零窗口、持续定时器和“窗口黏住”接收窗口归零后TCP不会永远死等。发送方会启动一个持续定时器每隔一段时间发送一个窗口探测包询问接收方缓冲区是否已经腾出空间。这个机制存在的意义在于防止窗口更新ACK包本身在网络上丢失导致双方都站在原地等对方先动。但窗口为零太久还会带来一个应用层很容易感知的现象——“传输完全卡住连接却还活着”。如果你在服务器上看到连接状态是ESTABLISHED但Send-Q一直居高不下同时对端的Recv-Q也在顶着上限十有八九就是接收窗口被压到了极低值。此时调大SO_RCVBUF可能马上有效但更要紧的是去查接收端应用为什么读得这么慢是不是陷入了阻塞。顺着这个思路会发现流量控制里藏着不少“反直觉”的设计。比如Nagle算法和延迟ACK的组合Nagle算法为了减少小包要求发送端攒足一个MSS或等所有小段都被确认后才发下一个延迟ACK则让接收端尽量攒出多个数据段后统一确认一次。这两个机制单独看都合理放在一起就可能产生“发送方等确认、接收方等数据”的互相等待延迟瞬间升高。所以你会看到许多RPC框架和游戏服务器会显式设置TCP_NODELAY目的就是绕过Nagle宁可多花一点带宽来换低延迟。3.3 一个真实排查为什么两个机房之间带宽够高吞吐却上不去有一类问题特别典型跨机房同步数据带宽充足延迟也不高但吞吐量始终上不去。抓包发现接收窗口通告值稳定在几十KB发送方明明有大量数据排队却因为rwnd受限被卡得死死的。查看接收端代码才知道应用启动时没有调整TCP接收缓冲区默认值而内核默认的接收缓冲区又比较保守同时启用窗口缩放后问题被放大。把接收端的SO_RCVBUF调大并确保应用高频read及时消费数据吞吐立刻翻了几倍。这类问题的核心教训是流量控制的瓶颈往往在应用层读取速度而不是内核参数本身。改参数只是给应用一个“喘气空间”如果应用本身消费数据的速度跟不上任何缓冲区调优都只是延缓问题爆发。4. 拥塞控制网络路径上的“公共拥堵费”怎样动态调节如果流量控制管的是“双方之间的水位差”拥塞控制管的就是“整条马路的拥挤度”。这里有个很反直觉的设定TCP发送方无法直接知道中间路由器有多忙只能通过丢包和延迟这些间接信号去猜。正因为是猜所以拥塞控制算法一直在演进从早期的Tahoe到Reno、NewReno、CUBIC再到Google提出的BBR本质上都是在猜得更准。4.1 慢启动为什么越快的网络越要从“慢”开始新连接建立后拥塞窗口不是从最大值开始的而是从很小很小的值通常是1个MSS甚至按RFC 6928的约定可能从10个MSS开始起步。每收到一个ACKcwnd翻倍。这个阶段增长是指数级的1变22变44变8。为什么不直接给满因为发送方不知道这条链路现在还有多少余量万一其他连接把带宽占满了一上来就猛发等于自爆。慢启动的作用是让发送方在短时间内快速探测到链路的可用容量接近上限后再转入线性增长。这里有一个经常被误解的点慢启动的“慢”是相对于“一次给满”而言的并不是真的慢。在延迟极低的内网环境里即使从10个MSS起步十几个RTT内就能把cwnd涨到几千个MSS对于几十毫秒级的延迟来说每秒可以探测到很大的带宽空间。真正影响慢启动表现的是RTT链路越长一个往返周期内能翻倍的机会越少启动阶段就越拖沓这也是为什么跨洲链路的首个几十毫秒吞吐总是上不来的原因之一。4.2 拥塞避免从“指数冒险”切换到“线性试探”当cwnd超过慢启动阈值ssthresh之后TCP就进入拥塞避免阶段。此时每次RTT最多把cwnd增加1个MSS增长方式是线性的。这样设计的目的很直接过了安全阈值每次多发的数据量必须是试探性的不能按倍数赌命。线性增长让TCP逐步挤压链路余量直到某个数据包丢失。问题在于什么样的信号才说明“试探过头了”经典拥塞控制主要看两个信号超时和3个重复ACK。发生超时说明网络已经把数据包或者ACK包憋了太久大概率是严重拥塞此时TCP把cwnd直接重置回初始值ssthresh降到当前cwnd的一半重新慢启动。发生3个重复ACK说明接收端还在正常回包只是某个段丢了拥塞程度没有超时那么严重于是进入快速恢复cwnd减半而不是归零然后再线性爬升。4.3 从Tahoe到CUBIC快速恢复为什么这么重要我把这套演进路线简化来看Tahoe是最早的实现之一检测到丢包后一律回到慢启动安全性有了但吞吐急剧掉血。Reno引入了快速恢复收到3个重复ACK后把cwnd砍半然后直接进拥塞避免而不是重头再来。这一刀砍下去吞吐虽然也有波动但比Tahoe平滑很多。NewReno修复了Reno在“同一窗口内丢多个包”时恢复慢的问题一次只重传一个丢包并保存快速恢复状态等所有丢失都恢复后再退出。CUBIC是当前Linux默认算法的代表它改良了标准线性增长根据距离上次丢包的时间用三次函数加大cwnd的增长幅度让高带宽长链路能更快地找回吞吐。CUBIC名字来自三次函数核心想法是在窗口接近上一轮丢包时的最大值时放慢增长一旦测到链路有空余就大胆冲刺这样可以兼顾高带宽利用率和稳定收敛。4.4 丢包不一定是拥塞BBR给传统算法补了一课经典拥塞控制把“丢包”当作拥塞的唯一可靠信号这个假设在现实网络中越来越站不住脚。很多丢包根本不是因为带宽耗尽而是因为路由器缓存太小被临时丢弃或是链路本身存在随机误码。如果TCP一遇到丢包就减半窗口而丢包原因和拥塞无关吞吐就会无谓地跳水。BBR的思路完全不同它不再盯着丢包而是尝试直接测量瓶颈链路的带宽和最小RTT用这两项数据计算发送速率。它要的不是“丢包后才收敛”而是“提前估计出链路能跑多快就按那个速率去发”。这种思路在丢包率较高的劣质链路上优势明显因此在移动网络和跨境链路场景里很受欢迎。但BBR也不是万能药。它追求高带宽利用率的代价是更激进地占用路由器队列空间可能让同链路上的其他TCP连接“吃不到”带宽。所以现在很多生产环境在做拥塞控制选型时会区分场景内网高带宽低延迟CUBIC通常够用跨区域长距离有损链路BBR带来的收益往往更直观。5. 排查传输慢时最容易误判的五个信号聊完理论我总结一下实际运维中经常会看走眼的几个点。这五个误区每一个我都踩过代价不小。5.1 把TCP的“可靠”理解成“有消息边界”TCP是字节流不是消息流。应用层调用一次send写入100字节接收方可能会在两次read中分别读到40字节和60字节也可能在一次read里读到120字节——因为两个200字节的包已经被协议栈拼到一块了。很多人第一次写socket程序时都栽在这里。正确处理方式是在应用层定义消息边界比如每段数据前加一个固定长度的长度头或者用特殊分隔符。如果把“一次send对应一次recv”当成理所当然所谓可靠性反而会变成“数据错位”的根源。5.2 看到重复ACK就断定是丢包重复ACK确实常常意味着丢包但乱序同样会触发重复ACK。比如路由器做了负载均衡两条路径延迟不同先发的包反而后到接收端就会连续回发指向旧序号的ACK。遇到这种情况直接降窗口只会让吞吐受损。正确做法是结合抓包里的“乱序”统计、TSval时间戳以及重传数据是否真正出现来综合判断。5.3 把RTO超时等同于“网络断了”RTO是动态计算的网络抖动大时RTO会自动拉长。有时候你看到RTO超时后重传成功这不代表链路有硬故障可能只是路由器队列瞬间排队太久。反过来如果频繁出现超时重传则说明网络确实出现了明显的拥塞或丢包此时不只是重传链路误码或网卡驱动问题也需要纳入怀疑范围。我排查耗时最久的一个问题最后竟是网卡RX环形队列丢包导致的TCP层看到的症状和普通网络拥塞几乎一模一样。5.4 只调发送端忽略接收窗口大小传输慢时很多人直觉上会去调发送端的SO_SNDBUF、tcp_wmem结果发现收效甚微。原因可能出在接收端接收窗口通告得太小或者接收端应用读取不积极发送端再宽也都卡在“min(cwnd, rwnd)”这个约束上。调整方向应该是先看对端的Recv-Q和窗口通告值再决定是优化应用读取逻辑还是扩大接收缓冲区。这个先后顺序不要颠倒。5.5 用tc模拟丢包时只丢不发看不到双向影响在本地用netem模拟丢包测试应用时我见过不少人只模拟单向丢包直接把一台机器的网卡上配上loss 5%。但TCP的ACK是反向传输的如果模拟环境里没有正确配置纯粹的单向链路可能导致反向路径也一起丢包从而误判重传机制。更稳妥的做法是借助网络命名空间或物理链路隔离确保丢包方向可控这样得出的结论才可信。6. 抓包小实验亲眼看看窗口是怎么动起来的理论说再多不如自己动手看一次。我建议你在一个受控的测试环境比如一台虚拟机里做下面这个小实验不需要特殊硬件只用Linux自带工具。6.1 准备一对简单的收发程序可以用Python写一个最简单的TCP服务端和客户端服务端接收数据后不调用read先让接收缓冲区堆积客户端持续发送大块数据。# 服务端先只 accept然后 sleep 不读数据 import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((0.0.0.0, 9000)) s.listen(5) conn, addr s.accept() time.sleep(30) # 模拟接收端应用读取极慢 data conn.recv(4096) print(len(data))# 客户端持续发送大块数据 import socket s socket.create_connection((127.0.0.1, 9000)) payload bA * 1024 * 1024 * 100 s.sendall(payload) s.close()这段代码故意让服务端睡眠30秒观察期间你会发现客户端很快就发不出数据了因为服务端的接收缓冲区被填满通告窗口变为0。这个实验能直观看到流量控制在“垃圾桶”场景下的真实表现。6.2 用ss和tcpdump观察窗口值变化在一个终端里执行ss -tin | grep -A1 127.0.0.1:9000-t显示TCP连接-i显示内部信息包括cwnd、rwnd、ssthresh、RTT。你会在输出里看到一行很像这样的数据cwnd:10 ssthresh: 0 rtt: 0.042ms接着用tcpdump抓包tcpdump -i lo -nn -XX tcp port 9000观察TCP头的Window字段变化。当服务端缓冲区告急时Window字段会逐渐变小最终变为0。这比读教科书里“接收窗口动态变化”的抽象描述直观得多。6.3 再改装一下制造丢包观察快速重传和拥塞窗口在Linux测试机上可以用netem给环路接口模拟丢包tc qdisc add dev lo root netem loss 10%重新跑客户端发送再抓包你会看到接收方连发重复ACK随后发送方触发快速重传。此时用ss观察拥塞窗口会发现cwnd从高位降到一半然后再慢慢爬升。注意tc规则用完后记得删除tc qdisc del dev lo root netem如果在生产机器上忘了删后面的问题排查可能白做半天这算是我第一次玩netem时的真实教训。6.4 如果能在Wireshark里看拥塞控制全貌千万别省这一步命令行抓包能看到字段但要把慢启动、拥塞避免、快速恢复这几个阶段的图形关系看明白还是Wireshark更方便。把抓包文件导入Wireshark打开“Statistics - TCP Stream Graph - Window Scaling”或者“Sequence Number (Stevens)”时序图能看到发送序号随时间的变化曲线。慢启动阶段曲线陡峭向上拥塞避免阶段平缓上升快速恢复时出现一个短暂的平台和回撤一眼就能识别出来。我个人的习惯是先把理论流程背下来再对着抓包图看当图上每个“拐点”都能对应到算法里的某个条件时TCP可靠性这层窗户纸才算真正捅破了。单纯背住“cwnd减半”“ssthresh重设”这些口诀遇到实际场景照样会晕。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑