TCP/IP 协议栈 — 每层头部与帧结构
#网络 · #网络协议 · #TCP/IP · #协议栈 · #封装 · #帧结构
理解网络协议栈,最直观的方式是看每一层的"包结构"——从以太网帧到 IP 包,再到 TCP/UDP 段,最后到 HTTP 消息。本文将逐层展示头部格式和封装过程。
1. 封装与解封 — 数据如何穿越协议栈
mermaid
flowchart TB
subgraph Send["发送方 (封装)"]
S1["应用层: HTTP 数据"]
S2["传输层: TCP Header + HTTP 数据"]
S3["网络层: IP Header + TCP 段"]
S4["链路层: Ethernet Header + IP 包 + FCS"]
end
S1 --> S2 --> S3 --> S4
subgraph Recv["接收方 (解封)"]
R4["链路层: 去 Ethernet Header, 校验 FCS"]
R3["网络层: 去 IP Header"]
R2["传输层: 去 TCP Header"]
R1["应用层: HTTP 数据"]
end
S4 -.->|"网络传输"| R4
R4 --> R3 --> R2 --> R1
style S1 fill:#2196F3,color:#fff
style S2 fill:#4CAF50,color:#fff
style S3 fill:#FF9800,color:#fff
style S4 fill:#9C27B0,color:#fff2. 以太网帧(数据链路层)
以太网帧格式 (IEEE 802.3):
0 6 12 14 帧尾
+--------+--------+--------+-------------------+--------+
| 目的MAC | 源MAC | 类型/长度 | 数据(IP包) | FCS |
| 6B | 6B | 2B | 46-1500 B | 4B |
+--------+--------+--------+-------------------+--------+
|←─────────────── 头部 14 字节 ──────────────→|| 字段 | 字节 | 说明 |
|---|---|---|
| 目的 MAC | 6 | 目标网卡物理地址 |
| 源 MAC | 6 | 发送方网卡物理地址 |
| EtherType | 2 | 0x0800 = IPv4, 0x0806 = ARP, 0x86DD = IPv6 |
| 数据 | 46-1500 | 最小 46 字节(不够填充),最大 1500(MTU) |
| FCS | 4 | CRC32 帧校验序列 |
VLAN 标签 (802.1Q):在源 MAC 和 EtherType 之间插入 4 字节。
2.1 ARP — 地址解析协议
问题: 我知道目标 IP (10.0.0.1),但不知道它的 MAC 地址?
ARP 请求 (广播):
谁有 10.0.0.1?请告诉 10.0.0.5
ARP 响应 (单播):
10.0.0.1 的 MAC 是 aa:bb:cc:dd:ee:ff3. IP 头部(网络层)
3.1 IPv4 头部(20-60 字节)
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version| IHL | DSCP/ECN | Total Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Identification |Flags| Fragment Offset |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Time to Live (TTL)| Protocol | Header Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options (if IHL > 5) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+| 字段 | 位数 | 说明 |
|---|---|---|
| Version | 4 | 4 = IPv4, 6 = IPv6 |
| IHL | 4 | 头部长度(×4 字节),最小 5 (=20B) |
| DSCP + ECN | 8 | 服务类型 + 拥塞通知 |
| Total Length | 16 | 整个 IP 包长度(含头部和数据) |
| Identification | 16 | 分片标识(同一原始包的片段有相同 ID) |
| Flags | 3 | bit0=保留, bit1=DF(不分片), bit2=MF(更多分片) |
| Fragment Offset | 13 | 分片偏移(×8 字节) |
| TTL | 8 | 生存时间(每跳减 1,到 0 丢弃) |
| Protocol | 8 | 6=TCP, 17=UDP, 1=ICMP |
| Header Checksum | 16 | 仅校验 IP 头部(每跳重新计算) |
| Source Address | 32 | 源 IPv4 地址 |
| Destination Address | 32 | 目标 IPv4 地址 |
3.2 IP 分片
如果 IP 包 > MTU (通常 1500):
原始 IP 包 [IP头|4500B 数据]
分片 1: [IP头 MF=1 Offset=0 |1480B]
分片 2: [IP头 MF=1 Offset=185 |1480B]
分片 3: [IP头 MF=1 Offset=370 |1480B]
分片 4: [IP头 MF=0 Offset=555 |60B]
所有分片共享相同的 Identification
接收方在 60s 内未收集齐 → 全部丢弃现代网络尽量避免 IP 分片:TCP MSS 协商、Path MTU Discovery (PMTUD)、IPv6 禁止中间路由器分片。
3.3 IPv6 头部(固定 40 字节)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version| Traffic Class | Flow Label |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Payload Length | Next Header | Hop Limit |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Source Address |
| (128 bits) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Destination Address |
| (128 bits) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+IPv6 的简化:
- 固定 40 字节头部(vs IPv4 可变 20-60B)
- 无分片(要求 PMTUD,最小 MTU=1280)
- 无校验和(交给传输层)
- Next Header 替代 Protocol(可以链式扩展)
4. ICMP — 网络诊断协议
ICMP 包 = IP 包 (Protocol=1) 的数据部分:
+--------+--------+-------------------+
| Type | Code | ICMP Data |
| 1B | 1B | ... |
+--------+--------+-------------------+| Type | Code | 含义 |
|---|---|---|
| 0 | 0 | Echo Reply (ping 响应) |
| 8 | 0 | Echo Request (ping 请求) |
| 3 | 0-15 | Destination Unreachable |
| 3 | 3 | Port Unreachable (UDP 探测) |
| 3 | 4 | Fragmentation Needed (PMTUD) |
| 11 | 0 | TTL Exceeded (traceroute) |
bash
# ping 使用 ICMP Echo
ping -c 4 8.8.8.8
# traceroute 利用 TTL 递增 + ICMP TTL Exceeded
traceroute 8.8.8.85. UDP 协议(传输层)
5.1 UDP 头部(仅 8 字节!)
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length | Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data (variable length) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+| 字段 | 位数 | 说明 |
|---|---|---|
| Source Port | 16 | 源端口(可为 0,表示不期望回复) |
| Destination Port | 16 | 目标端口 |
| Length | 16 | UDP 头部 + 数据的总字节数(最小 8) |
| Checksum | 16 | 可选(IPv4 可置 0 跳过,IPv6 强制) |
5.2 UDP vs TCP 深度对比
| 维度 | UDP | TCP |
|---|---|---|
| 连接 | 无连接 | 面向连接 |
| 可靠性 | 不保证送达 | 确认+重传 |
| 顺序 | 不保证 | 严格有序 |
| 流量控制 | 无 | 滑动窗口 |
| 拥塞控制 | 无(应用层自控) | 内置 |
| 头部开销 | 8 字节 | 20-60 字节 |
| 适用场景 | DNS, 直播, 游戏, QUIC | HTTP, 文件传输, 数据库 |
| 广播/多播 | ✅ | ❌ |
5.3 基于 UDP 的可靠协议
为什么 QUIC/HTTP3 选 UDP?
TCP 在内核态实现, 升级困难(需要改内核)
UDP 在用户态实现可靠传输 = 快速迭代
QUIC 在 UDP 之上实现了:
- 可靠传输 (类似 TCP 的 ACK)
- 多路复用(无 TCP 队头阻塞)
- 0-RTT 连接建立
- 内置 TLS 1.3
- 连接迁移 (Connection ID, 切换网络不断连)
KCP: 牺牲 10-20% 带宽, 换取 30-40% 低延迟(游戏加速)6. NAT 与 PAT — 解决 IPv4 地址枯竭
6.1 NAT 工作原理
mermaid
sequenceDiagram
participant C as 内网客户端<br/>192.168.1.5:54321
participant GW as NAT 网关<br/>内网:192.168.1.1<br/>公网:203.0.113.1
participant S as 外部服务器<br/>93.184.216.34:80
C->>GW: src=192.168.1.5:54321, dst=93.184.216.34:80
Note over GW: SNAT: 替换源地址<br/>记录映射: 54321→50001
GW->>S: src=203.0.113.1:50001, dst=93.184.216.34:80
S->>GW: src=93.184.216.34:80, dst=203.0.113.1:50001
Note over GW: DNAT: 查映射表<br/>50001→192.168.1.5:54321
GW->>C: src=93.184.216.34:80, dst=192.168.1.5:543216.2 NAT 类型
| 类型 | 映射方式 | NAT 表项 | 外部可访问性 |
|---|---|---|---|
| Full Cone (完全锥形) | 内网(IP,Port)→外网(IP,Port),固定映射 | 固定 | 任何外部主机可访问 |
| Restricted Cone | 同上 + 限制来源 IP | 固定 | 仅被访问过的外部 IP 可回访 |
| Port Restricted Cone | 同上 + 限制来源 IP+Port | 固定 | 最严格 |
| Symmetric (对称型) | 内网(IP,Port)+目标 → 外网(IP,Port) | 每个目标独立 | 仅该目标可回访 |
Symmetric NAT 是 P2P 最大的敌人——STUN 无法穿透 Symmetric NAT,需要 TURN 中继转发。
6.3 NAT 的问题
- 端口耗尽:单个公网 IP 最多 65535 个映射(实际 ~30000)
- 连接跟踪表溢出:
nf_conntrack: table full→ 丢包! - ALG (应用层网关):FTP/SIP 等协议在数据中嵌入 IP 地址,需要 NAT 修改应用层数据
bash
# 查看连接跟踪表
cat /proc/net/nf_conntrack | wc -l
sysctl net.netfilter.nf_conntrack_max
# 临时扩大
sysctl -w net.netfilter.nf_conntrack_max=2621447. Linux 网络栈路径
一个数据包从网卡到应用层,在 Linux 内核中经过的完整路径。
mermaid
flowchart TD
NIC["网卡 (NIC)"] --> DMA["DMA → 内核 Ring Buffer"]
DMA --> IRQ["硬中断 → NAPI 调度"]
IRQ --> SOFTIRQ["软中断 (ksoftirqd)"]
SOFTIRQ --> RX["netif_receive_skb()"]
RX --> BRIDGE{"桥接/OVS?"}
BRIDGE -->|"是"| BR_OUT["二层转发"]
BRIDGE -->|"否"| IP_RCV["ip_rcv()"]
IP_RCV --> IPT_PRE["iptables PREROUTING"]
IPT_PRE --> ROUTE{"路由决策"}
ROUTE -->|"本机"| IP_LOCAL["ip_local_deliver()"]
ROUTE -->|"转发"| IP_FWD["ip_forward()"]
IP_FWD --> IPT_POST["iptables POSTROUTING"]
IPT_POST --> TX["网卡发送"]
IP_LOCAL --> TCP_UDP["tcp_v4_rcv() / udp_rcv()"]
TCP_UDP --> SOCKET["Socket 接收队列"]
SOCKET --> APP["应用 recv() / read()"]
style NIC fill:#9C27B0,color:#fff
style APP fill:#4CAF50,color:#fff
style IPT_PRE fill:#FF9800,color:#fff
style IPT_POST fill:#FF9800,color:#fff关键可调的瓶颈点:
| 位置 | 参数 | 作用 |
|---|---|---|
| Ring Buffer | ethtool -G eth0 rx 4096 | 增大网卡缓冲区,减少丢包 |
| 软中断 | /proc/irq/<n>/smp_affinity | 绑核,防 CPU 0 瓶颈 |
| Socket 缓冲区 | net.core.rmem_max | 增大接收缓冲 |
| Backlog | net.core.netdev_max_backlog | 软中断队列长度 |
| iptables | iptables -nvL | 规则过多拖慢每个包 |
8. 网卡中断与数据接收 — 深入内核收包路径
前面展示了网络栈的宏观路径。本节深入网卡中断→DMA→软中断→协议栈的微观机制,以及 RSS/RPS/RFS/XDP 等多核优化技术。
8.1 中断驱动的收包流程(逐步骤)
mermaid
sequenceDiagram
participant NIC as 网卡 (NIC)
participant RAM as 内存 (DMA)
participant CPU as CPU
participant KERNEL as 内核协议栈
participant APP as 应用
Note over NIC: 电信号 → 帧 → 校验 FCS
NIC->>RAM: 1. DMA 写入 Ring Buffer<br/>(无需 CPU 参与!)
NIC->>CPU: 2. 硬中断 (IRQ)<br/>"数据到了!"
CPU->>CPU: 3. 硬中断处理:<br/>- 暂时屏蔽该 IRQ<br/>- 触发软中断 NET_RX_SOFTIRQ
Note over CPU: 硬中断返回,恢复被中断的进程
CPU->>CPU: 4. ksoftirqd 处理软中断:<br/>- 从 Ring Buffer 取 skb<br/>- 调用 netif_receive_skb()
CPU->>KERNEL: 5. 协议栈逐层处理<br/>L2 → L3 → L4 → Socket
KERNEL->>APP: 6. 数据放入 Socket 接收队列<br/>唤醒等待的进程 (epoll/read)关键时间点:
t0: 数据到达网卡 PHY
t1: DMA 完成 (~微秒级)
t2: 硬中断触发 (~微秒级)
t3: 软中断调度 (~纳秒级, 同 CPU)
t4: 协议栈处理 (~微秒-毫秒)
t5: 应用进程被唤醒
收包瓶颈在 t4-t5 (协议栈的处理开销)8.2 NAPI — 中断 + 轮询混合
问题: 高速网络下,每秒几十万个中断 → CPU 被中断淹没 → 活锁(livelock)
旧方案 (Linux 2.4):
每个包一次中断 → 中断风暴
NAPI (New API, Linux 2.6+):
1. 第一个包: 硬中断 → 触发软中断 → 开始 polling
2. 后续包: 暂时关闭中断 → 软中断持续从 Ring Buffer 轮询取包
3. 取完后: 重新开启中断
效果: 高负载时用轮询 (高效批量),低负载时用中断 (低延迟)
netif_napi_add(dev, &napi, my_poll, 64);
// my_poll 每次调用最多取 64 个包
// 取完后如果还有数据 → 软中断会再次调用8.3 多核收包优化 — RSS / RPS / RFS / XDP
mermaid
flowchart TD
subgraph Hardware["硬件层 (网卡)"]
NIC["网卡接收数据包"]
RSS["RSS (Receive Side Scaling)<br/>网卡硬件对包做<br/>Toeplitz Hash<br/>→ 分发到不同 RX Queue"]
end
subgraph CPU_Cores["CPU 核心层"]
direction LR
CORE0["CPU 0<br/>RX Queue 0<br/>IRQ 0"]
CORE1["CPU 1<br/>RX Queue 1<br/>IRQ 1"]
CORE2["CPU 2<br/>RX Queue 2<br/>IRQ 2"]
CORE3["CPU 3<br/>RX Queue 3<br/>IRQ 3"]
end
subgraph Software["软件层 (内核)"]
RPS["RPS (Receive Packet Steering)<br/>软件模拟 RSS<br/>→ 用 Hash 分发到<br/>目标 CPU 的 backlog"]
RFS["RFS (Receive Flow Steering)<br/>考虑应用所在 CPU<br/>→ 将包直接送到<br/>应用所在的核"]
end
subgraph Advanced["高级加速层"]
XDP["XDP (eXpress Data Path)<br/>网卡驱动最早期<br/>→ eBPF 程序处理<br/>→ 可旁路内核协议栈"]
DPDK["DPDK (Data Plane Dev Kit)<br/>用户态驱动<br/>→ 绕过内核<br/>→ 零拷贝收发包"]
end
NIC --> RSS --> CPU_Cores
CPU_Cores --> RPS --> RFS
RFS --> Advanced
style XDP fill:#FF5722,color:#fff
style DPDK fill:#E91E63,color:#fff
style RSS fill:#4CAF50,color:#fffRSS — 网卡硬件分流
RSS (Receive Side Scaling):
1. 网卡对每个包的 (src IP, dst IP, src Port, dst Port) 做 Toeplitz Hash
2. Hash 值低 n 位决定放入哪个 RX Queue (2^n 个队列)
3. 每个 RX Queue 绑定独立 MSI-X 中断和 CPU
效果: 同一条 TCP 连接的所有包→同一个 CPU → 无需锁 → 线性扩展
启用前提:
- 网卡硬件支持 RSS (大多数 10G+ 网卡)
- 启用多队列: ethtool -L eth0 combined 8
- 设置 hash key 和 indirection table
# 查看 RSS 配置
ethtool -x eth0
# 查看队列数
ethtool -l eth0RPS — 软件模拟 RSS
当网卡不支持 RSS 或队列数不够时, 用 RPS:
收包 CPU 对包的五元组做 Hash → 放入目标 CPU 的 backlog 队列
→ 目标 CPU 发 IPI (处理器间中断) → 目标 CPU 收包
echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus # CPU 位图
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt # 流表大小
RPS 比 RSS 慢 (多一次 CPU 间转移), 但不需要硬件支持RFS — 应用在同一 CPU 收包
RFS (Receive Flow Steering):
问题: 包被 RSS/RPS 分到 CPU 2, 但应用在 CPU 5 → 跨核传递 + 锁
RFS:
1. 应用调用 recvmsg() 时记录 [五元组 → CPU 5]
2. 新包到达 → 查流表 → 发现目标在 CPU 5 → 直接发给 CPU 5
效果: 应用所在的 CPU 直接处理数据, 最大化缓存命中
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cntXDP — 最早期的包处理
c
// XDP 程序运行在网卡驱动最早期 —— 在 skb 分配之前!
// → 可以轻松处理 10Gbps+ 线速
// 示例: XDP 丢弃所有 UDP 5000 端口的包
int xdp_filter(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) return XDP_PASS;
if (eth->h_proto != htons(ETH_P_IP)) return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end) return XDP_PASS;
if (ip->protocol != IPPROTO_UDP) return XDP_PASS;
struct udphdr *udp = (void *)((long)ip + ip->ihl * 4);
if ((void *)(udp + 1) > data_end) return XDP_PASS;
if (udp->dest == htons(5000)) return XDP_DROP; // 丢弃!
return XDP_PASS;
}XDP 的三种运行模式:
| 模式 | 说明 | 性能 | 要求 |
|---|---|---|---|
| Native (原生) | eBPF 程序运行在网卡驱动中 | 最高 | 网卡驱动支持 |
| Offload (卸载) | eBPF 程序运行在网卡硬件上 | 更高 | SmartNIC (Netronome等) |
| Generic (通用) | eBPF 运行在 skb 分配之后 | 一般 | 任何网卡 |
DPDK — 用户态高速网络
DPDK (Data Plane Development Kit):
原理:
1. 从内核"抢走"网卡 (UIO/VFIO 驱动)
2. 用户态程序直接操作网卡寄存器
3. 轮询模式 (PMD) 持续取包, 无中断开销
4. Huge Pages 减少 TLB miss
5. 每 CPU 一个线程 + 无锁数据结构
效果: 单核 10-20 Mpps (百万包/秒)
代价: 独占 CPU (100% 轮询), 无内核协议栈 (需自己实现)
典型场景:
- DPDK: 防火墙/负载均衡 (如 OVS-DPDK, VPP)
- XDP: 比 DPDK 轻, 适合 DDoS 防护、4 层负载均衡8.4 中断亲和性与 CPU 绑核
bash
# 查看网卡中断分布
cat /proc/interrupts | grep eth0
# 把不同 RX 队列的中断绑定到不同 CPU
# (中断号从 /proc/interrupts 获取)
echo 2 > /proc/irq/120/smp_affinity # 绑定到 CPU 1 (位图: 0010)
echo 4 > /proc/irq/121/smp_affinity # 绑定到 CPU 2 (0100)
# 查看中断亲和性
cat /proc/irq/120/smp_affinity_list
# 使用 irqbalance 自动分配 (生产环境推荐)
systemctl enable irqbalance8.5 Interrupt Coalescing — 中断合并
问题: 每收到一个小包就发一次中断 → 频繁上下文切换
中断合并 (Interrupt Coalescing):
网卡收到数据后不立即发中断 → 攒几个包或等一小段时间 → 再发中断
ethtool -C eth0 rx-usecs 50 # 最多等 50 微秒
ethtool -C eth0 rx-frames 32 # 或攒够 32 个帧
⚠️ 权衡: 等得越久 → 中断越少 CPU开销越低 → 但延迟增加
实时性敏感场景(交易/游戏) → 降低 rx-usecs
吞吐优先(CDN/文件传输) → 增大 rx-usecs8.6 收包调优速查表
| 位置 | 调优项 | 命令 | 效果 |
|---|---|---|---|
| Ring Buffer | 增大缓冲区 | ethtool -G eth0 rx 4096 tx 4096 | 减少突发丢包 |
| 中断合并 | 降低延迟 | ethtool -C eth0 rx-usecs 10 | 低延迟 |
| 中断合并 | 提高吞吐 | ethtool -C eth0 rx-usecs 100 rx-frames 64 | 高吞吐 |
| RSS | 多队列 | ethtool -L eth0 combined 8 | 多核并行 |
| 中断绑核 | 手动绑核 | echo 0xFF > /proc/irq/<n>/smp_affinity | 专核专用 |
| RPS | 软件分流 | echo ff > .../rx-0/rps_cpus | 无 RSS 网卡的补偿 |
| RFS | 靠近应用 | echo 4096 > .../rx-0/rps_flow_cnt | 缓存友好 |
| netdev_budget | 软中断预算 | sysctl net.core.netdev_budget=600 | 每次软中断最多处理包数 |
| netdev_max_backlog | 软中断队列 | sysctl net.core.netdev_max_backlog=5000 | 防丢包 |
9. 从网卡到应用的拥塞传播链
协议栈真正难的地方,不只是认识头部格式,而是理解:任何一层堵住,压力都会向上下游传播。
9.1 典型传播路径
mermaid
flowchart LR
NIC["NIC RX Ring"] --> BACKLOG["softirq backlog"]
BACKLOG --> IP["IP/TCP 协议栈"]
IP --> SKB["Socket Recv-Q / Send-Q"]
SKB --> EP["epoll / recv / send"]
EP --> APP["应用 worker"]
APP --> DOWN["下游 Redis / MySQL / RPC"]一旦应用消费速度低于到包速度,常见演化顺序是:
- 应用线程或 goroutine 被下游占住
- socket 缓冲区开始堆积,
Recv-Q/Send-Q变大 - softirq 和 backlog 压力上升
- 再往前是 NIC ring buffer 丢包、驱动丢包
- 用户看到的是超时、重传、RT 变高,甚至误以为是“网络不稳定”
9.2 一个真实感最强的案例:client -> proxy -> mysql
这正是你前面强调的那种分析方式。
mermaid
sequenceDiagram
participant C as Client
participant P as Proxy / App
participant M as MySQL
C->>P: 建立连接并发送请求
P->>M: 发起查询
Note over M: 慢 SQL / 锁等待 / 磁盘抖动
M-->>P: 返回变慢
Note over P: worker 被占住,连接持续不释放
C->>P: 更多新请求到来
Note over P: Recv-Q / 连接数 / fd 持续升高此时要重点分清:
- 如果是 TCP 层对端不再发 FIN,更常见的是大量
ESTABLISHED、Recv-Q/Send-Q堆积 - 如果应用逻辑已经 close 了一端但另一端没处理完,才可能出现
CLOSE_WAIT之类问题 CLOSE_WAIT本质上说明:对端已经关闭了连接,但本地应用还没有调用close()把 fd 真正释放掉
所以在 client -> proxy -> mysql 这种链路里,如果下游堵住,proxy 更常见的风险是:
- worker 被卡住
- 连接数上涨
- fd 被占满
- accept 新连接失败
- 上游访问变成 502/504/timeout
而不是简单粗暴地说“一定一直 CLOSE_WAIT”。CLOSE_WAIT 是应用没有及时 close 的结果,不是下游变慢的必然状态。
10. 生产排障:看到什么现象先怀疑哪一层
| 现象 | 优先怀疑 | 常用观察点 |
|---|---|---|
| 大量重传 | 丢包、拥塞、对端接收慢 | ss -ti, sar -n TCP, 抓包 |
Recv-Q 很大 | 应用读不动 | ss -ant, 应用线程栈 |
Send-Q 很大 | 对端收不动 / 网络堵 | ss -ant, 下游 RT |
| CPU 系统态高 | softirq、协议栈、iptables | top, /proc/softirqs, perf top |
| 连接很多但 CPU 不高 | 下游阻塞、线程/连接池堵塞 | 应用 profile、DB/Redis 指标 |
TIME_WAIT 多 | 主动关闭方很多 | 连接复用、短连接策略 |
CLOSE_WAIT 多 | 应用没有及时 close fd | 代码、goroutine dump、fd 列表 |
10.1 最常用的几组命令
bash
ss -ant # 看连接状态、Recv-Q、Send-Q
ss -s # 看各类 TCP 状态汇总
sar -n TCP,ETCP 1 # 看重传、失败连接、被动连接等
ethtool -S eth0 # 看网卡层丢包、队列统计
cat /proc/softirqs # 看 NET_RX/NET_TX 是否打偏
cat /proc/net/softnet_stat
lsof -p <pid> | wc -l # 看进程 fd 数量10.2 一个排障判断树
text
先看网络状态(重传、连接状态、队列)
↓
再看应用有没有消费不过来(线程池、goroutine、epoll loop)
↓
再看下游(MySQL/Redis/RPC)是否慢
↓
最后再回头确认是否真是 NIC / IRQ / backlog / rmem/wmem 参数问题这个顺序非常重要,因为线上多数“网络慢”最终都不是网线问题,而是应用层或下游消费能力下降,倒逼协议栈表现出异常。
11. 完整数据包封装图示
一个 HTTP GET 请求经过 TCP/IP 协议栈的逐层封装过程:
mermaid
flowchart LR
subgraph HTTP["应用层 — HTTP"]
H["HTTP Request<br/>GET /index.html"]
end
subgraph TCP["传输层 — TCP"]
T["TCP Header (20B)<br/>源端口: 54321 → 目标端口: 80<br/>Seq=1001, Ack=1"]
end
subgraph IP["网络层 — IP"]
I["IP Header (20B)<br/>源IP: 192.168.1.10<br/>目标IP: 93.184.216.34<br/>Protocol=6(TCP), TTL=64"]
end
subgraph Eth["链路层 — Ethernet"]
E["Ethernet Header (14B)<br/>源MAC: aa:bb:cc:dd:ee:ff<br/>目标MAC: 11:22:33:44:55:66<br/>EtherType=0x0800(IPv4)"]
FCS["FCS (4B)<br/>帧校验序列"]
end
HTTP -->|"封装"| TCP
TCP -->|"封装"| IP
IP -->|"封装"| Eth
Eth --> FCS
Final["最终链路传输<br/>总开销 = 14+20+20+4 = 58B<br/>不计 HTTP 数据"]
FCS --> Final每经过一层,数据被包装在更大的帧中。接收端反向解开:Ethernet → IP → TCP → HTTP。
12. 网卡中断合并(Interrupt Coalescing)与延迟的关系
12.1 为什么网卡不是每收一个包就中断一次?
如果每收到一个包就触发一次硬中断,高流量时 CPU 会被中断淹没(interrupt storm)。网卡通过中断合并(也叫中断节流/interrupt throttling)来平衡吞吐和延迟:
mermaid
flowchart LR
subgraph "无中断合并"
A1["包1到达"] --> I1["中断1"]
A2["包2到达"] --> I2["中断2"]
A3["包3到达"] --> I3["中断3"]
end
subgraph "有中断合并"
B1["包1到达"] --> W["等待: 攒够N个包<br/>或超时T μs"]
B2["包2到达"] --> W
B3["包3到达"] --> W
W --> I["一次中断<br/>批量处理3个包"]
end12.2 中断合并的两个维度
| 参数 | 含义 | 对延迟的影响 |
|---|---|---|
rx-usecs | 收到第一个包后等多久才触发中断 | 值越大 → 延迟越高,但 CPU 效率越好 |
rx-frames | 攒够多少个包才触发中断 | 值越大 → 延迟越高,但吞吐越好 |
tx-usecs | 发送完成后等多久才触发中断 | 影响发送完成通知的及时性 |
tx-frames | 攒够多少个发送完成才触发中断 | 同上 |
bash
# 查看当前中断合并配置
ethtool -c eth0
# Coalesce parameters for eth0:
# rx-usecs: 50
# rx-frames: 0
# tx-usecs: 50
# tx-frames: 0
# 降低延迟(牺牲 CPU)
ethtool -C eth0 rx-usecs 10 tx-usecs 10
# 提高吞吐(牺牲延迟)
ethtool -C eth0 rx-usecs 100 rx-frames 64
# 自适应模式(网卡自动调整)
ethtool -C eth0 adaptive-rx on adaptive-tx on12.3 NAPI(New API)— Linux 的混合中断/轮询机制
Linux 不是纯中断驱动,而是中断 + 轮询混合:
mermaid
flowchart TD
A["包到达网卡"] --> B["触发硬中断"]
B --> C["关闭中断<br/>切换到轮询模式 (NAPI poll)"]
C --> D["softirq 批量处理<br/>ring buffer 中的包"]
D --> E{"ring buffer 空了?"}
E -->|是| F["重新开启中断<br/>退出轮询"]
E -->|否| G{"处理了 budget 个包?"}
G -->|是| H["让出 CPU<br/>下次 softirq 继续"]
G -->|否| DNAPI 的精髓:
- 低流量时:中断驱动(及时响应)
- 高流量时:自动切换到轮询(避免中断风暴)
budget参数控制每次轮询最多处理多少包(默认 64)
12.4 中断合并对延迟的量化影响
| 配置 | 典型额外延迟 | 适用场景 |
|---|---|---|
rx-usecs=0(关闭合并) | +0μs | 超低延迟交易系统 |
rx-usecs=10 | +5-10μs | 低延迟服务 |
rx-usecs=50(常见默认) | +25-50μs | 通用服务器 |
rx-usecs=100 | +50-100μs | 吞吐优先 |
adaptive-rx on | 动态 | 大多数场景推荐 |
对 P99 的影响:
text
场景: 服务 P50=1ms, 业务逻辑本身很快
rx-usecs=50 → P99 可能多出 50μs(看起来不多)
但如果一个请求经过 3 跳(client→LB→app→DB):
每跳 +50μs × 3 = +150μs
对于 1ms 的服务,P99 被拉高 15%11.5 实战建议
| 场景 | 建议配置 | 原因 |
|---|---|---|
| 金融/交易系统 | rx-usecs=0, 关闭合并 | 每微秒都重要 |
| 低延迟 RPC 服务 | rx-usecs=10-20, adaptive | 平衡延迟和 CPU |
| 通用 Web 服务 | 默认或 adaptive | 延迟不敏感 |
| 大数据/流处理 | rx-usecs=100+, rx-frames=64 | 吞吐优先 |
| 容器环境 | 通常无法调整(宿主机配置) | 需要和运维协调 |
13. 从请求到响应的全链路延迟拆解
12.1 一个 HTTP 请求的完整延迟组成
mermaid
flowchart LR
A["DNS<br/>~1-50ms"] --> B["TCP Connect<br/>~RTT/2"]
B --> C["TLS Handshake<br/>~1-2 RTT"]
C --> D["HTTP Request<br/>发送"]
D --> E["Server Processing<br/>~1-100ms"]
E --> F["HTTP Response<br/>接收"]
F --> G["用户看到结果"]12.2 每一段的典型延迟数值
| 阶段 | 本地/同机房 | 同城跨机房 | 跨地域 | 跨洲际 |
|---|---|---|---|---|
| DNS 解析 | <1ms (缓存) | <1ms | 1-5ms | 5-50ms |
| TCP 三次握手 | <0.5ms | 1-3ms | 10-30ms | 80-150ms |
| TLS 1.2 握手 | 1-2ms | 3-6ms | 20-60ms | 160-300ms |
| TLS 1.3 握手 | 0.5-1ms | 1.5-3ms | 10-30ms | 80-150ms |
| HTTP 请求传输 | <0.1ms | <0.5ms | 1-5ms | 10-50ms |
| 服务端处理 | 1-100ms | 同 | 同 | 同 |
| HTTP 响应传输 | 取决于大小 | 同 | 同 | 同 |
| 总计(首次请求) | 3-105ms | 6-115ms | 42-230ms | 340-700ms |
12.3 每一段的瓶颈判断方法
| 阶段 | 判断方法 | 工具 |
|---|---|---|
| DNS 慢 | dig +trace 看每级耗时 | dig, nslookup, tcpdump |
| TCP 连接慢 | 抓包看 SYN→SYN-ACK 时间 | tcpdump, ss |
| TLS 慢 | 抓包看 ClientHello→ServerHello | openssl s_time, wireshark |
| 服务端处理慢 | 应用 trace/profile | pprof, jaeger, zipkin |
| 响应传输慢 | 看 TTFB vs 总时间 | curl -w, chrome devtools |
| 网络本身慢 | ping RTT, mtr 逐跳 | ping, mtr, traceroute |
12.4 优化每一段的常见手段
mermaid
flowchart TD
subgraph "减少 DNS 延迟"
D1["本地 DNS 缓存"]
D2["预解析 (dns-prefetch)"]
D3["HTTPDNS"]
end
subgraph "减少连接延迟"
C1["连接池 / Keep-Alive"]
C2["TCP Fast Open"]
C3["TLS 1.3 (0-RTT)"]
C4["QUIC (0-RTT)"]
end
subgraph "减少处理延迟"
P1["缓存 (Redis/本地)"]
P2["异步化"]
P3["预计算"]
end
subgraph "减少传输延迟"
T1["CDN 就近"]
T2["压缩 (gzip/br)"]
T3["减少响应体"]
end12.5 一个实战原则
text
先用 curl -w 拆解总延迟的组成,
确定瓶颈在 DNS、连接、TTFB 还是传输,
再针对性优化。
不要盲目加缓存或换协议。bash
# curl 延迟拆解
curl -o /dev/null -s -w "\
DNS: %{time_namelookup}s\n\
Connect: %{time_connect}s\n\
TLS: %{time_appconnect}s\n\
TTFB: %{time_starttransfer}s\n\
Total: %{time_total}s\n\
" https://example.com
# 输出示例:
# DNS: 0.012s
# Connect: 0.045s (TCP = Connect - DNS = 33ms)
# TLS: 0.089s (TLS = AppConnect - Connect = 44ms)
# TTFB: 0.156s (Server = TTFB - TLS = 67ms)
# Total: 0.234s (Transfer = Total - TTFB = 78ms)NAPI 中断合并与 ethtool 调优
高吞吐下的中断风暴
当网络吞吐量极高(如 10Gbps/25Gbps)时,每个数据包都触发一次硬中断 → CPU 被中断淹没。
text
10Gbps 线速,最小帧 64B:
包速率 ≈ 10Gbps / (64B × 8) ≈ 14.88 Mpps(百万包/秒)
→ 每包触发一次中断 → 每秒 1488 万次中断
→ CPU 全部用于处理中断,无法处理业务 → 软锁死NAPI 中断合并机制
mermaid
flowchart TB
subgraph Old["传统方式: 每包一次中断"]
P1["包1到达"] --> I1["中断1"]
P2["包2到达"] --> I2["中断2"]
P3["包3到达"] --> I3["中断3"]
end
subgraph NAPI["NAPI: 中断 + 轮询混合"]
NP1["包1到达"] --> NI1["中断 → 开始轮询"]
NI1 --> NAPIPoll["NAPI poll():<br/>从 ring buffer 批量取包<br/>直到预算花完或无包"]
NP2["包2→包N 到达<br/>(不触发中断)"] --> NAPIPoll
NAPIPoll --> Done["预算花完或无包<br/>→ 重新启用中断"]
endbash
# ethtool 配置中断合并(interrupt coalescing)
# 查看当前配置
ethtool -c eth0
# 关键参数
ethtool -C eth0 \
rx-usecs 50 \ # 收包中断延迟最多 50μs
rx-frames 256 \ # 或累积 256 个帧后触发
tx-usecs 50 \ # 发包中断延迟
tx-frames 256 # 或累积 256 个帧
# adaptive mode: 网卡自动调整合并参数
ethtool -C eth0 adaptive-rx on adaptive-tx on| 参数 | 含义 | 小值(低延迟) | 大值(高吞吐) |
|---|---|---|---|
rx-usecs | 收包中断最大延迟 | 10μs | 100μs |
rx-frames | 触发中断的累积帧数 | 1 | 512 |
adaptive-rx | 自适应调整 | on | on |
text
调优策略:
延迟敏感(交易、游戏):
rx-usecs=10, rx-frames=1 → 每包立刻中断 → 延迟 < 50μs
吞吐优先(CDN、数据备份):
rx-usecs=100, rx-frames=512 → 合并处理 → CPU 开销降低 5-10×
→ 但单个包的延迟增加到 ~100μs
自适应(推荐):
adaptive-rx on → 低负载时低延迟,高负载时高吞吐
Linux 内核 5.+ 和 Intel/MLX 网卡支持bash
# 监控中断分布
watch -n 1 'cat /proc/interrupts | grep eth0'
# 看软中断(NAPI poll)消耗
top -H # 找 ksoftirqd 线程的 CPU 使用率
# 网卡 ring buffer 大小
ethtool -g eth0
# RX ring buffer 满 → 丢包 → 增大
ethtool -G eth0 rx 4096
登录后即可发表评论 👇