ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

运输层核心机制详解:从UDP到TCP的可靠传输与拥塞控制

运输层核心机制详解:从UDP到TCP的可靠传输与拥塞控制

运输层这块内容,我前前后后翻过不下五遍不同版本的教材,每次重读都有新的体会。原因很简单——它是整个网络体系里最“接地气”的一层。你写代码时调用的 socket 接口、排查问题时抓的包、面试时被反复追问的 TCP 三次握手,全都落在这层。但很多朋友学到这里会卡住:概念一堆,什么端口、复用、可靠传输、流量控制、拥塞控制,单独看都懂,串起来就乱。这篇我就按自己理解的路子,把运输层从头到尾捋一遍,重点讲清楚每个机制“为什么这么设计”,而不是只告诉你“是什么”。不管你是正在啃计算机网络的学生,还是工作后想补基础的开发者,应该都能从中拿到点实在的东西。

1. 运输层到底在解决什么问题

1.1 从“两台主机通信”到“两个进程通信”的跨越

网络层把数据从一台主机送到另一台主机,靠的是 IP 地址。但一台主机上同时跑着浏览器、聊天软件、音乐播放器,数据到了主机之后,到底该交给哪个程序?网络层管不了这件事,它只负责把包裹送到楼下,至于送到几零几房间,是运输层的事。

这就是运输层最核心的价值:提供进程到进程的逻辑通信。物理上,数据经过的是一条条链路和一堆路由器;逻辑上,两个进程就像直接连了一根管子,你在这头写,我在那头读。运输层用端口号来标识进程,用复用和分用来让多个进程共享同一条网络层通道。

我习惯用一个类比:网络层是快递公司的干线运输,把货从A城送到B城;运输层是B城里的快递员,负责把货分到具体的收件人手上。IP 地址是城市地址,端口号是门牌号。少了门牌号,货到了城市也没法投递。

1.2 复用与分用:一个容易被忽略但极其关键的概念

复用发生在发送方:多个应用进程的数据,通过各自的端口交给运输层,运输层再统一封装后交给网络层。分用发生在接收方:网络层把数据交上来,运输层根据端口号把数据分给正确的进程。

这两个词听着抽象,但你想一下:你开着微信、浏览器、下载工具,它们同时在收发数据,网卡只有一块,IP 只有一个,凭什么互不干扰?靠的就是端口号区分。每个进程绑定一个端口,运输层在封装时把源端口和目的端口写进首部,接收方一看目的端口就知道给谁。

提示:端口号是 16 位,范围 0 到 65535。其中 0 到 1023 是熟知端口,分配给 HTTP、FTP 这类标准服务;1024 到 49151 是登记端口;49152 以上是临时端口,客户端发起连接时由系统动态分配。你自己写服务端程序时,别去抢熟知端口,容易和系统服务打架。

1.3 运输层和网络层的分工边界

很多人分不清这两层的职责。我总结成一句话:网络层负责“主机到主机”,运输层负责“进程到进程”。网络层提供的服务是不可靠的、尽最大努力交付的;运输层则可以在不可靠的网络层之上,构建出可靠的服务。

这里有个关键点:网络层不保证数据不丢、不乱序、不重复,这些活儿全甩给了运输层。TCP 之所以复杂,就是因为它要在一条本身就不可靠的通道上,硬生生造出“可靠”的假象。而 UDP 干脆躺平,网络层什么样它就什么样,只加了一层端口复用,别的啥也不管。

理解了这个分工,你就能明白为什么 TCP 有那么多机制,而 UDP 简单到首部只有 8 个字节。复杂度不是凭空来的,是为了弥补下层的不足。

2. UDP:简单到极致的“裸奔”协议

2.1 UDP 首部结构拆解

UDP 的首部只有 8 个字节,四个字段,每个 2 字节:源端口、目的端口、长度、校验和。就这么点东西,没有序号,没有确认号,没有窗口,没有任何控制字段。

字段长度作用
源端口2 字节发送方进程端口,不需要回复时可置 0
目的端口2 字节接收方进程端口,分用的关键
长度2 字节UDP 首部加数据的总长度,最小值是 8
校验和2 字节检测传输过程中是否出错,出错则丢弃

校验和这块有个细节值得说:UDP 计算校验和时,会加一个 12 字节的伪首部,里面包含源 IP、目的 IP、协议号、UDP 长度。伪首部不参与实际传输,只是为了把 IP 层的信息拉进来一起校验,防止数据被错误地送到别的主机。这个设计挺巧妙,但也增加了计算量。

2.2 UDP 适合什么场景

UDP 不保证可靠,那它凭什么还存在?因为快。没有握手,没有确认,没有重传,发出去就不管了。这种特性在下面这些场景里反而是优势:

  • 实时音视频:丢一两帧画面无所谓,但如果为了重传卡住,体验更差。直播、语音通话大多跑在 UDP 上。
  • DNS 查询:一次请求一个响应,数据量小,用 UDP 省去建立连接的开销,快得多。
  • 游戏对战:位置同步要求低延迟,偶尔丢包可以靠客户端预测弥补,重传反而添乱。
  • 广播和多播:UDP 天然支持一对多,TCP 只能一对一。

我做过一个实时数据采集的小工具,一开始用 TCP,结果网络稍微抖动就卡顿,因为 TCP 的重传和拥塞控制会把延迟拉高。换成 UDP 之后,丢几个包无所谓,整体流畅度反而上去了。这就是选对协议的威力。

2.3 UDP 的“不可靠”到底意味着什么

不可靠不是说出错概率高,而是说协议本身不提供任何保证。数据可能丢、可能乱序、可能重复,UDP 一概不管。应用层如果在意这些,得自己实现。

比如你想用 UDP 传文件,就得自己在应用层加序号、加确认、加重传逻辑。这活儿干起来不比直接用 TCP 轻松,所以除非有特殊需求,传文件还是老老实实用 TCP。

注意:UDP 没有拥塞控制,意味着它可以无节制地往网络里灌数据。如果大量应用都用 UDP 猛发,网络容易拥塞崩溃。这也是为什么有些网络会限制 UDP 流量。用 UDP 时,应用层最好自己做点速率控制,别把网络当自家水管。

3. TCP:在不可靠之上造出可靠

3.1 TCP 首部逐字段精讲

TCP 首部最少 20 字节,比 UDP 复杂得多。我把它拆成几块来看:

  • 源端口、目的端口:各 2 字节,和 UDP 一样,用于分用。
  • 序号:4 字节,标识本报文段数据第一个字节的编号。TCP 是面向字节流的,每个字节都有编号。
  • 确认号:4 字节,期望收到对方下一个报文段的第一个字节的序号。
  • 数据偏移:4 位,指出首部长度,因为首部有可选项,长度不固定。
  • 保留:6 位,暂时没用,留着以后扩展。
  • 六个标志位:URG、ACK、PSH、RST、SYN、FIN,每个都有特定用途。
  • 窗口:2 字节,告诉对方自己还能接收多少字节,用于流量控制。
  • 校验和:2 字节,同样带伪首部。
  • 紧急指针:2 字节,配合 URG 使用。
  • 选项:可变长度,最常用的是 MSS(最大报文段长度)。

序号和确认号是 TCP 可靠传输的基石。序号让接收方知道数据的位置,确认号让发送方知道哪些数据已经被收到。这两个字段配合重传机制,构成了可靠传输的核心。

3.2 三次握手:为什么是三次而不是两次

三次握手的流程大家都背过:客户端发 SYN,服务端回 SYN+ACK,客户端再发 ACK。但为什么非得三次?两次行不行?

假设只有两次:客户端发 SYN,服务端回 SYN+ACK,连接建立。问题在于,服务端无法确认客户端收到了自己的 SYN+ACK。如果这个响应在路上丢了,客户端以为没建立成功,服务端却以为建立了,就会一直等着客户端发数据,白白浪费资源。

三次握手的本质是双方都要确认对方的收发能力都正常。第一次让服务端知道客户端能发;第二次让客户端知道服务端能收也能发;第三次让服务端知道客户端能收。三次之后,双方都确认了彼此的收发能力,连接才算真正建立。

还有一个经典问题:已失效的连接请求。客户端发了一个 SYN,网络延迟很久才到服务端,此时客户端早就不等了。服务端收到后回 SYN+ACK,如果没有第三次握手,服务端就单方面建立了连接,一直等数据,浪费资源。有了第三次,客户端不会回 ACK,服务端就知道这个连接是无效的,不会建立。

3.3 四次挥手与 TIME_WAIT 状态

关闭连接需要四次挥手,因为 TCP 是全双工的,每个方向要单独关闭。客户端发 FIN,服务端回 ACK,此时客户端到服务端的方向关了,但服务端到客户端的方向还开着。等服务端也发 FIN,客户端回 ACK,连接才完全关闭。

这里最让人头疼的是TIME_WAIT 状态。主动关闭的一方在发完最后一个 ACK 后,要等 2MSL(报文最大生存时间的两倍)才能彻底关闭。为什么要等?

一是确保最后的 ACK 能到达对方。如果这个 ACK 丢了,对方会重发 FIN,此时如果客户端已经关闭,就没法回应了。等 2MSL 就是为了能重发 ACK。

二是让本次连接的所有报文在网络中消失。如果立即关闭,旧连接的延迟报文可能被新连接误收,造成数据混乱。等 2MSL,足够让所有旧报文消亡。

实操心得:服务器上大量 TIME_WAIT 是常见现象,尤其在高并发短连接场景。可以通过调整内核参数来缓解,比如开启 TIME_WAIT 重用。但别盲目调,理解清楚原理再动手,否则可能引入更难排查的问题。

4. 可靠传输的三大机制

4.1 停止等待协议与连续 ARQ 协议

最朴素的可靠传输是停止等待:发一个报文段,等确认,收到确认再发下一个。简单是简单,但效率极低。假设往返时间 RTT 是 20ms,那每秒最多发 50 个报文段,带宽利用率惨不忍睹。

改进方案是连续 ARQ:不等确认,连续发多个报文段,收到确认后滑动窗口往前移。这样信道利用率大幅提升。TCP 实际用的就是滑动窗口机制,配合累积确认。

累积确认的意思是:确认号 N 表示 N 之前的所有字节都收到了。比如收到确认号 1001,说明 1 到 1000 字节都到了。这样即使中间某个确认丢了,后面的确认也能覆盖它,减少重传。

4.2 超时重传与自适应 RTT 计算

发送方发了一个报文段,多久没收到确认就重传?这个时间叫超时重传时间 RTO。设太短,会频繁重传,浪费带宽;设太长,丢包后恢复慢,延迟高。

TCP 的做法是动态计算。它先测量每个报文段的往返时间 RTT,然后算一个加权平均的平滑往返时间 RTTs,再算一个RTT 偏差 RTTd,最后 RTO = RTTs + 4 × RTTd。这样 RTO 能自适应网络状况的变化。

具体公式是这样的:第一次测量时,RTTs 取测量值,RTTd 取测量值的一半。之后每收到一个新确认,就更新:

  • RTTs = (1 - α) × 旧RTTs + α × 新RTT测量值,α 通常取 0.125
  • RTTd = (1 - β) × 旧RTTd + β × |RTT测量值 - RTTs|,β 通常取 0.25
  • RTO = RTTs + 4 × RTTd

这套算法让 TCP 能根据网络延迟的变化自动调整重传时机,比固定超时时间聪明得多。

4.3 快速重传:不等超时就重传

超时重传有个问题:要等到 RTO 到了才重传,太慢。快速重传解决了这个问题:如果发送方连续收到三个重复的确认,就认为后面的报文段丢了,立即重传,不用等超时。

举个例子:发送方发了 1、2、3、4、5 五个报文段,2 丢了。接收方收到 1,回确认 2;收到 3,还是回确认 2;收到 4,继续回确认 2;收到 5,依然回确认 2。发送方收到三个重复的确认 2,就知道 2 丢了,马上重传 2,不用等 RTO 超时。

这个机制把丢包恢复时间从“一个 RTO”缩短到“三个重复确认到达的时间”,在高延迟网络里效果非常明显。

5. 流量控制与拥塞控制

5.1 滑动窗口与零窗口探测

流量控制是为了防止发送方发得太快,把接收方的缓冲区撑爆。TCP 用滑动窗口实现:接收方在确认报文里带上自己的接收窗口大小,发送方根据这个值调整发送速率。

如果接收方缓冲区满了,会通告一个零窗口,发送方就停止发送。但这里有个死锁风险:如果接收方后来腾出空间,发了一个窗口更新的报文,但这个报文丢了,发送方还在等,接收方也在等,双方僵住。

解决办法是零窗口探测:发送方收到零窗口后,启动一个定时器,每隔一段时间发一个探测报文,接收方收到后回复当前窗口大小。这样即使窗口更新报文丢了,也能通过探测恢复。

5.2 慢开始、拥塞避免、快重传、快恢复

拥塞控制和流量控制不是一回事。流量控制看的是接收方能力,拥塞控制看的是整个网络的状况。TCP 的拥塞控制有四个核心算法:

  • 慢开始:连接刚建立时,拥塞窗口从 1 开始,每收到一个确认就翻倍,指数增长。虽然叫“慢开始”,但增长其实很快。
  • 拥塞避免:当拥塞窗口达到慢开始门限后,改为线性增长,每个 RTT 加 1,避免增长过快导致拥塞。
  • 快重传:收到三个重复确认就立即重传,不等超时。
  • 快恢复:快重传后,把慢开始门限和拥塞窗口都减半,然后执行拥塞避免,而不是回到慢开始。

这四个算法配合起来,让 TCP 能在网络状况好时快速提速,在出现拥塞时迅速降速,整体表现相当稳健。

阶段触发条件窗口变化
慢开始连接建立或超时指数增长,直到门限
拥塞避免窗口达到门限线性增长,每 RTT 加 1
快重传三个重复确认立即重传丢失报文段
快恢复快重传之后门限和窗口减半,进入拥塞避免

提示:实际网络里,拥塞控制算法有很多变种,比如 CUBIC、BBR 等,不同系统默认用的可能不一样。理解经典的四个算法是基础,但别以为 TCP 就只有这一套。

6. 常见问题与排查实录

6.1 连接建立失败怎么查

连接建不起来,最常见的原因是端口没开、防火墙拦了、或者服务端没监听。排查顺序我一般是这样:

  1. 先用telnet或nc试一下目标端口通不通。
  2. 服务端用netstat或ss看端口有没有在监听。
  3. 检查防火墙规则,看是否放行了对应端口。
  4. 如果都正常,抓包看 SYN 有没有发出去,有没有收到 SYN+ACK。

抓包是最直接的手段。如果 SYN 发出去了但没响应,多半是网络路径或防火墙问题;如果收到了 RST,说明端口没开或服务没起。

6.2 传输慢、卡顿的排查思路

传输慢的原因很多,我按经验列个速查表:

现象可能原因排查方法
延迟高但带宽没用满网络拥塞或 RTT 大测 RTT,看是否触发拥塞控制降速
带宽用满但速度上不去窗口太小检查接收窗口和拥塞窗口
频繁重传链路质量差抓包看重传率
间歇性卡顿丢包导致超时重传看是否有大量超时事件

我遇到过一次传输慢的问题,最后发现是接收方窗口设得太小,发送方一直填不满管道。调大窗口后速度立刻上去了。所以别只盯着带宽,窗口大小同样是瓶颈。

6.3 TIME_WAIT 过多怎么办

高并发短连接场景下,服务器上 TIME_WAIT 堆积是常态。处理方式有几种:

  • 开启tcp_tw_reuse,允许重用 TIME_WAIT 状态的连接,但只对 outgoing 连接有效。
  • 调整tcp_max_tw_buckets,限制 TIME_WAIT 数量,超了就直接清理。
  • 从架构上改用长连接,减少连接建立和关闭的频率。

我个人更倾向于从架构层面解决,比如用连接池复用连接。调内核参数是治标,改架构才是治本。而且乱调参数可能引入新问题,比如重用连接导致旧数据串进来。

6.4 几个容易混淆的概念澄清

  • 流量控制 vs 拥塞控制:前者管接收方,后者管网络。一个是别撑爆对方缓冲区,一个是别压垮网络。
  • 序号 vs 确认号:序号标识自己发的数据,确认号告诉对方自己期望收到什么。
  • SYN vs FIN:SYN 用于建立连接,FIN 用于关闭连接,都占一个序号。
  • RTO vs RTT:RTT 是实际往返时间,RTO 是重传超时时间,RTO 根据 RTT 动态计算。

这些概念初学容易混,多抓几次包,对着实际数据看,很快就清楚了。我当初就是靠反复抓包,把三次握手和四次挥手的每个字段都对照了一遍,才真正记住。

运输层的内容看着杂,但主线很清楚:UDP 简单直接,TCP 复杂可靠,所有机制都是为了在不可靠的网络层之上,给应用层提供不同级别的服务。理解每个设计背后的“为什么”,比死记硬背字段有用得多。我自己的习惯是,学完一个协议就抓一次包,看着真实的数据流动,很多疑惑自然就解开了。

RELATED READING

延伸阅读

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