1. 运输层到底在整条链路里扮演什么角色
很多人学计算机网络,学到运输层就开始犯迷糊。前面物理层、数据链路层、网络层,好歹还能对应到网线、网卡、IP地址这些看得见摸得着的东西,一到运输层,突然全是端口、连接、窗口、确认号这些抽象概念,感觉像在背天书。我当初也是这么过来的,后来做后端开发,天天跟TCP打交道,被线上问题反复教育之后,才真正把这一层想明白。
运输层的位置很特殊,它夹在网络层和应用层之间。网络层负责把数据包从一台主机送到另一台主机,但它只管送到主机,不管送到主机上的哪个程序。你可以把网络层想象成快递公司,它能把包裹送到某栋楼,但楼里住了几十户人家,到底送给谁,网络层不管。运输层干的就是这个"送到具体住户"的活,它通过端口号来区分同一台主机上的不同应用进程。
这个类比还能再往下延伸。快递送到楼里之后,有的住户要求必须本人签收、少一件都不行,有的住户觉得差不多就行、丢了也无所谓。运输层提供的两种主要协议正好对应这两种需求:TCP讲究可靠,丢一个包都要重传,保证数据完整有序;UDP讲究快,发出去就不管了,丢了就丢了。选哪个,取决于你的应用能不能容忍数据丢失。
我见过不少初学者把运输层和网络层混为一谈,觉得IP地址就能定位一切。实际上IP地址只能定位到主机,端口才能定位到进程。一台服务器上同时跑着Web服务、数据库、缓存,它们共用同一个IP,靠的就是不同的端口号来区分。理解这一点,是理解运输层所有机制的前提。
这一层要解决的核心问题其实就三个:第一,怎么在一台主机上区分不同的应用;第二,怎么保证数据可靠地送达;第三,怎么在不可靠的网络上做流量控制和拥塞控制。把这三点想透,运输层就没有难理解的地方了。接下来我会逐个拆开讲,尽量用实际场景和踩过的坑来说明,而不是干巴巴地念概念。
2. 端口与复用分用:一台主机怎么同时伺候几十个程序
2.1 端口号不是随便编的,它有一套分配规则
端口号是一个16位的数字,范围从0到65535。这个范围看着挺大,但实际用起来是有讲究的,不能随便乱用。端口号被划分成几个区间,每个区间有明确的用途,这个规则是所有人都要遵守的约定,违反了就可能跟别人的程序撞车。
| 端口区间 | 范围 | 用途说明 |
|---|---|---|
| 熟知端口 | 0 - 1023 | 分配给系统级或广泛使用的服务,如HTTP的80、HTTPS的443 |
| 注册端口 | 1024 - 49151 | 分配给用户注册的应用程序,如MySQL的3306、Redis的6379 |
| 动态端口 | 49152 - 65535 | 临时分配给客户端进程,通信结束后释放 |
我踩过一个坑:早期写一个内部工具,图省事直接把服务端口设成了80,结果跟本机已经跑的Web服务冲突,启动直接报错。后来才知道,1024以下的端口在类Unix系统上需要管理员权限才能绑定,普通用户根本用不了。所以自己写服务,老老实实从1024以上选,最好避开那些知名服务的常用端口,免得以后部署时打架。
动态端口这块也值得说一句。客户端发起连接时,操作系统会从动态端口区间里随机分配一个端口给这个连接。这意味着同一个客户端程序,每次发起连接用的源端口可能都不一样。这个设计是为了让多个连接能同时存在而不冲突。你打开浏览器访问同一个网站,可能同时开了好几个连接,它们的目标端口都是443,但源端口各不相同,操作系统靠这个四元组来区分不同的连接。
2.2 复用和分用,其实是同一件事的两个方向
复用和分用这两个词听着唬人,说白了就是"合"和"分"。复用是发送方把多个应用进程的数据合到一起,交给网络层发出去;分用是接收方把网络层交上来的数据,按端口分给对应的应用进程。
发送方向的复用很好理解。你的电脑上开着浏览器、聊天软件、音乐播放器,它们都在往外发数据。运输层给每个进程的数据打上端口标记,然后统一交给网络层。网络层不关心这些数据来自哪个进程,它只管往目标主机送。这就像快递站把不同人寄的包裹统一装车,装车的时候不关心包裹里是什么,只关心送到哪个城市。
接收方向的分用才是容易出问题的地方。数据到了目标主机,运输层要根据端口号把数据交给正确的进程。这里有个细节:TCP和UDP的端口是相互独立的。也就是说,TCP的80端口和UDP的80端口是两个不同的东西,可以同时被不同的程序占用。我见过有人排查问题,发现某个端口被占用,用工具查了半天,结果发现占用的是UDP,而自己的程序用的是TCP,虚惊一场。
还有一个更隐蔽的坑:分用的时候,如果目标主机上没有任何进程监听那个端口,会发生什么?对于UDP,操作系统会回一个ICMP端口不可达的报文;对于TCP,会回一个RST报文拒绝连接。这个机制在排查网络问题时特别有用。你telnet一个端口,如果立刻被拒绝,说明主机是通的,只是那个端口没服务;如果一直卡着没反应,那可能是防火墙把包丢了,或者主机根本不可达。这两种情况的排查方向完全不同。
2.3 套接字:IP加端口才是完整的通信端点
单说IP或者单说端口,都不足以唯一标识一个通信端点。IP加端口,才构成一个完整的套接字。而一条TCP连接,是由四个元素唯一确定的:源IP、源端口、目标IP、目标端口。这四个元素组成一个四元组,只要其中任何一个不同,就是不同的连接。
这个四元组的概念在实际开发中非常关键。比如你写一个服务器,监听在0.0.0.0:8080。0.0.0.0表示监听本机所有网卡的所有IP。当有客户端连接进来时,服务器会为每个连接创建一个新的套接字,这个套接字的四元组是唯一的。所以同一个客户端用不同的源端口发起两次连接,服务器会看到两个不同的连接,各自独立维护状态。
我遇到过一个问题:某台服务器上跑的服务,客户端反馈偶尔连不上。排查发现,服务器配置了多个IP,服务只监听了其中一个IP,而客户端通过DNS解析可能拿到另一个IP,连过去自然没人应答。这就是没理解套接字绑定IP的含义。绑定0.0.0.0和绑定具体IP,行为是不一样的。绑定具体IP只接受发往那个IP的连接,绑定0.0.0.0则接受发往本机任意IP的连接。生产环境里,除非有特殊需求,一般建议绑定0.0.0.0,避免因为IP变化导致服务不可达。
3. UDP:看似简单,用对了地方才叫真香
3.1 UDP的报文结构简单到极致
UDP的头部只有8个字节,四个字段:源端口、目标端口、长度、校验和。就这么点东西,没有序列号,没有确认号,没有窗口,没有任何连接状态。发送方把数据加上这8个字节的头,直接交给网络层就完事了。
这种极简设计带来的好处是开销小、延迟低。没有建立连接的三次握手,没有维护连接状态的负担,没有重传等待的时间。对于实时性要求高、能容忍少量丢包的应用,UDP是天然的选择。比如视频直播,丢一两帧画面,用户可能根本察觉不到,但如果为了重传那一帧卡住整个画面,体验反而更差。
但简单也意味着责任转移。TCP帮你做的可靠性保证、顺序保证、流量控制,UDP统统不做,这些活要么应用层自己扛,要么就接受不保证。我见过有人用UDP传文件,结果发现文件偶尔损坏,还以为是代码bug,查了半天才反应过来,UDP根本不保证数据完整到达,校验和只能检测错误,检测到了也只是丢弃,不会重传。
3.2 校验和:UDP唯一的错误检测手段
UDP的校验和字段是它唯一的错误检测机制。发送方计算整个报文(包括头部和数据)的校验和,接收方收到后重新计算,如果不一致就丢弃。注意,是丢弃,不是重传。UDP没有重传机制,丢了就丢了,应用层如果在意,得自己想办法。
校验和的计算用的是反码求和。把数据按16位分组,所有组做反码加法,最后取反码。这个算法实现起来不复杂,但有个细节容易忽略:如果数据长度是奇数个字节,需要在末尾补一个全零字节再计算。这个补的字节只用于计算校验和,不会真的发送出去。
提示:UDP的校验和是可选的。在IPv4里,发送方可以把校验和字段填成全零,表示不计算校验和。接收方看到全零就跳过校验。但在IPv6里,校验和是强制的,不允许跳过。这个差异在做跨协议栈开发时要注意。
实际开发中,我建议除非有极端的性能考量,否则不要关闭UDP校验和。网络传输中出现位错误的概率虽然不高,但一旦发生,没有校验和的话,错误数据会直接交给应用层,可能导致难以排查的诡异问题。开启校验和的开销在现代硬件上几乎可以忽略。
3.3 UDP的适用场景与常见误用
UDP适合的场景有这么几类:实时音视频、在线游戏的状态同步、DNS查询、DHCP、以及一些自定义的可靠传输协议(在UDP之上自己实现可靠性)。这些场景的共同特点是:要么对延迟极度敏感,要么通信模式简单(一问一答),要么应用层自己有能力处理丢包和乱序。
常见的误用是把UDP当成"更快的TCP"来用。有人觉得TCP慢,就换成UDP,结果发现数据经常丢失或乱序,又不得不在应用层把TCP的可靠性机制重新实现一遍,最后做出来的东西比直接用TCP还慢。这个弯路我见太多人走过了。选UDP之前先问自己:我的应用能容忍丢包吗?能容忍乱序吗?如果答案是不能,那还是老老实实用TCP。
还有一个坑是UDP的缓冲区问题。UDP没有流量控制,发送方发得太快,接收方的缓冲区满了,后续的包就会被直接丢弃。这个丢弃是静默的,发送方完全不知道。所以用UDP做大量数据传输时,应用层必须自己实现某种形式的速率控制,否则丢包率会高得离谱。我做过一个局域网内的UDP传输测试,发送方全速发送,接收方缓冲区设得不够大,丢包率直接飙到30%以上,调大缓冲区之后才降下来。
4. TCP的可靠传输:那些看不见的机制才是精髓
4.1 三次握手不只是打招呼,它在交换关键信息
TCP建立连接要三次握手,这个流程大家都背过:客户端发SYN,服务端回SYN+ACK,客户端再回ACK。但很多人只记住了流程,没想过为什么是三次而不是两次。
两次握手的问题在于:服务端无法确认客户端收到了自己的SYN+ACK。如果客户端的第一个SYN因为网络延迟,很久之后才到达服务端,服务端回SYN+ACK,客户端可能已经不再需要这个连接了。两次握手的话,服务端发完SYN+ACK就认为连接建立了,开始发数据,但客户端根本不认这个连接,数据全白发了。三次握手让客户端有机会告诉服务端"我收到了你的确认,这个连接有效",服务端才敢真正建立连接。
三次握手还在交换一个关键信息:初始序列号。双方各自随机生成一个初始序列号,后续所有数据的序列号都基于这个初始值递增。随机化初始序列号是为了安全,防止别人猜测序列号来伪造报文。这个设计在早期互联网上可能没那么重要,但在今天,序列号可预测会带来严重的安全隐患。
注意:初始序列号不是从0或1开始的,而是随机生成的。如果你抓包看到序列号是个很大的随机数,不要惊讶,这是正常行为。
握手过程中还有个细节:SYN报文会消耗一个序列号。也就是说,如果客户端发的SYN序列号是X,那么客户端发送的第一个数据字节的序列号是X+1。FIN报文同样消耗一个序列号。这个细节在计算确认号的时候容易搞错,我自己就曾经在写一个简易TCP协议栈的时候,因为没算对SYN消耗的序列号,导致确认号一直对不上,排查了大半天。
4.2 滑动窗口:可靠和效率的平衡艺术
如果每发一个数据包都要等对方确认了再发下一个,那网络利用率会低得可怕。滑动窗口机制就是为了解决这个问题:发送方可以在没有收到确认的情况下,连续发送多个数据包,只要这些数据包的序列号落在窗口范围内。
窗口大小决定了发送方在等待确认期间最多能发多少数据。窗口越大,吞吐量越高,但接收方的缓冲区压力也越大。接收方通过TCP头部里的窗口字段告诉发送方自己还能接收多少数据,发送方根据这个值调整自己的发送窗口。这就是流量控制。
滑动窗口的"滑动"体现在:当发送方收到对某个序列号的确认时,窗口的左边界向右移动,新的序列号进入窗口范围,可以继续发送。这个过程是动态的,随着确认的到达不断向前推进。
我踩过一个跟窗口相关的坑:在一个高延迟的网络环境里,默认的窗口大小导致吞吐量上不去。原因是带宽时延积太大,窗口填不满管道。带宽时延积等于带宽乘以往返时间。比如带宽是100Mbps,往返时间是100ms,那么带宽时延积就是100Mbps乘以0.1s等于10Mbit,也就是大约1.25MB。如果窗口大小小于这个值,发送方就会经常处于等待确认的状态,管道填不满,吞吐量自然上不去。解决办法是启用窗口缩放选项,把窗口大小的上限从65535字节扩大到更大。
4.3 超时重传:时间设多少才合适
TCP发送一个数据包后,会启动一个定时器。如果在定时器超时之前没有收到确认,就认为数据包丢失,重新发送。这个超时时间设多少,是个技术活。
设得太短,网络稍微抖动一下就触发重传,造成不必要的网络负担,而且可能让拥塞更严重。设得太长,丢包后要等很久才重传,传输效率低。TCP的做法是动态估算:根据往返时间的测量值,计算一个加权平均的往返时间和偏差,超时时间设为加权平均往返时间加上若干倍的偏差。这样既能适应网络的变化,又不会过于激进。
这个动态估算的算法有个名字叫Jacobson/Karels算法,是TCP可靠传输的基石之一。它的核心思想是:往返时间的波动越大,超时时间就应该设得越保守。因为波动大意味着网络状况不稳定,给一个宽松的超时时间可以避免误判。
实际抓包的时候,你会看到重传的报文。如果重传频繁发生,说明网络质量有问题,或者超时时间设得不合理。我排查过一次线上问题,发现某个服务的TCP重传率异常高,最后定位到是中间某台设备的缓冲区太小,导致突发流量时丢包。这种问题从应用层看不出来,只有抓包分析TCP行为才能发现。
4.4 快速重传:不等超时就重传
超时重传有个缺点:要等超时时间到了才重传,这段时间可能很长。快速重传机制允许发送方在收到三个重复确认时立即重传,不用等超时。
重复确认的意思是:接收方收到了一个失序的报文,比如期望序列号是100,结果收到了120,它就会重复发送对100的确认。发送方连续收到三个对同一个序列号的确认,就推断这个序列号的报文丢了,立刻重传,不用等定时器超时。
这个机制大大加快了丢包恢复的速度。在丢包率不高的网络里,大部分丢包都是通过快速重传恢复的,超时重传反而是少数情况。我抓包统计过,在一个质量还不错的网络里,快速重传占了重传总数的九成以上。
快速重传也有它的局限。如果丢包太多,或者窗口太小,可能凑不够三个重复确认,那就只能等超时了。另外,如果接收方的确认报文本身丢了,发送方也收不到重复确认,同样得等超时。所以超时重传这个兜底机制永远不能去掉。
5. 拥塞控制:TCP最复杂的部分,也是最容易被忽略的
5.1 拥塞控制和流量控制是两码事
很多人把拥塞控制和流量控制搞混。流量控制是接收方告诉发送方"我还能收多少",是端到端的,跟网络中间的状态无关。拥塞控制是发送方感知到网络拥堵了,主动降低发送速率,是面向整个网络的。
打个比方:流量控制像是收件人说"我家信箱只能放这么多信,你别一次寄太多";拥塞控制像是邮局发现路上堵车了,主动限制大家寄信的速度,免得堵得更厉害。一个关心接收方,一个关心网络。
TCP的拥塞控制有四个核心算法:慢启动、拥塞避免、快速重传、快速恢复。这四个算法配合工作,让TCP能在不知道网络具体状态的情况下,动态调整发送速率。
5.2 慢启动:从零开始试探网络容量
慢启动的名字有点误导,它其实一点都不慢。慢启动的意思是:连接刚建立时,发送方不知道网络能承受多大的速率,所以从一个很小的拥塞窗口开始,每收到一个确认就把窗口翻倍。1变2,2变4,4变8,是指数增长的。
指数增长很快就能让窗口变得很大,所以叫"慢启动"其实是指起点低,不是指增长慢。这个设计让TCP能快速探测到网络的可用带宽,同时又不至于一上来就发太多把网络打爆。
慢启动什么时候结束?当窗口增长到慢启动阈值时,就进入拥塞避免阶段。慢启动阈值初始设为一个很大的值,当发生拥塞时,阈值会被设为当前窗口的一半,然后窗口重新从1开始慢启动。这个机制让TCP能记住上次拥塞时的窗口大小,避免再次撞到同一个墙上。
我观察过慢启动的实际效果。在一个局域网环境里,慢启动阶段非常短,几个往返之后窗口就涨到很大了。但在跨地域的广域网环境里,因为往返时间长,慢启动需要更多时间才能把窗口涨起来。这也是为什么短连接在广域网里效率不高的原因之一:连接刚建立,窗口还没涨起来,数据传输就结束了。
5.3 拥塞避免:线性增长,谨慎试探
进入拥塞避免阶段后,窗口不再翻倍,而是每个往返时间只增加一个最大报文段的大小。这个增长是线性的,比慢启动的指数增长慢得多。目的是在接近网络容量时谨慎试探,不要一下子加太多导致拥塞。
线性增长意味着,如果网络容量很大,从慢启动切换到拥塞避免后,窗口要涨到理想值需要很长时间。这是TCP的一个固有特性:它在探测网络容量时偏保守。对于需要高吞吐量的长连接,这个特性可能导致带宽利用率不足。
实际中,很多操作系统提供了拥塞控制算法的选择。除了传统的Reno算法,还有CUBIC、BBR等更现代的算法。CUBIC改进了窗口增长曲线,在高带宽高延迟网络里表现更好。BBR则完全换了个思路,不再基于丢包来判断拥塞,而是基于带宽和往返时间的测量来主动控制发送速率。选哪个算法,取决于你的网络环境和应用需求。
5.4 快速恢复:丢包后不要回到起点
快速重传触发后,发送方知道丢了一个包,但网络可能并没有严重拥塞。如果这时候把窗口直接降到1重新慢启动,那就太保守了,会白白浪费带宽。快速恢复的做法是:把窗口降到原来的一半,然后直接进入拥塞避免阶段,而不是回到慢启动。
这个机制的道理是:收到三个重复确认说明后续的包还在到达,网络还在工作,只是丢了个别包。这种情况下没必要把发送速率降到最低,减半就够了。只有超时重传才说明网络可能严重拥塞,才需要回到慢启动。
快速恢复和快速重传经常一起出现,合称"快速重传/快速恢复"。这两个机制让TCP在丢包时能快速恢复,而不是每次都从头再来。我抓包对比过,启用快速恢复和不启用,在丢包率1%的网络里,吞吐量差距能达到30%以上。
6. 抓包实战:用Wireshark把运输层看个透
6.1 抓包前的准备工作
光看理论容易忘,抓一次包比看十遍书都管用。Wireshark是最常用的抓包工具,免费且功能强大。但在开始抓包之前,有几个准备工作要做。
首先是选择抓哪个网卡。如果机器上有多个网卡,要选实际承载流量的那个。选错了网卡,抓到的包全是无关的广播或者干脆什么都没有。不确定的话,可以先随便选一个,看看有没有流量,没有就换一个。
其次是设置过滤器。不加过滤直接抓,在繁忙的网络里几秒钟就能抓出几万个包,根本没法看。抓包之前先想清楚要抓什么:按IP过滤、按端口过滤、按协议过滤。比如只想看某个服务的TCP流量,可以设过滤条件为tcp port 8080。这个过滤是在抓包时生效的,能大幅减少抓到的数据量。
提示:Wireshark的抓包过滤和显示过滤是两套语法。抓包过滤在开始抓之前设置,用的是BPF语法;显示过滤在抓完之后设置,用的是Wireshark自己的语法。两者不要搞混。
6.2 从三次握手看起
抓到一个TCP连接,第一件事就是找三次握手。在Wireshark的显示过滤器里输入tcp.flags.syn==1,就能筛出所有SYN报文。找到客户端发的第一个SYN,右键选择Follow TCP Stream,Wireshark会自动把这个连接的所有报文筛出来,并按顺序排列。
看三次握手的时候,重点关注几个字段:序列号、确认号、窗口大小、选项。序列号和确认号能帮你理解TCP的编号机制。窗口大小能看出双方的接收能力。选项里可能包含最大报文段长度、窗口缩放、选择性确认等信息,这些选项直接影响传输性能。
我建议第一次抓包的时候,找一个简单的HTTP请求来抓。HTTP基于TCP,流程清晰,报文不多,适合入门。抓完之后,对照着三次握手的理论,一个包一个包地看,把每个字段的含义搞清楚。这个过程可能有点枯燥,但走一遍之后,运输层的很多概念就具象化了。
6.3 观察滑动窗口和重传
抓到一个数据传输的过程后,可以在Wireshark里看窗口的变化。Wireshark有个很好的功能叫TCP Stream Graphs,能画出序列号随时间的变化图、窗口大小随时间的变化图。通过这些图,能直观地看到滑动窗口是怎么滑动的,窗口大小是怎么变化的。
重传的报文在Wireshark里会被标记出来,显示为黑色背景或者带有重传标记。如果看到大量重传,说明网络有问题。可以进一步分析重传的类型:是超时重传还是快速重传。超时重传说明问题比较严重,快速重传说明只是个别丢包。
我还喜欢看Wireshark的专家信息,它会自动分析抓到的包,给出警告和建议。比如重复确认、窗口满、校验和错误等问题,专家信息里都会有提示。这个功能对快速定位问题很有帮助。
6.4 一个真实的排查案例
有一次线上服务响应变慢,用户反馈页面加载要好几秒。我先在服务器上抓包,发现TCP重传率很高。进一步分析,发现重传的都是大包,小包很少重传。这提示可能是链路的MTU有问题,大包被分片或者丢弃了。
沿着这个思路查下去,发现中间某台设备的MTU设得比两端都小,导致大包经过时被丢弃。TCP发现大包丢了就重传,重传的还是大包,还是被丢,陷入恶性循环。解决办法是调整MTU或者启用路径MTU发现。调整之后,重传率降下来了,响应时间也恢复正常。
这个案例说明,运输层的问题往往不是运输层本身的问题,而是下层网络的问题在运输层的表现。抓包分析的时候,不能只盯着运输层看,要结合网络层的ICMP报文、链路层的帧大小一起分析。运输层是网络问题的"显示器",很多底层问题都会在这里露出马脚。
7. 运输层学完之后,下一步该往哪走
运输层是计算机网络里承上启下的一层。往上,它支撑着应用层的各种协议;往下,它依赖网络层提供的尽力而为服务。把运输层搞明白,再回头看应用层的HTTP、HTTPS,很多设计就顺理成章了。比如HTTP为什么用TCP而不是UDP,HTTP/3为什么又换成了基于UDP的QUIC,这些问题的答案都在运输层。
我的建议是,学完运输层之后,不要急着往下背应用层的协议细节,而是先动手做几个小实验。用Socket写一个简单的TCP客户端和服务端,观察连接建立、数据传输、连接关闭的完整过程。再用UDP写一个,对比两者的行为差异。如果条件允许,用Wireshark抓包看看自己写的程序产生的流量,把理论和实际对应起来。
还有一个值得深入的方向是TCP的拥塞控制算法。传统的Reno和CUBIC基于丢包判断拥塞,在高带宽网络里逐渐力不从心。BBR这类基于测量的算法代表了新的思路。如果你对网络性能优化感兴趣,这个方向有很多可以深挖的东西。
最后说一个我自己的体会:运输层的知识,光看书是学不会的。那些序列号、窗口、确认号的交互,必须在实际抓包中反复观察才能真正理解。我当初学的时候,把三次握手的流程背得滚瓜烂熟,但直到第一次抓包看到真实的SYN、SYN+ACK、ACK报文,才真正明白每个字段在干什么。所以,别怕麻烦,打开Wireshark,抓一个包,比看十页书都值。