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包缓存了,导致服务端不断收到重复的连接请求,最后连接数爆满。排查了整整一天才找到原因。

三次握手流程:

  1. 客户端发送SYN(seq=x),进入SYN_SENT状态
  2. 服务端回复SYN+ACK(seq=y, ack=x+1),进入SYN_RCVD状态
  3. 客户端发送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协议 无连接 不可靠传输 低延迟 应用:DNS/音视频 面向连接 可靠传输 流量控制 拥塞控制 端口与Socket IP + Port = Socket 进程间通信的端点

传输层的内容就讲到这里。UDP和TCP各有各的战场,没有谁比谁更高级,只有谁更适合。选对协议,比优化协议更重要。