这周课程的标题是《传输层(上)》,正好讲到网络协议栈里最核心的一层。说实话,传输层是很多网络问题的分水岭,理解了它,你再看什么连接超时、端口占用、视频卡顿,思路会完全不同。这篇博客把这周涉及的内容整理了一遍,包括端口寻址、UDP、TCP报文结构、三次握手和四次挥手,也补了一些我实际排查问题时踩过的坑。
1. 为什么网络层已经把数据送到门口,传输层还要再插一脚
学习网络的时候,很多人会有一个先入为主的疑问:IP地址都已经让数据包可以从一台主机跑到另一台主机了,那传输层是不是多余?我一开始也有这个困惑,直到自己抓包的时候发现了一个非常朴素的事实:数据包到达一台服务器之后,内核需要知道把这个负载交给哪个进程。同理,你本机的浏览器、邮件客户端、即时通讯软件同时在收发数据,操作系统收到数据之后,怎么区分这是给浏览器的还是给QQ的?这个区分工作,恰恰是网络层做不了的。
网络层的核心设备是IP地址,它解决的是一个“跨网络寻址”的问题,也就是让数据包可以横穿整个互联网,最终找到目标主机。但主机内部通常不是一台只跑一个应用的专用机器,而是一台同时跑着几十上百个进程的通用系统。如果你只有IP地址,数据包到了网卡之后就只能“待分配”,内核完全不知道该把它扔给哪个进程。
传输层在这里承担的是“最后一公里内的精确分拣”角色。它引入了端口(Port)和协议类型两个维度,把一个运输问题从“主机到主机”细化成了“进程到进程”。端口号是一个16位的整数,范围0到65535,它标识的就是主机上一个具体的通信入口,可以理解成大楼楼层已经找到了,但快递员还需要按门铃找到具体是哪个房间。
还有一层经常被忽略的作用是复用与分用。发送端可以多个应用程序同时使用不同的端口对外通信,这叫复用,接收端把收到的数据根据目的端口分发给不同进程,这叫分用。如果没有传输层,那你只能一次跑一个网络应用,体验完全不可接受。所以传输层不是给网络层“锦上添花”,它是网络体系结构里必不可少的那一棒接力。
这周的课正好把传输层拆成了两部分来讲,上半部分重点是概念与协议基础,包括端口、套接字、UDP和TCP报文结构。下半部分预期会进入可靠性、流量控制这类更深的机制。我理解这么拆分是有道理的:如果一上来就把TCP的重传机制、滑动窗口、拥塞控制塞进来,很容易被细节淹没。先搞清楚传输层的定位和协议长什么样,再来理解算法行为,才是顺的路径。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 端口和套接字:先搞懂进程与进程之间的寻址是怎么回事
2.1 端口号不是设备上的物理接口
端口号这个名字容易让人误解,我最早以为它对应的是路由器上的物理插口,后来才知道它完全是一个逻辑概念,硬要说的话它更像是“进程在网络上的门牌号”。操作系统会给每一个需要网络通信的进程分配或由进程主动绑定一个端口,这个端口和IP地址组合起来,就能唯一定位到互联网上的一个进程。
习惯上端口号分成了三段:0到1023是系统端口,也叫知名端口,HTTP服务用80,HTTPS用443,DNS用53,SSH用22,像这类约定俗成的服务都固定在这段区间。1024到49151是注册端口,应用可以申请使用,但部分需要特权或约定,比如MySQL的3306。49152到65535是动态端口,客户端程序发起对外连接时,操作系统通常会从这个区间自动挑一个空闲的端口作为临时源端口。
我在自己的机器上验证过这个行为。用浏览器打开几个网站之后,在终端执行:
bash复制netstat -an | grep ESTABLISHED
可以看到几乎每一条TCP连接,本地地址的端口都是49152以上的高位端口,这就是操作系统自动分配的临时端口。而目标地址的端口一般是443或者80,因为你要访问服务,目标进程只在那里等你。
2.2 套接字:四元组才是一条完整的连接
在Linux和Windows网络编程里经常听到“套接字”(Socket)这个概念,很多初学者误以为Socket是一个端口。实际上套接字描述的是IP地址与端口号的组合。如果你写过一个简单的TCP服务端程序,一定会调用bind()绑定一个端口,再调用listen()开始监听,那个绑定之后形成的端点,就是套接字。
一条TCP连接要用四个要素来唯一定义:源IP、源端口、目的IP、目的端口。这四样合起来叫四元组。反过来说,服务器上同一个80端口可以同时维持上万条连接,就是因为每一条连接的客户端IP和客户端端口组合不同。连接不是由单一端口决定的,而是被四元组区分开的。
有一次我自己写一个简易服务端测试代码,同一个端口绑定两个进程,结果第二个进程直接抛出了“Address already in use”。这个报错的本质就是:同一个套接字不能同时被两个进程占用,因为内核的端口分配表里不允许出现完全相同的IP加端口。理解了套接字的组成,这类错误就很好理解了。
2.3 端口占用排查痕迹:新手最容易踩的坑
既然端口是个有限资源,冲突就不可避免。最常见的现场是:你启动一个服务,提示端口被占用,但又不知道是哪个进程占的。在Linux下可以用这条链排查:
bash复制ss -lnpt | grep 8080
ss会列出监听状态的socket,配合-p显示PID和进程名,你会直接看到占用者的身份。如果是处理Windows服务器,可以用:
powershell复制netstat -ano | findstr 8080
tasklist /FI "PID eq 12345"
我踩过最典型的一个坑是服务绑定地址写成了127.0.0.1,后来另一台机器怎么都连不上。查了一圈才发现监听地址只允许本机回环访问,改成0.0.0.0才把服务暴露到外部网络。这类问题和端口本身没关系,但排查过程中对端口、地址、套接字关系的理解越清晰,定位越快速。
3. UDP:头部极简的“尽力而为”协议,为什么到现在还没被淘汰
3.1 UDP首部只有8个字节
UDP(用户数据报协议)给我的第一感觉就是“不当家不知柴米贵”——它的头部只有四组字段:源端口、目的端口、长度、校验和,每组各占2字节,加起来8个字节。对比TCP动辄20字节的固定头部,UDP简直算得上“轻装上阵”。
这里唯一有点概念门槛的是校验和字段。UDP的校验和除了覆盖UDP头部和数据之外,还会覆盖一个12字节的“伪首部”。伪首部里包含源IP地址、目的IP地址、协议号还有UDP长度。伪首部并不是真实传输的一部分,它纯属计算校验和时临时拼接出来的一个结构。为什么要这么设计?为了避免同一个UDP数据报被“错投”到错误的IP地址上还察觉不出来。IP层如果发生了误投递,UDP可以通过校验伪首部识别出目标地址不匹配,这算是一种跨层的保护机制。
3.2 UDP不保证送达、不保证顺序、不防止重复
UDP的不可靠表现得很直白:发送方调用了sendto(),内核就把数据扔进网卡队列,后续什么情况都不管了。数据包丢失了不重传;到达顺序错乱了不重排;出现了重复数据也不去重。接收方有没有收到?收到了没有?应用层自己负责。
但不可靠协议在互联网里的存活度其实非常高,这背后有一个逻辑:不是所有数据都需要可靠传输。比如视频通话、在线游戏,这类场景对延迟极其敏感,也不是很在乎偶尔一帧画面的丢失。如果用了TCP,一旦丢包就会触发重传,造成的数据等待和卡顿比丢掉那一帧的观感严重得多。实时交互场景里,“晚到”的数据甚至比“丢掉的”更让人难受。
我还用过UDP来实现局域网内的设备发现功能。设备启动时向广播地址发送一条UDP报文,网关内的其他主机收到后回复自己的信息。这类一次性、短小、失败成本低的通信,UDP天然适合,完全不用为建立连接消耗额外往返时间。
3.3 从抓包看一条真实的DNS请求
DNS域名解析是UDP最典型的应用场景之一。我用Wireshark简单验证过一次,流程非常直观。在过滤栏输入dns,然后清空本地DNS缓存或刷新某个域名时,能看到一条查询请求从你本机的临时端口(常常是高位端口)发向目的端口53,紧接着会收到一条UDP响应报文,甚至看不到任何连接建立的痕迹,这就是无连接协议的抓包特征。
在命令行也可以快速验证:
bash复制dig www.example.com +short
之后在Wireshark里看到的就是典型的UDP问答过程。这个例子的好处是,它把“无连接、简单头部、快、不可靠”这些抽象特点全部具象化了:没有第三次握手,没有序号的维护,没有状态管理,一问一答,数据就完成了传递。
4. TCP头部结构:20个字节里写满了控制逻辑
4.1 TCP头部的核心字段到底在标记什么
TCP和UDP最本质的区别,是TCP是有状态的协议。它要通过连接来维护通信双方的上下文信息,而这些状态绝大多数都反映在TCP头部字段里。
TCP头部固定部分是20字节。源端口和目的端口各占2字节,和UDP一样;序号(Sequence Number)占4字节,标记的是这个报文段第一个字节在发送字节流中的偏移量;确认号(Acknowledgment Number)也占4字节,表示接收方期望下一次收到的序号。这两个字段是整个可靠传输机制的基石,重传和去重的判断逻辑都建立在这两个数字上。
数据偏移字段指示首部长度,因为TCP头部之后还有可选字段,长度不固定,必须告诉接收方“从哪里开始算数据”。窗口大小字段则用于流量控制,告诉对端“你最多还能发多少字节”,这也是防止接收方缓冲区被撑爆的关键机制。
4.2 六个标志位:TCP状态机的最小操作面板
TCP头部有一个特别值得研究的区域,是占2字节的标志位字段,其中有六个常用的控制位,分别代表了报文段要触发或者表达的状态转移意图:
- URG:紧急指针有效,很少用。
- ACK:确认号有效,表示这个报文段是在确认之前的某个数据。
- PSH:接收方应尽快把这个段交给应用层。
- RST:连接异常要重置。
- SYN:发起连接请求,同步初始序号。
- FIN:通知对端自己这边数据发送完毕,请求断开连接。
SYN和ACK配合构成了三次握手的基础。我第一次抓包观察三次握手时,看到第一条报文SYN置1,第二条SYN和ACK同时置1,第三条只有ACK,瞬间就理解了“握手”的本质是在交换彼此的初始序号。这个初始序号非常重要,如果双方不知道对方的ISN(初始序号),就没法对收到的数据段进行排序,也就不可能实现可靠性。
4.3 用Wireshark读懂一条TCP建连报文
看固定字段时有一个技巧:任何一条TCP报文都可以先看序号和确认号怎么变化的。我抓过一个访问网站的包,里面TCP流的第一条报文是SYN,序号是0——这是Wireshark的相对序号显示方式,方便人观察。第三条报文ACK之后,第四号报文就开始携带HTTP请求数据了,而且它的序号就是1,和第三条的序列号一致,这说明之前握手过程没有消耗任何字节的数据载荷。
值得注意的一个细节是,SYN和FIN报文都会消耗一个序号。这意味着即使它们是控制报文,不携带应用数据,它们本身也会占据一个序号位置。如果抓包时发现握手后数据报文的起始序号不是1,不用惊讶,这是带有TCP时间戳选项或实际数据引起的正常现象。理解这个“控制报文也占序号”的原理,后续分析重传时才能少走弯路。
5. 三次握手与四次挥手:连接治理里不得不较真的细节
5.1 为什么一定是三次,两次不行吗
三次握手的目的是让双方都确认“自己发送的能力”和“对端接收的能力”是正常的。SYN的英文全称是Synchronize,翻译过来是“同步”,实际上双方要同步的就是初始序号。
为什么必须是三次?核心原因是TCP要避免“迟到的重复连接请求”引发错误。假设只有两次握手,一个在网络中滞留了很久的旧SYN报文突然到达服务器,服务器会以为这是一个新的连接请求,于是回复SYN+ACK并分配资源,但这个连接其实根本不会被客户端认可,白白浪费服务器资源。三次握手的最后一个ACK正是用来解决这种历史重复连接请求问题的——客户端收到一个回复后,会判断这条回复是否对应自己刚刚发起的连接请求,如果不是,直接发送一个RST报文终止这个虚假连接。
用通俗的比喻来说,A/B两人打电话,A说“我能听到你吗”,B说“我能听到你,你能听到我吗”,A再说“我能听到你”。整个过程要保证双方互相验证过收发能力,缺任何一步都会让某一方状态不完整。
5.2 TIME_WAIT为什么非得等2MSL
四次挥手的难点不是四次,而是最后那个TIME_WAIT状态。主动关闭连接的一方,在发送最后一个ACK之后,并不会马上关闭连接,而是进入TIME_WAIT状态,等待2MSL时长。MSL全称Maximum Segment Lifetime,报文段最大存活时间。
TIME_WAIT有两个目的。第一个是为了让迟到的重传报文最终可以被丢弃,如果主动关闭方立即进入CLOSED状态,而网络中还有一个旧的数据段“游荡”,它可能会被分配到新的连接上,造成数据混淆。第二个是为了让被动关闭方重发的FIN报文有机会被正确处理,如果最后一个ACK丢了,被动关闭方会超时重发FIN,TIME_WAIT状态下还能再回复一次ACK。
这个状态直接导致了一个常见问题:服务器主动关闭连接后,端口会进入TIME_WAIT,短时间内不能重新绑定。我接手过一个Nginx调优的任务,高并发场景下看到大量TIME_WAIT连接,后来确认是服务端主动关闭了连接造成的,通过调整短连接策略和复用配置才缓解。看到TIME_WAIT不用恐慌,它是TCP设计里一个合理的正常状态。
5.3 RST报文:异常时候的“急刹车”
RST标志位的含义是连接要被异常终止。发RST,一般有几个常见原因:端口根本没被监听,对方直接回RST;连接已经超时被对端关闭,本地还在傻傻地发数据;服务器主动拒绝连接,比如进程崩溃、服务过载等。
我曾经排查过一个SpringBoot应用偶发连接失败的问题,抓包发现服务端在收到SYN之后直接回了RST,说明服务器的accept队列已经满了,内核就直接拒绝新连接。这类情况只看应用日志很难找到根因,因为连接根本还没到达应用层就被内核丢弃了。后来调整了backlog参数和操作系统层的somaxconn才解决问题。从这个角度说,掌握TCP头部标志位的语义,对排查生产环境问题帮助很大。
6. 结合实验本地环境对瞄:用抓包工具把理论吃透
这周讲课的节奏很快,很多东西如果光靠看书,很容易以为自己懂了,一到实际操作还是会发懵。我建议学传输层的时候,把Wireshark或者tcpdump作为标配工具,跟着抓几类典型的包,理解深度会完全不一样。
6.1 抓一次完整的UDP/TCP对比实验
最简单有效的一个练习是:在终端启动一个简单的UDP服务端和客户端,比如用Python一行命令启动:
python复制# UDP服务端示例
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('0.0.0.0', 8888))
data, addr = s.recvfrom(1024)
s.sendto(b'hello', addr)
然后用Wireshark抓包,能看到的是UDP报文只有四字段,全程没有连接状态变化。换成TCP服务端再试,就能看到三次握手的完整过程,而且关闭连接时四次挥手也会出现。对比两张抓包截图,UDP和TCP的差异简直一目了然。
这里有个实操建议:抓本机回环流量时,Wireshark默认不会抓取loopback接口,Linux下需要选择“loopback: lo”这个接口。Windows上也有类似的选择,或者使用Npcap loopback adapter。第一次抓不到包的话,多半是接口选错了。
6.2 抓包过滤表达式的三个常用姿势
在Wireshark里过滤TCP/UDP流量,有三个基础过滤条件特别常用:
bash复制tcp.port == 443 # 只看源或目标端口是443的TCP报文
udp.length > 100 # 过滤UDP中数据长度大于100的报文
tcp.flags.syn == 1 && tcp.flags.ack == 0 # 只筛出SYN包
第三个过滤条件在观察“SYN超时重传”的场景下非常好用。当网络不可达时,抓包能看到内核每隔一段时间就会重发一个SYN,默认重传次数由net.ipv4.tcp_syn_retries控制,通常默认是6次,期间不断递增重传间隔,最后才返回连接超时。这个过程如果不抓包,你根本想象不到客户端默默地重传了多少次。
6.3 一个课后可做的思考:你常用的应用用了什么协议
学完传输层(上),可以拿你手机里的应用列表挨个做一次“协议猜测”:视频直播大概率用了UDP或者基于UDP的私有协议,文件下载基本都是TCP,在线游戏可能两者混用,域名解析肯定是UDP,HTTPS访问必须是TCP。然后找一台测试机,跑一下抓包,验证自己的判断。这个过程比背十遍“UDP无连接、TCP面向连接”有效太多。
等下周课程进入TCP可靠传输、滑动窗口和拥塞控制之后,再回头看头部字段,很多设计理由会自己浮现出来。别急,传输层是一座值得慢慢挖的矿山。
