4、传输层协议:TCP协议特性与报文结构、三次握手与四次挥手、TCP流量控制与拥塞控制、UDP协议特性与应用场景、端口与Socket编程基础

传输层是整个网络协议栈的「腰部」。你想想看,网络层帮你把数据包从一台机器扔到另一台机器,但两台机器上跑着几十个进程,数据该交给谁?这就是传输层要解决的核心问题——端到端通信。

这一层有两个主角:TCP 和 UDP。一个稳如老狗,一个快如闪电。我个人习惯把 TCP 比作「顺丰快递」——有回执、有重发、有流量控制;UDP 就是「飞鸽传书」——扔出去就不管了,能不能到看缘分。

4.1 TCP协议特性与报文结构

TCP 的全称是 Transmission Control Protocol,翻译过来就是「传输控制协议」。它最核心的特性就三个字:可靠。

什么叫可靠?我举个例子。你在浏览器里输入一个网址,网页上的文字、图片、视频必须完整无缺地到达你的电脑。如果中间丢了一个字节,页面可能就花屏了,或者干脆打不开。TCP 就是干这个的——保证数据按序、无差错、不重复地到达对端。

TCP 的报文结构,说白了就是数据包长什么样。我刚开始学的时候觉得这玩意儿就是一堆二进制,后来在项目里抓包分析才发现,每个字段都有它的设计逻辑。

字段 长度 作用
源端口 / 目的端口 各 16 位 标识发送方和接收方的进程
序列号 (SEQ) 32 位 标记数据流的字节序号,用于排序和去重
确认号 (ACK) 32 位 期望收到的下一个序列号,表示之前的数据已收到
数据偏移 4 位 指示 TCP 头部的长度
标志位 9 位 SYN、ACK、FIN、RST 等,控制连接状态
窗口大小 16 位 流量控制用的,告诉对方「我还能收多少」
校验和 16 位 检测报文是否在传输中被篡改或损坏
紧急指针 16 位 配合 URG 标志位使用,标记紧急数据位置
我的小技巧:抓包的时候,重点关注序列号和确认号的变化。你只要盯着这两个字段,就能把 TCP 的「对话」过程看得一清二楚。我曾经用 Wireshark 排查过一个丢包问题,就是靠序列号跳变定位到的。

4.2 三次握手与四次挥手

这是面试必考题,也是实际排障的硬功夫。三次握手建立连接,四次挥手断开连接。为什么是三次?为什么不是两次?为什么挥手要四次?

咱们先看三次握手的流程:

  1. 客户端发 SYN:「嘿,我想跟你说话,这是我的初始序列号 x。」
  2. 服务端回 SYN + ACK:「收到,这是我的初始序列号 y,我确认收到了你的 x。」
  3. 客户端发 ACK:「好,我确认收到了你的 y,咱们开始聊吧。」

为什么是三次?说白了,就是为了防止「历史连接」干扰。如果只握手两次,服务端收到一个延迟的旧 SYN 包,就会误以为客户端想建立新连接,白白浪费资源。三次握手让服务端能确认「客户端确实活着,而且确实想连我」。

四次挥手的过程类似,但更复杂一点:

  1. 主动方发 FIN:「我说完了,准备关闭。」
  2. 被动方回 ACK:「知道了,但我还有话没说完。」
  3. 被动方发 FIN:「我也说完了,可以关了。」
  4. 主动方回 ACK:「好,确认关闭。」
我曾经踩过的坑:有一次线上服务出现大量 CLOSE_WAIT 状态的连接,查了半天发现是业务代码里忘了调用 close()。被动方收到 FIN 后,如果应用程序不主动关闭 socket,就会一直卡在 CLOSE_WAIT,最终导致端口耗尽。嗯,从那以后我写网络代码都会加一个超时关闭的兜底逻辑。

下面这张图把三次握手和四次挥手的过程画在一起,方便你对照着看:

TCP 三次握手与四次挥手流程图 客户端 服务端 三次握手 SYN=1, seq=x SYN=1, ACK=1, seq=y, ack=x+1 ACK=1, seq=x+1, ack=y+1 数据传输阶段(双方互发数据) 四次挥手 FIN=1, seq=u ACK=1, seq=v, ack=u+1 FIN=1, ACK=1, seq=w, ack=u+1 ACK=1, seq=u+1, ack=w+1 CLOSED → SYN_SENT → ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED CLOSED → LISTEN → SYN_RCVD → ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED

4.3 TCP流量控制与拥塞控制

这两个概念经常被搞混。我简单区分一下:

  • 流量控制:解决「接收方吃不下」的问题。接收方告诉发送方「我缓冲区快满了,你慢点发」。
  • 拥塞控制:解决「网络中间设备扛不住」的问题。发送方自己判断「网络好像堵了,我得降速」。

流量控制靠的是滑动窗口机制。接收方在 TCP 头部的「窗口大小」字段里填上自己还能收多少字节,发送方就按这个量发。如果窗口变成 0,发送方就停手,但会定期发一个「窗口探测」包,看看接收方缓过来没有。

拥塞控制有四个经典算法:

算法 触发时机 行为
慢启动 连接刚建立或超时重传后 拥塞窗口从 1 开始,每收到一个 ACK 翻倍,指数增长
拥塞避免 窗口达到慢启动阈值 (ssthresh) 每收到一个 ACK 窗口 +1,线性增长
快重传 收到 3 个重复 ACK 不等超时,立即重传丢失的报文
快恢复 快重传之后 ssthresh 减半,拥塞窗口设为 ssthresh,进入拥塞避免
核心要点:流量控制是「接收方说了算」,拥塞控制是「发送方自己猜」。两者协同工作,才能让 TCP 既快又稳。我在调优一个视频流服务时,就是通过调整接收窗口大小和拥塞控制算法,把吞吐量提升了 30%。

4.4 UDP协议特性与应用场景

UDP 和 TCP 是传输层的「双子星」,但性格截然相反。UDP 的全称是 User Datagram Protocol,用户数据报协议。它没有连接、没有确认、没有重传、没有流量控制——说白了就是「裸奔」。

UDP 的报文结构比 TCP 简单得多:

0                   16                  32
+-------------------+-------------------+
|     源端口        |     目的端口       |
+-------------------+-------------------+
|      长度         |      校验和        |
+-------------------+-------------------+
|              数据(可变长度)           |
+---------------------------------------+

就 8 字节头部,加上数据,完事。没有序列号,没有确认号,没有窗口。所以 UDP 的延迟极低,但不可靠。

那 UDP 有什么用?我列几个典型场景:

  • DNS 查询:一个请求一个响应,丢了就重发,用 TCP 反而浪费。
  • 视频直播 / 语音通话:丢一帧画面没关系,但延迟高了就卡顿,用户受不了。
  • 游戏:玩家的位置信息需要实时同步,偶尔丢一个包不影响整体体验。
  • DHCP:客户端连 IP 都没有,没法建立 TCP 连接,只能用 UDP 广播。
我的经验:如果你在做一个实时性要求高的应用,优先考虑 UDP。但别忘了在应用层自己实现「轻量级可靠机制」——比如序列号校验和超时重传。我做过一个物联网数据采集项目,就是用 UDP + 应用层 ACK,既保证了实时性,又避免了 TCP 的队头阻塞问题。

4.5 端口与Socket编程基础

端口是什么?说白了就是「门牌号」。IP 地址找到了你家,端口号找到了你家的哪个房间。一个 IP 地址上有 65536 个端口(0-65535),其中 0-1023 是知名端口,比如 HTTP 用 80,HTTPS 用 443,SSH 用 22。

Socket 是操作系统提供给应用程序的「网络编程接口」。你可以把它理解成一个「文件描述符」——打开一个 socket,就像打开一个文件,往里写数据就是发送,往外读数据就是接收。

一个典型的 TCP Socket 编程流程是这样的:

// 服务端
int server_fd = socket(AF_INET, SOCK_STREAM, 0);  // 创建 socket
bind(server_fd, &addr, sizeof(addr));              // 绑定端口
listen(server_fd, 5);                              // 开始监听
int client_fd = accept(server_fd, NULL, NULL);     // 接受连接
recv(client_fd, buffer, sizeof(buffer), 0);        // 接收数据
send(client_fd, response, strlen(response), 0);    // 发送数据
close(client_fd);                                  // 关闭连接

// 客户端
int sock = socket(AF_INET, SOCK_STREAM, 0);        // 创建 socket
connect(sock, &addr, sizeof(addr));                 // 连接服务端
send(sock, request, strlen(request), 0);            // 发送数据
recv(sock, buffer, sizeof(buffer), 0);              // 接收数据
close(sock);                                        // 关闭连接

UDP 的 Socket 编程更简单,不需要 connect 和 listen:

// 服务端
int sock = socket(AF_INET, SOCK_DGRAM, 0);          // 创建 UDP socket
bind(sock, &addr, sizeof(addr));                    // 绑定端口
recvfrom(sock, buffer, sizeof(buffer), 0, &client_addr, &addr_len);  // 接收
sendto(sock, response, strlen(response), 0, &client_addr, addr_len);  // 发送

// 客户端
int sock = socket(AF_INET, SOCK_DGRAM, 0);          // 创建 UDP socket
sendto(sock, request, strlen(request), 0, &server_addr, addr_len);    // 发送
recvfrom(sock, buffer, sizeof(buffer), 0, NULL, NULL);                // 接收
我曾经踩过的坑:写 TCP 服务端时,一定要处理 accept 返回的客户端 fd。有一次我忘了把客户端 fd 放到事件循环里,结果服务端只能处理一个连接,后面的客户端全卡住了。嗯,从那以后我写网络服务必用 epoll 或 select 做多路复用。

端口和 Socket 的关系,可以用一句话总结:端口是逻辑概念,Socket 是编程接口。你通过 Socket 绑定到某个端口,操作系统就知道进来的数据该交给哪个进程。

最后说一个实用技巧:用 netstat -anp 或 ss -tuln 查看本机端口占用情况。排查端口冲突时,这两个命令能救命。