万字长文:从三次握手的“为什么”到内核 sk_buff 的流动,彻底说透 UDP、TCP 与 HTTP
前言:你理解的“TCP可靠、UDP不可靠”,可能只触及了冰山一角
“TCP 是可靠的,UDP 是不可靠的”——这句话几乎所有开发者都能脱口而出。但当你线上服务的接口突然超时飙高、下载速度莫名骤降、游戏画面频繁卡顿,你真的能说清楚问题出在握手阶段、滑动窗口、拥塞控制,还是零窗口探测吗?
大多数开发者的网络协议认知停留在这句“八股文”层面。遇到超时、丢包、拥塞时,只能盲目地改改超时时间、调调缓冲区大小,运气好解决了,运气不好就陷入“重启试试”的玄学循环。
真正的底层认知,决定了你排查问题的效率上限。这篇文章,我们从 UDP 的 8 字节首部一路拆到 TCP 的滑动窗口与拥塞控制,再到 HTTP 从 1.1 到 3.0 的演进逻辑,最后深入 Linux 内核看一眼数据包的全生命周期。读完你会理解:为什么 UDP 没有发送缓冲区?TCP 为什么是三次握手而不是两次?TIME_WAIT 为什么要等 2MSL?HTTP/3 为什么要“抛弃”TCP?
一、网络协议栈全景:四层模型中,HTTP、TCP、UDP 各司其职
在深入拆解之前,先建立全局视图。现代网络通信基于TCP/IP 四层模型:
| 层级 | 协议示例 | 核心职责 |
|---|---|---|
| 应用层 | HTTP、DNS、RTP | 为用户提供具体服务,定义数据格式与语义 |
| 传输层 | TCP、UDP | 端到端通信,提供端口寻址与传输控制 |
| 网络层 | IP | 跨网络的数据包路由与寻址 |
| 数据链路层 | Ethernet | 物理介质上的帧传输 |
数据发送时,应用层数据(如 HTTP 报文)逐层向下封装:传输层加上 TCP/UDP 首部、网络层加上 IP 首部、数据链路层加上帧首部。接收时则反向逐层解封装。
HTTP 是应用层协议,它“坐”在 TCP 或 QUIC(基于 UDP)之上;TCP 和 UDP 是传输层协议,它们“坐”在 IP 之上。理解这个层次关系,是后续一切分析的基础。
二、UDP 协议深度拆解:8 字节的极简主义
2.1 核心特性
UDP(User Datagram Protocol)的设计目标是最小化开销。它的三个核心特性决定了它的“性格”:
无连接:发送数据前不需要建立连接,直接发——像寄信,写完就投递,不管对方在不在。
不可靠:不保证数据到达,不保证顺序,丢包了就丢了——没有重传,没有确认。
面向报文:应用层交多少数据,UDP 就原封不动发多少,不会拆分也不会合并。
2.2 8 字节固定首部结构
UDP 的首部极其精简,固定8 字节,无选项字段:
text
0 7 8 15 16 23 24 31 +--------+--------+--------+--------+ | 源端口号 | 目的端口号 | +--------+--------+--------+--------+ | UDP 长度 | UDP 校验和 | +--------+--------+--------+--------+
| 字段 | 长度 | 说明 |
|---|---|---|
| 源端口号 | 16 bit | 发送方端口,0 表示不关心回复 |
| 目的端口号 | 16 bit | 接收方端口 |
| UDP 长度 | 16 bit | 整个报文长度(首部 8 字节 + 数据),最小 8,最大 65535 |
| UDP 校验和 | 16 bit | 可选,IPv6 下必须计算 |
Linux 内核中用struct udphdr结构体描述这个首部。
2.3 内核收发路径与“无发送缓冲区”
UDP没有发送缓冲区,但有接收缓冲区。这意味着:
发送方:应用层调用
sendto()后,内核直接封装成 UDP 数据报交给 IP 层。如果网卡繁忙,数据可能直接在协议栈中丢弃——没有排队重试机制。接收方:接收缓冲区用于暂存数据,等待应用层读取。但如果缓冲区满了,新来的数据会被直接丢弃——这再次体现了 UDP 的“不可靠”。
2.4 典型应用场景
UDP 的极简设计让它成为实时性优先场景的首选:
实时音视频(RTP):宁可丢一帧画面,也不能等重传造成卡顿
DNS 查询:一次查询一个响应,简单高效
在线游戏:位置同步等高频小包,延迟敏感
QUIC(HTTP/3 的底层):在 UDP 之上实现了可靠的流式传输
三、TCP 协议深度拆解(重头戏)
如果说 UDP 是“极简主义”,那 TCP 就是“精密仪器”。TCP 的设计目标是在不可靠的 IP 网络之上提供可靠的字节流传输。
3.1 20 字节固定首部 + 可变选项
TCP 首部至少20 字节,最多 60 字节(带选项时):
text
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 源端口号 | 目的端口号 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 序列号 (32 bit) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 确认号 (32 bit) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 首部长度 | 保留 |U|A|P|R|S|F| 窗口大小 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 校验和 | 紧急指针 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 选项 (可选,最多40字节) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
核心字段解读:
序列号(32 bit):当前报文第一个字节的序号,用于按序重组和丢包检测
确认号(32 bit):期望收到的下一个字节序号,告诉发送方“这之前的我都收到了”
首部长度(4 bit):单位是 32-bit 字,最小 5(即 20 字节),最大 15(60 字节)
标志位(6 bit):URG、ACK、PSH、RST、SYN、FIN——连接管理的“指令集”
窗口大小(16 bit):接收方通告的可用缓冲区大小,最大 65535,可通过窗口缩放选项扩展
为什么 TCP 需要 20 字节而 UDP 只要 8 字节?序列号、确认号、窗口、标志位——每一项都是 TCP 承诺“不丢数据、不乱顺序、不超对方承受能力”的代价。
3.2 三次握手:为什么不是两次?
TCP 建立连接需要三次握手:
第一次(SYN):客户端 → 服务器,SYN=1,携带初始序列号
client_isn第二次(SYN+ACK):服务器 → 客户端,SYN=1,ACK=1,携带服务器的初始序列号
server_isn,确认号 =client_isn + 1第三次(ACK):客户端 → 服务器,ACK=1,确认号 =
server_isn + 1
关键问题:为什么不是两次握手?
两次握手无法解决历史连接问题。假设客户端发送的 SYN 报文在网络中滞留,客户端超时重传后建立了新连接并完成了数据传输。此时旧 SYN 报文“迟到”到达服务器,服务器以为这是一个新连接请求,回复 SYN+ACK——如果只有两次握手,服务器会认为连接已建立并开始分配资源,但实际上客户端根本不认这个“幽灵连接”。第三次握手的核心作用就是让客户端有机会拒绝这种历史重复请求。
初始序列号(ISN)不是从 0 开始的,而是一个 32 位计数器,每 4 微秒加 1,约 4.55 小时循环一次——这是为了防止序列号重叠。
3.3 四次挥手与 TIME_WAIT
TCP 断开连接需要四次挥手:
主动关闭方发送 FIN
被动关闭方回复 ACK
被动关闭方发送 FIN
主动关闭方回复 ACK
为什么挥手要四次而握手只要三次?因为 TCP 是全双工的——握手时 SYN 和 ACK 可以合并(SYN+ACK),但挥手时被动关闭方收到 FIN 后,可能还有数据要发送,所以 ACK 和 FIN 必须分开发。
TIME_WAIT 为什么是 2MSL?主动关闭方收到对方的 FIN 并回复 ACK 后,进入 TIME_WAIT 状态,等待2MSL(Maximum Segment Lifetime,最大报文生存时间)后才彻底关闭。两个原因:
保证最后的 ACK 能被对方收到:如果 ACK 丢失,对方会重传 FIN,TIME_WAIT 确保能收到并重发 ACK
让旧连接的所有报文在网络中消失:防止旧连接的“迟到报文”被新连接误认为是有效数据
3.4 可靠传输机制
TCP 的可靠性靠一套精密的机制保证。
滑动窗口
没有滑动窗口时,每条数据都要等 ACK 才能发下一条,效率极低。滑动窗口允许发送方在收到 ACK 之前连续发送窗口大小以内的数据:
发送窗口= 已发送未确认的数据范围
接收窗口= 接收方可用的缓冲区大小
收到一个 ACK,窗口就“滑动”一次,继续发送下一条数据
窗口大小由接收方根据自身处理能力动态通告。
RTO 与超时重传
发送方为每个报文启动重传计时器,超时未收到 ACK 则重传。RTO(Retransmission Timeout)的计算至关重要:
太小 → 不必要的重传,浪费带宽
太大 → 增加延迟
RTO 基于加权平均 RTT计算,且每次重传后超时时间会翻倍(指数退避)。
快速重传与 SACK
如果只靠超时重传,丢包检测太慢。快速重传优化了这一点:发送方收到3 个重复的 ACK时,立即重传丢失的报文,不用等超时。
但快速重传有个问题:如果有多个报文丢失,它不知道具体丢的是哪些。SACK(Selective Acknowledgment,选择性确认)解决了这个问题——接收方在 ACK 中明确告知“我收到了哪些乱序数据块”,发送方只重传真正丢失的部分。
3.5 流量控制:零窗口探测
流量控制的核心是让发送方不要发得太快,以免接收方来不及处理。
接收方通过窗口大小字段通告自己的可用缓冲区。当接收方缓冲区满了,窗口大小变为0,发送方停止发送。
但如果这个“零窗口”通告丢失了呢?发送方等待窗口更新,接收方等待数据——死锁!TCP 用零窗口探测打破僵局:
发送方启动持续计时器
超时后发送零窗口探测报文
如果窗口仍为 0,重置计时器继续等待
如果窗口已恢复,继续发送数据
探测次数一般为 3 次
3.6 拥塞控制:慢启动、拥塞避免、快重传与快恢复
流量控制关注的是端到端(接收方能不能收得过来),拥塞控制关注的是网络(网络能不能承载这么多数据)。
TCP 拥塞控制有四个经典算法:
① 慢启动(Slow Start)
刚建立连接时,发送方不知道网络带宽有多少,从1 个 MSS开始,每收到一个 ACK,拥塞窗口(cwnd)翻倍——指数增长,直到达到慢启动阈值(ssthresh)。
② 拥塞避免(Congestion Avoidance)
达到 ssthresh 后,从指数增长切换到线性增长:每经过一个 RTT,cwnd 加 1——慢下来,避免一下子撑爆网络。
③ 快重传(Fast Retransmit)
收到 3 个重复 ACK 时立即重传(前面已讲),同时将 ssthresh 减半,cwnd 设为 ssthresh + 3,进入快恢复。
④ 快恢复(Fast Recovery)
快重传之后不回到慢启动,而是直接进入拥塞避免——因为收到 3 个重复 ACK 说明网络仍然能传输数据,只是丢了几个包,不需要从头慢启动。
现代 Linux 内核支持多种拥塞控制算法,默认通常是CUBIC(基于丢包的算法),Google 的BBR(基于带宽和延迟的算法)也在广泛使用。
四、HTTP 协议演进:从文本到二进制,从 TCP 到 QUIC
HTTP 是应用层协议,它的每一次演进都对应着底层传输机制的优化需求。
4.1 HTTP/1.1:文本协议与队头阻塞
HTTP/1.1 采用纯文本格式传输,报文结构清晰:
text
GET /index.html HTTP/1.1 Host: example.com User-Agent: Mozilla/5.0 (空行) (响应体)
核心痛点:
队头阻塞(Head-of-Line Blocking):虽然引入了 Keep-Alive 长连接,但请求必须按顺序响应——前一个请求是大文件,后面的小请求就得排队等
同一域名仅支持 6-8 个并发 TCP 连接,多资源页面需排队加载
头部冗余:每次请求都携带完整头部,重复传输
4.2 HTTP/2:二进制分帧与多路复用
HTTP/2 在应用层做了彻底重构:
二进制分帧:将所有数据拆分为帧(Frame),替代纯文本
多路复用:同一 TCP 连接上,多个请求/响应通过流 ID标识,可并行传输、乱序响应——解决了 HTTP 层的队头阻塞
HPACK 头部压缩:用静态表 + 动态表压缩重复头部,压缩率可达 50%-70%
但 HTTP/2 有个“隐形天花板”:它仍然跑在 TCP 上。一旦 TCP 层发生丢包,整个连接的所有流都会被阻塞——这是TCP 层的队头阻塞。
4.3 HTTP/3:基于 UDP 的 QUIC 协议
HTTP/3 彻底抛弃了 TCP,采用基于UDP的QUIC协议。
QUIC 的核心优势:
| 优势 | 说明 |
|---|---|
| 0-RTT 快速建连 | 首次 1-RTT,后续 0-RTT(复用会话票据) |
| 无队头阻塞多路复用 | 每个流独立——单流丢包不影响其他流 |
| 连接迁移 | 基于 Connection ID 而非四元组,Wi-Fi 切 4G 不断连 |
| 内置 TLS 1.3 | 加密是协议的一部分,不是附加层 |
HTTP/3 的革新本质上是将可靠传输的控制权从操作系统内核(TCP)上移到用户态(QUIC),带来了更大的灵活性和更快的迭代速度。
五、横向对比与内核视图
5.1 TCP vs UDP 对比
| 维度 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠(确认 + 重传) | 不可靠,丢包不重传 |
| 传输方式 | 面向字节流 | 面向报文 |
| 首部大小 | 20-60 字节 | 8 字节(固定) |
| 发送缓冲区 | 有 | 无 |
| 流量控制 | 滑动窗口 + 零窗口探测 | 无 |
| 拥塞控制 | 慢启动 + 拥塞避免等 | 无 |
| 系统资源 | 高(维护连接状态) | 低 |
| 典型场景 | Web、文件传输、邮件 | 音视频、DNS、游戏 |
5.2 一次 HTTP 请求在内核中的数据流动
以一次 HTTP GET 请求为例,数据在 Linux 内核中的完整路径:
应用层:浏览器构造 HTTP 请求报文
Socket 层:通过 Socket 写入内核的TCP 发送缓冲区
传输层(TCP):TCP 将数据分段(MSS 大小),加上 TCP 首部
网络层(IP):加上 IP 首部,查询路由表决定从哪个网卡发出
数据链路层:加上 Ethernet 首部,封装成帧
网卡驱动:将帧写入ring buffer,通过 DMA 交给网卡硬件发送
接收路径相反:
网卡:DMA 将帧写入内存,触发硬中断通知 CPU
软中断:
ksoftirqd内核线程处理,逐层解封装IP 层:重组 IP 分片
TCP 层:按序重组、ACK 确认、放入接收缓冲区
Socket 层:应用层通过
read()读取数据
整个过程中,Socket 缓冲区是用户态与内核态的数据交换枢纽,sk_buff结构体是内核中描述每个报文的核心数据结构。
六、实战调优与总结
6.1 TCP 常见优化手段
①tcp_tw_reuse:复用 TIME_WAIT 端口
TIME_WAIT 占着端口不释放,高并发下可能导致端口耗尽。net.ipv4.tcp_tw_reuse=1允许将 TIME_WAIT 状态的 socket 用于新连接。注意:仅在客户端(发起连接方)有效,且需要tcp_timestamps开启。tcp_tw_recycle已不推荐使用,在 NAT 环境下会引发问题。
②TCP_NODELAY:禁用 Nagle 算法
Nagle 算法会延迟小包的发送以合并成更大的报文,但这对低延迟交互(如游戏、实时通信)是灾难。设置TCP_NODELAY=1禁用 Nagle,让数据立即发送。代价是网络中可能出现更多小包。
③ 调整缓冲区大小
高带宽长距离网络(高 BDP)中,默认的缓冲区可能不够大。调整net.core.rmem_max、net.core.wmem_max和net.ipv4.tcp_rmem、net.ipv4.tcp_wmem可以提升吞吐量。
④ 调整监听队列
net.core.somaxconn控制 listen 队列最大长度,高并发场景下默认 128 可能不够。
6.2 HTTP 优化思路
启用HTTP/2多路复用减少连接数
在弱网环境考虑HTTP/3(QUIC)降低延迟
使用TLS 会话复用减少握手开销
合理配置Keep-Alive超时,平衡连接复用与资源释放
写在最后
从 UDP 的 8 字节首部到 TCP 的滑动窗口与拥塞控制,再到 HTTP 从 1.1 到 3.0 的演进——每一层协议的设计都是对“如何在不可靠网络上可靠、高效地传输数据”这一问题的回答。
理解这些底层机制,不是让你去改写内核,而是让你在面对超时、丢包、延迟抖动时,知道从哪里下手排查——是握手阶段的问题?是滑动窗口太小?是拥塞控制触发了?还是应用层的队头阻塞?
文章篇幅有限,更深入的内核源码剖析和真实案例调优已打包成专栏资料。
🔥 福利领取方式
如果这篇文章帮你打通了网络协议的“任督二脉”,欢迎点赞 + 在看 + 转发,让更多被网络问题困扰的开发者看到。
