Skip to content

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:#fff

2. 以太网帧(数据链路层) ​

以太网帧格式 (IEEE 802.3):
 0        6        12       14                           帧尾
+--------+--------+--------+-------------------+--------+
| 目的MAC | 源MAC  | 类型/长度 |     数据(IP包)    |  FCS   |
|  6B    |  6B    |   2B    |    46-1500 B      |  4B    |
+--------+--------+--------+-------------------+--------+
|←─────────────── 头部 14 字节 ──────────────→|
字段字节说明
目的 MAC6目标网卡物理地址
源 MAC6发送方网卡物理地址
EtherType20x0800 = IPv4, 0x0806 = ARP, 0x86DD = IPv6
数据46-1500最小 46 字节(不够填充),最大 1500(MTU)
FCS4CRC32 帧校验序列

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:ff

3. 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)                       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
字段位数说明
Version44 = IPv4, 6 = IPv6
IHL4头部长度(×4 字节),最小 5 (=20B)
DSCP + ECN8服务类型 + 拥塞通知
Total Length16整个 IP 包长度(含头部和数据)
Identification16分片标识(同一原始包的片段有相同 ID)
Flags3bit0=保留, bit1=DF(不分片), bit2=MF(更多分片)
Fragment Offset13分片偏移(×8 字节)
TTL8生存时间(每跳减 1,到 0 丢弃)
Protocol86=TCP, 17=UDP, 1=ICMP
Header Checksum16仅校验 IP 头部(每跳重新计算)
Source Address32源 IPv4 地址
Destination Address32目标 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    |       ...         |
+--------+--------+-------------------+
TypeCode含义
00Echo Reply (ping 响应)
80Echo Request (ping 请求)
30-15Destination Unreachable
33Port Unreachable (UDP 探测)
34Fragmentation Needed (PMTUD)
110TTL Exceeded (traceroute)
bash
# ping 使用 ICMP Echo
ping -c 4 8.8.8.8

# traceroute 利用 TTL 递增 + ICMP TTL Exceeded
traceroute 8.8.8.8

5. 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 Port16源端口(可为 0,表示不期望回复)
Destination Port16目标端口
Length16UDP 头部 + 数据的总字节数(最小 8)
Checksum16可选(IPv4 可置 0 跳过,IPv6 强制)

5.2 UDP vs TCP 深度对比 ​

维度UDPTCP
连接无连接面向连接
可靠性不保证送达确认+重传
顺序不保证严格有序
流量控制无滑动窗口
拥塞控制无(应用层自控)内置
头部开销8 字节20-60 字节
适用场景DNS, 直播, 游戏, QUICHTTP, 文件传输, 数据库
广播/多播✅❌

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:54321

6.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=262144

7. 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 Bufferethtool -G eth0 rx 4096增大网卡缓冲区,减少丢包
软中断/proc/irq/<n>/smp_affinity绑核,防 CPU 0 瓶颈
Socket 缓冲区net.core.rmem_max增大接收缓冲
Backlognet.core.netdev_max_backlog软中断队列长度
iptablesiptables -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:#fff

RSS — 网卡硬件分流 ​

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 eth0

RPS — 软件模拟 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_cnt

XDP — 最早期的包处理 ​

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 irqbalance

8.5 Interrupt Coalescing — 中断合并 ​

问题: 每收到一个小包就发一次中断 → 频繁上下文切换

中断合并 (Interrupt Coalescing):
  网卡收到数据后不立即发中断 → 攒几个包或等一小段时间 → 再发中断

  ethtool -C eth0 rx-usecs 50   # 最多等 50 微秒
  ethtool -C eth0 rx-frames 32  # 或攒够 32 个帧

  ⚠️ 权衡: 等得越久 → 中断越少 CPU开销越低 → 但延迟增加
           实时性敏感场景(交易/游戏) → 降低 rx-usecs
           吞吐优先(CDN/文件传输) → 增大 rx-usecs

8.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"]

一旦应用消费速度低于到包速度,常见演化顺序是:

  1. 应用线程或 goroutine 被下游占住
  2. socket 缓冲区开始堆积,Recv-Q / Send-Q 变大
  3. softirq 和 backlog 压力上升
  4. 再往前是 NIC ring buffer 丢包、驱动丢包
  5. 用户看到的是超时、重传、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、协议栈、iptablestop, /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个包"]
    end

12.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 on

12.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 -->|否| D

NAPI 的精髓:

  • 低流量时:中断驱动(及时响应)
  • 高流量时:自动切换到轮询(避免中断风暴)
  • 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 (缓存)<1ms1-5ms5-50ms
TCP 三次握手<0.5ms1-3ms10-30ms80-150ms
TLS 1.2 握手1-2ms3-6ms20-60ms160-300ms
TLS 1.3 握手0.5-1ms1.5-3ms10-30ms80-150ms
HTTP 请求传输<0.1ms<0.5ms1-5ms10-50ms
服务端处理1-100ms同同同
HTTP 响应传输取决于大小同同同
总计(首次请求)3-105ms6-115ms42-230ms340-700ms

12.3 每一段的瓶颈判断方法 ​

阶段判断方法工具
DNS 慢dig +trace 看每级耗时dig, nslookup, tcpdump
TCP 连接慢抓包看 SYN→SYN-ACK 时间tcpdump, ss
TLS 慢抓包看 ClientHello→ServerHelloopenssl s_time, wireshark
服务端处理慢应用 trace/profilepprof, 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["减少响应体"]
    end

12.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/>→ 重新启用中断"]
    end
bash
# 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μs100μs
rx-frames触发中断的累积帧数1512
adaptive-rx自适应调整onon
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
批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记