4、传输层协议详解:TCP与UDP
传输层是整个TCP/IP协议栈的“心脏”。它负责为上层应用提供端到端的通信服务。说白了,网络层只负责把数据包送到主机,而传输层才真正决定把数据交给哪个进程。
这一层有两个核心协议:TCP 和 UDP。一个可靠但慢,一个快但不可靠。我做了十几年网络,见过太多因为选错协议而翻车的案例。咱们今天就把它们彻底讲透。
核心认知:TCP提供可靠、面向连接的字节流服务;UDP提供不可靠、无连接的数据报服务。选谁,取决于你的业务场景。
4.1 TCP协议详解
TCP(Transmission Control Protocol)是传输层的“老大哥”。它提供的是可靠的、面向连接的、基于字节流的通信。你想想看,如果下载文件时丢了一个包,整个文件就坏了,这谁能忍?所以TCP就是为了解决这个问题而生的。
4.1.1 三次握手:建立连接
TCP是面向连接的协议。通信之前,双方必须先建立连接。这个建立过程就是著名的三次握手。
我记得刚入行时,面试官最爱问这个问题。其实它没那么玄乎,就是双方互相确认“我准备好了,你准备好了吗?”的过程。
我的经验:排查连接问题时,我习惯先用 tcpdump 抓包看三次握手是否完成。如果只有 SYN 没有 SYN-ACK,八成是防火墙或对端端口没开。
三次握手的流程如下:
- 客户端发送 SYN:客户端选择一个初始序列号(ISN),发送 SYN 报文,进入 SYN_SENT 状态。
- 服务端回复 SYN-ACK:服务端收到 SYN,分配资源,回复 SYN-ACK,确认号 = 客户端 ISN + 1,进入 SYN_RCVD 状态。
- 客户端发送 ACK:客户端收到 SYN-ACK,回复 ACK,确认号 = 服务端 ISN + 1,进入 ESTABLISHED 状态。服务端收到 ACK 后也进入 ESTABLISHED。
为什么要三次?两次不行吗?我简单解释一下:两次握手无法防止已失效的连接请求突然传到服务端。比如客户端发了 SYN,网络延迟了,客户端重发 SYN 并成功建立连接,传输完数据后关闭。结果第一个 SYN 突然到了,服务端以为新连接来了,就白白浪费资源。三次握手通过最后一次 ACK 来确认客户端确实“活着”。
4.1.2 四次挥手:断开连接
有连接就有断开。TCP的断开过程叫四次挥手。为什么是四次?因为TCP是全双工的,双方都要单独关闭自己的发送通道。
我曾经帮一个同事排查“连接卡在 CLOSE_WAIT”的问题。他写了一个服务端程序,客户端断开后服务端没调用 close(),结果连接数暴涨,最后 OOM 了。嗯,这就是典型的四次挥手没走完。
四次挥手的流程:
- 主动方发送 FIN:主动关闭方发送 FIN 报文,进入 FIN_WAIT_1 状态。
- 被动方回复 ACK:被动方收到 FIN,回复 ACK,进入 CLOSE_WAIT 状态。主动方收到 ACK 后进入 FIN_WAIT_2。
- 被动方发送 FIN:被动方数据发送完毕,发送 FIN,进入 LAST_ACK 状态。
- 主动方回复 ACK:主动方收到 FIN,回复 ACK,进入 TIME_WAIT 状态。被动方收到 ACK 后关闭。
注意:TIME_WAIT 状态会持续 2MSL(约 2 分钟)。这是为了确保最后一个 ACK 能被对方收到,防止旧连接的数据包干扰新连接。高并发短连接场景下,TIME_WAIT 过多会耗尽端口资源。我曾经在线上遇到过这个问题,解决方案是开启 net.ipv4.tcp_tw_reuse 和 tcp_tw_recycle(但要注意 NAT 环境)。
4.1.3 滑动窗口与流量控制
TCP的滑动窗口机制,说白了就是控制发送速度,防止接收方来不及处理。接收方会在 ACK 中告诉发送方:“我的接收窗口还剩多少字节”。发送方根据这个窗口大小来调整发送量。
我习惯把滑动窗口想象成一个传送带。接收方说“我一次只能处理 10 个包裹”,发送方就只发 10 个。等接收方处理完了,再发下一批。
这里有个关键点:窗口大小是动态变化的。接收方应用程序读取数据越快,窗口就越大。如果应用程序读得慢,窗口就会缩小,甚至变成 0。这就是流量控制的核心。
4.1.4 拥塞控制
流量控制是“点对点”的,而拥塞控制是“端到端”的。它要解决的是:网络中间设备(路由器、交换机)扛不住了怎么办?
TCP的拥塞控制有四个核心算法:
| 算法 | 触发条件 | 行为 |
|---|---|---|
| 慢启动 | 连接刚建立 / 超时重传后 | cwnd 从 1 开始,每收到一个 ACK 翻倍(指数增长),直到 ssthresh |
| 拥塞避免 | cwnd ≥ ssthresh | 每收到一个 ACK,cwnd 增加 1/cwnd(线性增长) |
| 快重传 | 收到 3 个重复 ACK | 立即重传丢失的报文,不等超时 |
| 快恢复 | 快重传后 | ssthresh = cwnd/2,cwnd = ssthresh + 3,进入拥塞避免 |
避坑指南:我曾经在调优一个文件传输服务时,发现带宽利用率只有 30%。抓包一看,每次丢包都触发超时重传,cwnd 直接降到 1。后来启用了 TCP 的 SACK(选择性确认)和 FACK,快重传生效,带宽利用率直接拉到 85%。
4.2 UDP协议详解
UDP(User Datagram Protocol)和 TCP 完全是两个极端。它无连接、不可靠、不保证顺序。发送方只管把数据报扔出去,收不收得到、顺序对不对,它不管。
你可能会问:这种协议有什么用?其实用处大了。想想看,视频通话时丢一帧画面,你根本感觉不到。但如果用 TCP,丢包后重传,反而导致延迟飙升,画面卡死。所以实时性要求高的场景,UDP 是首选。
4.2.1 UDP的特点
- 无连接:不需要握手,直接发数据。延迟低。
- 不可靠:不保证送达,不保证顺序,不保证不重复。
- 面向报文:应用层交给 UDP 多大的报文,UDP 就原样发送,不拆分也不合并。
- 头部开销小:UDP 头部只有 8 字节(源端口、目的端口、长度、校验和),而 TCP 头部至少 20 字节。
4.2.2 UDP的应用场景
| 应用场景 | 典型协议 | 为什么用 UDP |
|---|---|---|
| 域名解析 | DNS | 查询响应快,一次请求一次响应,丢包重试即可 |
| 动态主机配置 | DHCP | 客户端没有 IP 时无法建立 TCP 连接,只能用 UDP 广播 |
| 实时音视频 | RTP / WebRTC | 允许丢包,但不能容忍延迟 |
| 网络管理 | SNMP | 轻量级,偶尔丢包不影响整体监控 |
| 在线游戏 | 自定义 UDP | 玩家位置更新必须实时,丢一帧无所谓 |
我的建议:如果你在做一个新项目,不确定用 TCP 还是 UDP,先问自己一个问题:“我能接受偶尔丢包吗?” 如果能,用 UDP;如果不能,用 TCP。如果既要可靠又要低延迟,可以考虑在 UDP 之上自己实现可靠机制(比如 QUIC 协议就是这么干的)。
4.3 端口号与Socket编程基础
端口号是传输层的“门牌号”。IP 地址把数据包送到主机,端口号把数据交给具体的进程。一个端口号是 16 位的,范围 0~65535。
端口号分为三类:
- 知名端口(0~1023):HTTP(80)、HTTPS(443)、FTP(21)、SSH(22) 等。需要 root 权限才能绑定。
- 注册端口(1024~49151):公司注册的端口,比如 MySQL(3306)、Redis(6379)。
- 动态/私有端口(49152~65535):客户端临时使用的端口,由操作系统自动分配。
Socket 编程,说白了就是操作传输层的 API。服务端创建一个 Socket,绑定端口,监听连接;客户端创建一个 Socket,连接服务端。数据通过 Socket 读写。
我刚开始学 Socket 编程时,踩过一个坑:服务端没有设置 SO_REUSEADDR,程序崩溃重启时提示“Address already in use”。因为 TIME_WAIT 状态的连接还占着端口。加上这个选项后,问题解决。
下面是一个简单的 TCP Socket 编程示例(伪代码):
// 服务端
int server_fd = 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(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 5); // 最大等待队列长度
int client_fd = accept(server_fd, NULL, NULL);
char buffer[1024];
recv(client_fd, buffer, sizeof(buffer), 0);
send(client_fd, "Hello", 5, 0);
close(client_fd);
close(server_fd);
// 客户端
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
send(client_fd, "World", 5, 0);
char buffer[1024];
recv(client_fd, buffer, sizeof(buffer), 0);
close(client_fd);
注意:生产环境中的 Socket 编程要处理很多边界情况:非阻塞 I/O、多线程/多进程、信号处理、缓冲区溢出、连接超时等。上面的示例只是演示基本流程,千万别直接用到线上。
UDP 的 Socket 编程更简单,不需要 listen 和 accept,直接用 sendto() 和 recvfrom() 收发数据。因为没有连接,所以每次发送都要指定目标地址。
好了,传输层的核心内容就这些。TCP 的可靠机制、UDP 的轻量特性、端口号的作用、Socket 的基本用法,都是你以后排查网络问题、开发网络应用的基础。多抓包、多实践,慢慢就会形成直觉。