4. 传输层核心协议:UDP与TCP详解
传输层是整个TCP/IP协议栈的「腰部」。没有它,应用层的数据就不知道往哪送,网络层的IP包也不知道该交给哪个进程。说白了,传输层干的就是两件事:端到端通信和进程间寻址。
这一章我们重点啃两个核心协议——UDP和TCP。我做了十几年网络,见过太多人把三次握手背得滚瓜烂熟,但一问「为什么是三次不是两次」就卡壳。嗯,咱们今天不光要记住,还要理解背后的设计逻辑。
4.1 UDP协议详解
UDP,全称用户数据报协议。它的设计哲学就一个字:快。
先看它的头部结构——只有8个字节,简单得让人感动:
| 字段 | 长度 | 说明 |
|---|---|---|
| 源端口 | 16位 | 发送方端口号 |
| 目的端口 | 16位 | 接收方端口号 |
| 长度 | 16位 | UDP头部+数据的总长度 |
| 校验和 | 16位 | 可选,检测传输错误 |
UDP不建立连接,不保证可靠,没有拥塞控制。发送方把数据往网络里一丢,就不管了。接收方收到就收到,收不到也不会重传。
核心特点:
- 无连接:发送前不需要握手,省去了建立连接的开销
- 不可靠:不保证数据到达,不保证顺序,不保证不重复
- 面向报文:应用层给多少数据,UDP就原样发送,不拆分也不合并
- 低延迟:没有确认、重传、排序等机制,延迟极低
我在项目中遇到过一个问题:某视频监控系统偶尔会出现画面花屏。排查了半天,发现是UDP校验和没启用,导致数据在传输过程中被篡改。后来强制开启校验和,问题就解决了。所以别小看那16位的校验和,关键时刻能救命。
适用场景:
- 实时音视频通话(WebRTC、VoIP)
- 在线游戏(对延迟敏感,丢包可以容忍)
- DNS查询(一次请求一次响应,不需要连接)
- DHCP(客户端还没IP地址,没法建立TCP连接)
4.2 TCP协议详解
TCP和UDP完全是两个极端。TCP追求的是可靠,为了可靠,它愿意付出任何代价。
TCP头部比UDP复杂得多,最少20个字节:
| 字段 | 长度 | 说明 |
|---|---|---|
| 源端口 / 目的端口 | 各16位 | 标识通信进程 |
| 序列号 | 32位 | 保证数据有序到达 |
| 确认号 | 32位 | 期望收到的下一个序列号 |
| 数据偏移 | 4位 | 头部长度 |
| 标志位 | 6位 | URG/ACK/PSH/RST/SYN/FIN |
| 窗口大小 | 16位 | 流量控制用 |
| 校验和 | 16位 | 强制校验 |
| 紧急指针 | 16位 | URG标志位有效时使用 |
TCP的核心机制包括:
- 面向连接:通信前必须建立连接,通信后必须释放连接
- 可靠传输:通过序列号、确认、重传保证数据完整到达
- 流量控制:滑动窗口机制,防止发送方太快淹没接收方
- 拥塞控制:慢启动、拥塞避免、快重传、快恢复
- 全双工通信:双方可以同时发送和接收数据
注意:TCP的可靠是有代价的。每次发送数据都要等待确认,如果网络延迟高,吞吐量会急剧下降。我曾经优化过一个跨洋数据传输系统,TCP的吞吐量只有带宽的30%,后来改用UDP+应用层重传,才把利用率提上去。
4.3 TCP三次握手与四次挥手
这是面试必考题,也是理解TCP状态机的关键。
三次握手(建立连接)
为什么是三次?我换个角度给你讲。
第一次握手:客户端说「我要连你」
第二次握手:服务端说「好,我收到了,我也要连你」
第三次握手:客户端说「收到你的确认了,开始传数据吧」
你想想看,如果只有两次握手,服务端怎么知道客户端收到了自己的确认?万一第一次握手的SYN包在网络中延迟了,客户端重传了一个,等连接关闭后那个延迟的SYN包又到了,服务端就会误以为客户端要建立新连接,白白浪费资源。
三次握手就是为了解决这个「历史连接」问题。我见过一个线上故障,就是因为某个中间设备把SYN包缓存了,导致服务端不断收到重复的连接请求,最后连接数爆满。排查了整整一天才找到原因。
三次握手流程:
- 客户端发送SYN(seq=x),进入SYN_SENT状态
- 服务端回复SYN+ACK(seq=y, ack=x+1),进入SYN_RCVD状态
- 客户端发送ACK(seq=x+1, ack=y+1),进入ESTABLISHED状态
四次挥手(释放连接)
TCP是全双工的,所以每一方的连接都需要单独关闭。
第一次挥手:客户端发送FIN,说「我不发了」
第二次挥手:服务端回复ACK,说「知道了,但我还有数据要发」
第三次挥手:服务端发完数据后,发送FIN,说「我也发完了」
第四次挥手:客户端回复ACK,说「收到,关闭吧」
这里有个坑:客户端发送第四次挥手后,不会立即关闭,而是进入TIME_WAIT状态,等待2MSL(最大报文生存时间)。为什么?
因为最后一个ACK可能丢失。如果服务端没收到ACK,会重发FIN。客户端在TIME_WAIT期间可以重新发送ACK。我见过一个系统频繁出现端口被占用的问题,就是因为TIME_WAIT太多,端口耗尽了。后来调整了内核参数才解决。
避坑指南:我曾经在压测时发现大量TIME_WAIT连接,导致新连接无法建立。解决方案是开启net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle(注意:tcp_tw_recycle在NAT环境下有坑,慎用)。
4.4 TCP流量控制与拥塞控制
这两个概念经常被混淆,我帮你理清楚。
流量控制是端到端的,解决的是「接收方能吃多少」的问题。
拥塞控制是全局的,解决的是「网络能承受多少」的问题。
流量控制:滑动窗口
接收方在TCP头部告诉发送方自己的窗口大小(即接收缓冲区剩余空间)。发送方根据这个窗口大小调整发送速率,不能超过接收方的处理能力。
举个例子:接收方说「我窗口只有100字节了」,发送方就不能一口气发1000字节,否则接收方会丢弃多余的数据。
拥塞控制:四大算法
| 算法 | 触发条件 | 行为 |
|---|---|---|
| 慢启动 | 连接刚建立或超时重传 | 拥塞窗口从1开始,每收到一个ACK翻倍 |
| 拥塞避免 | 拥塞窗口达到慢启动阈值 | 每收到一个ACK,窗口增加1/cwnd |
| 快重传 | 收到3个重复ACK | 立即重传丢失的报文,不等超时 |
| 快恢复 | 快重传后 | 将慢启动阈值设为当前窗口的一半,窗口设为阈值 |
我在优化一个CDN系统时发现,慢启动阶段太慢,导致短连接性能很差。后来启用了TCP Fast Open,在SYN包中就携带数据,减少了握手开销。效果立竿见影。
4.5 端口与Socket
端口是传输层的概念,用来标识主机上的不同进程。IP地址+端口号,就构成了一个Socket(套接字)。
端口号范围:
- 0-1023:知名端口,如HTTP(80)、HTTPS(443)、SSH(22)
- 1024-49151:注册端口,供应用程序使用
- 49152-65535:动态/私有端口,客户端临时使用
Socket是应用层与传输层之间的接口。在Linux中,一切皆文件,Socket也不例外。你可以用socket()创建,用bind()绑定端口,用listen()监听,用accept()接受连接。
Socket编程核心API:
// 创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定端口
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
// 监听
listen(sockfd, 10);
// 接受连接
int clientfd = accept(sockfd, NULL, NULL);
我建议你动手写一个简单的TCP回显服务器,把三次握手、流量控制、Socket编程串起来。纸上得来终觉浅,绝知此事要躬行。
传输层的内容就讲到这里。UDP和TCP各有各的战场,没有谁比谁更高级,只有谁更适合。选对协议,比优化协议更重要。