4.1 网络分层与报文之旅
一个字节从浏览器发出到对端进程收到,中间被套上了几层「信封」?为什么协议要分成四层而不是一层?MTU 和 MSS 差在哪,为什么它能让一次本该成功的请求卡住?时延、带宽、吞吐、丢包这四个数怎么组合起来描述一条链路?
用封装图解释任意一个字段「归谁管」;用 C 结构体把三层首部的长度与偏移量出来;用 tcpdump 在同一次抓包里看到以太网、IP、TCP 三层首部;用 ping -M do 测出路径 MTU。
第 1 篇讲系统调用时,read 和 write 的对象是文件;到了网络,同样的两个系统调用面对的是一条双向字节流,对端在另一台机器上。中间发生了什么,是这一篇要回答的问题。第 4 篇的顺序是「协议 → 内核 → 优化」:先用六章把协议讲清楚,再用五章把内核路径拆开,最后一章把两边合起来做优化。
这一章是全篇的地基。它不教你配置任何东西,只做三件事:给你一张分层地图,让你知道每个字段归哪一层管;带你走一遍报文从应用到网卡再回来的完整旅程;教你用抓包工具把这张地图和真实字节对上。后面十一章反复回到这张地图上——讲 TCP 握手时回到运输层,讲 sk_buff 时回到封装,讲容器网络时回到「每一层都有一个设备」。
读法建议:开一个终端,把文中的 ip、ss、tcpdump 命令跑一遍。所有命令都在本机回环或自建 network namespace 里可复现,不需要外网。结论以 Linux 6.8、x86-64 为背景。
4.1.1 分层解决的是「变化隔离」
先看一个具体问题:你要把一段 JSON 发给另一台机器。这中间至少有四件互相独立的事要做。
- 把 JSON 变成字节,并让对端知道「这是 JSON,长度是多少」。
- 找到对方的机器,并让中间的路由器知道该往哪转发。
- 保证字节按序到达,丢了能重发,发太快了能刹车。
- 把字节变成电信号或光信号,送到网线上。
这四件事的变化频率完全不同。换一个应用协议(JSON 换 Protobuf),第 2、3、4 步一行代码都不用改;换一块网卡(千兆换万兆),第 1、2、3 步也不用改。分层的目的不是把问题切小,而是把变化隔离开:让每一层只依赖下一层提供的接口,而不依赖下一层怎么实现。
这个「接口」在协议栈里有个正式名字:服务原语。上层调用 send 时不需要知道下面是以太网还是 Wi-Fi,只需要知道「给我一个能把字节送出去的东西」。反过来,下层交付数据时也不关心上面是 HTTP 还是 SSH,只负责把一段字节交给正确的上层实体。
协议(protocol) 是同层对等实体之间通信规则的集合,包含语法(字段格式)、语义(字段含义与动作)、时序(先后顺序)三要素。实体(entity) 是执行某层协议的活动元素,在实现里通常是一个函数集合或一个内核模块。对等层(peer layer) 指两台机器上处于同一层的实体,它们之间「逻辑上」直接通信,实际上数据要穿过下面所有层。
「逻辑上直接通信」这句话是理解分层的关键。两台机器的 TCP 实体看起来在直接对话,实际上每个 TCP 段都被装进 IP 包、装进以太网帧,经过交换机、路由器,一层层拆开再装回去。对等层之间的通信是虚拟的,只有最底层的物理介质上才有真实的信号传播。
路由器要做的决定是「这个包往哪个口发」,这个信息在 IP 首部的目的地址里。它不需要知道这个包是 HTTP 还是 SSH,所以它只拆到第三层就重新封装转发。这正是分层的收益:中间设备只需要实现它关心的那几层。交换机同理,只看第二层的 MAC 地址。
分层也有代价:每一层都要复制或搬移一次数据,每一层都要加自己的首部,每一层都可能引入自己的队列和延迟。所以高性能场景里会出现「跨层优化」——比如 TSO 让 TCP 把 64 KB 的段直接交给网卡,由硬件切分,省掉协议栈里的分段循环。跨层优化不是否定分层,而是在明确知道边界在哪之后,有意识地绕过它。
4.1.2 OSI 七层与 TCP/IP 四层:两张地图
教科书里有两种分层模型。OSI 七层是 ISO 提出的参考模型,理论上更完整;TCP/IP 四层是实际互联网跑出来的模型,工程上更常用。两者的对应关系必须记牢,否则读文档时会把「表示层」和「应用层」混为一谈。
| OSI 七层 | TCP/IP 四层 | 典型协议 | Linux 里的落点 |
|---|---|---|---|
| 应用层 / 表示层 / 会话层 | 应用层 | HTTP、DNS、TLS、SSH | 用户态进程 + 库(如 OpenSSL) |
| 运输层 | 运输层 | TCP、UDP、QUIC | 内核 net/ipv4/tcp*.c、udp.c |
| 网络层 | 网络层 | IPv4、IPv6、ICMP、ARP* | 内核 net/ipv4/、net/core/ |
| 数据链路层 | 网络接口层 | 以太网、Wi-Fi、PPP | 内核驱动 + net/core/dev.c |
| 物理层 | 网络接口层 | 双绞线、光纤、无线电 | 网卡硬件与 PHY 芯片 |
这张表的用法是定位,不是背诵。遇到一个字段,先问它在哪一行,就知道该去查哪个模块。表里 ARP 打了星号:它工作在链路层和网络层之间,把 IP 地址解析成 MAC 地址,严格说属于网络层的辅助协议,但报文直接封装在以太网帧里。
OSI 的表示层和会话层在 TCP/IP 里没有独立实现,它们的功能被并进了应用层。TLS 就是一个例子:它做加密和身份认证(表示层职责),但作为一个用户态库存在,压在自己的记录层协议上,跑在 TCP 之上。所以「TLS 是第几层」这个问题没有唯一答案,取决于你按哪个模型数。
各层的职责可以压缩成一句话,这四句话在排障时最有用:
| 层 | 一句话职责 | 出问题时的典型症状 |
|---|---|---|
| 应用层 | 定义「这些字节是什么意思」 | 返回 500、JSON 解析失败、证书过期 |
| 运输层 | 在两端进程之间建立「哪条流」 | 端口不通、连接被拒、CLOSE_WAIT 堆积 |
| 网络层 | 把包从源主机送到目的主机 | ping 不通、路由环路、TTL 耗尽 |
| 链路层 | 把帧送到同一个局域网内的下一跳 | ARP 解析失败、交换机环路、CRC 错误 |
这张表是从「现象反推层」的入口。看到「连接被拒绝」先想运输层,看到「网络不可达」先想网络层,看到「ARP 无响应」先想链路层。不要一上来就抓包,先用这张表把范围缩小。
「HTTP 是哪一层的协议?」标准答案是应用层。但如果追问「HTTP/3 呢」,答案会变得微妙:HTTP/3 跑在 QUIC 上,QUIC 跑在 UDP 上,而 QUIC 自己实现了可靠传输和拥塞控制。按功能它横跨运输层和应用层。这类问题的价值不在于答案,而在于逼你说清楚「分层是按功能划分的,不是按代码位置划分的」。
4.1.3 封装:一层套一层的首部
数据从上往下走时,每一层在前面加一个首部(有时还在后面加尾部),这个过程叫封装(encapsulation)。
应用层数据 "GET / HTTP/1.1\r\nHost: ...\r\n\r\n"
│
▼ 加 TCP 首部(20 字节起)
┌──────────────┬─────────────────────────────────────────────┐
│ TCP 首部 │ 应用数据 │
│ sport dport │ │
│ seq ack │ │
└──────────────┴─────────────────────────────────────────────┘
│
▼ 加 IP 首部(20 字节起)
┌────────────┬──────────────┬──────────────────────────────────┐
│ IP 首部 │ TCP 首部 │ 应用数据 │
│ src dst │ │ │
│ proto ttl │ │ │
└────────────┴──────────────┴──────────────────────────────────┘
│
▼ 加以太网首部 + 尾部(14 + 4 字节)
┌─────────┬────────────┬──────────────┬──────────────┬─────────┐
│ 以太网 │ IP 首部 │ TCP 首部 │ 应用数据 │ FCS │
│ dst src │ │ │ │ CRC32 │
│ type │ │ │ │ │
└─────────┴────────────┴──────────────┴──────────────┴─────────┘
◀────────── 每层只关心自己那一段 ──────────▶
读这张图要抓住两点。第一,首部是给对等层看的,不是给上层看的。TCP 首部里的序号只有对端的 TCP 实体关心,应用读到的字节流里根本没有它。第二,封装是嵌套的,不是并列的:以太网帧的载荷整个就是 IP 包,IP 包的载荷整个就是 TCP 段。所以一个 1500 字节的以太网帧,真正能装应用数据的只有 1500 − 20 − 20 = 1460 字节(不算选项)。
首部的存在还解释了一个常见疑问:为什么小包传输效率低。一个 10 字节的 HTTP/2 帧,加上 TCP 和 IP 首部是 50 字节,加以太网是 68 字节,有效载荷占比不到 15%。这不是浪费,是分层的成本。高并发服务里减少往返次数(而不是减少字节数)之所以收益最大,就是因为每个往返都带着一整套首部。
封装(encapsulation) 是上层数据加上本层首部后交给下层的过程。解封装(decapsulation) 是接收端逐层去掉首部、把载荷交给上层的过程。每层处理的数据单位有专门的名字:应用层叫报文(message),运输层叫段(segment,TCP)或数据报(datagram,UDP),网络层叫包(packet),链路层叫帧(frame)。日常口语里混着叫「包」没问题,但写文档和查 RFC 时要分清。
- 「MTU 是 1500,所以一次能发 1500 字节数据」。错。1500 是帧的载荷,要减去 IP 和 TCP 首部才是数据量。IPv4 无选项时是 1460,IPv6 因为首部固定 40 字节,通常是 1440。
- 「分片是 TCP 做的」。错。TCP 只按 MSS 切段,分片是 IP 层的事,发生在包已经交给网络层之后。看到分片应该去查 MTU,而不是查 TCP。
- 「首部只是元数据,不影响性能」。错。40 字节首部对 1460 字节数据是 2.7% 的开销;对小包(比如 10 字节的请求)首部占比接近 80%。
- 「分层意味着实现也分开了」。不一定。Linux 里
sendmsg一路调到驱动可能全在同一个进程上下文里完成,没有线程切换。分层的边界是逻辑边界,不是调度边界。
4.1.4 分用:靠首部里的标识字段逐层上交
从下往上走时,每一层剥掉自己的首部,把剩下的内容交给上层,这个过程叫分用(demultiplexing)。分用的依据是首部里的标识字段,一层一层往上找。
| 层 | 分用依据 | 取值示例 | 在内核里由谁做 |
|---|---|---|---|
| 链路层 | 以太网类型字段 | 0x0800 IPv4,0x86dd IPv6,0x0806 ARP | __netif_receive_skb_core 里的 ptype 链 |
| 网络层 | IP 首部的协议号 | 6 TCP,17 UDP,1 ICMP | ip_local_deliver_finish 查 inet_protos |
| 运输层 | 目的端口号 | 80、443、8080 | tcp_v4_rcv 查 ehash,udp_rcv 查 udp_hash |
| 应用层 | 端口已被 socket 占用 | 由 bind/listen 决定 | sk_receive_queue + 唤醒等待者 |
四层里有三层靠「一个整数字段」分用,只有应用层靠 socket。这解释了一个重要事实:端口是运输层唯一的多路复用标识。一台机器上所有 TCP 服务共享同一个 IP 地址,靠端口区分;如果没有端口,内核收到一个段就不知道该交给谁。
端口号是 16 位,范围 0–65535,分三段:
- 0–1023:知名端口(well-known),绑定需要
CAP_NET_BIND_SERVICE或 root。 - 1024–49151:注册端口(registered),IANA 登记但不强制。
- 49152–65535:临时端口(ephemeral),客户端
connect时由内核自动分配。
/proc/sys/net/ipv4/ip_local_port_range 控制临时端口范围,默认通常是 32768 60999,约 2.8 万个。这意味着一台客户端对同一个目的 IP:端口,最多只能同时发起约 2.8 万条连接——这个数字在 4.11 讲百万连接时会成为关键约束。
# 看当前临时端口范围
cat /proc/sys/net/ipv4/ip_local_port_range
# 看哪些端口在监听,以及各自属于哪个进程
ss -lntp
# 看某个端口的连接来自哪些地址
ss -tn state established '( sport = :8080 )'
4.1.5 一次 HTTP 请求的报文之旅
把地图和真实路径对上,最好的办法是跟着一个请求走一遍。假设你在本机跑了一个服务,监听 127.0.0.1:8080,然后用 curl 请求它。
第一段:应用层生成字节。 curl 把请求行、首部、空行拼成一个字符串。此时它还只是一段内存里的字节,没有任何网络语义。
第二段:运输层分段。 write 系统调用进入内核,TCP 把这段字节放进发送缓冲区。如果字节数超过 MSS,就切成多个段;每段分配一个序号。序号不是从 0 开始,而是握手时随机选的初始序号(ISN),这一点在 4.5 会讲原因。
第三段:网络层加地址。 每个 TCP 段被装进一个 IP 包,填上源 IP 和目的 IP。如果目的地址是 127.0.0.1,内核查路由表会发现出接口是 lo,一个虚拟的回环设备。这一步决定了后面走的是「本机捷径」而不是真的网卡。
第四段:链路层封装。 回环设备有全 0 的 MAC(addr_len=6),以太网首部仍是 14 字节,类型字段 0x0800。真正的物理网卡上,这一步会填上下一跳的 MAC 地址(由 ARP 得到)。
第五段:驱动与网卡。 对回环设备,ndo_start_xmit 直接把 skb 挂到接收队列,绕过了 DMA 和硬件;对真实网卡,驱动把 skb 的物理地址写进 TX ring 的描述符,网卡 DMA 读走后发上线路。
第六段:对端收包。 数据从网卡进 RX ring,触发硬中断,NAPI 轮询把帧取出来,逐层分用,最后按目的端口找到 socket,把数据放进 sk_receive_queue,唤醒阻塞在 epoll_wait 或 read 上的进程。
这条路径里有两个地方最容易被忽视。一是回环不是「不经过协议栈」,它完整走了 IP 和 TCP,只是跳过了链路层和硬件,所以本机通信一样有 TCP 开销,只是没有网卡开销。二是发送和接收可能发生在同一个 CPU 上,本机回环甚至会直接在发送上下文里调用接收函数,这就是 4.11 要讲的「本机捷径」。
# 起一个最小服务端(任意目录,Python 3 自带)
python3 -m http.server 8080 --bind 127.0.0.1 &
# 另开一个终端:看这次请求的完整 TCP 交互
sudo tcpdump -i lo -nn -c 12 'tcp port 8080'
# 再发请求
curl -sS -o /dev/null http://127.0.0.1:8080/
预期能看到 Flags [S]、Flags [S.]、Flags [.]、Flags [P.] 依次出现——这就是三次握手加一次数据推送,4.5 会逐行解释。
- 应用有没有发出字节:
strace -e trace=write,sendto,sendmsg -f <pid>,看返回值。 - TCP 有没有发出去:
ss -tin state established,看Send-Q是否堆积、cwnd是否被压到很小。 - IP 有没有选对路:
ip route get <目的地址>,一条命令给出出接口、源地址、下一跳。 - 链路有没有通:
ip neigh show <下一跳>看 ARP 是否解析成功。 - 对端有没有收到:在对端
tcpdump -i any -nn 'tcp port <端口>',比对 seq。 - 对端应用有没有读走:
ss -lnt看Recv-Q,队列堆满说明应用太慢而不是网络太慢。
4.1.6 用 C 把首部量一遍
背字段表不如自己描一遍。下面这段程序不依赖内核私有头文件,手工定义三层首部,把「偏移量」和「长度」用 printf 打出来。它能在任何 POSIX 系统上编译运行,编译期还会用 _Static_assert 卡住长度,一旦结构体写错就编译失败。
/* pkt_layout.c:手工描出三层首部,验证封装的偏移与长度 */
#include <arpa/inet.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
struct eth_hdr { /* RFC 894:14 字节 */
uint8_t dst[6];
uint8_t src[6];
uint16_t ethertype;
} __attribute__((packed));
struct ipv4_hdr { /* RFC 791:固定 20 字节 */
uint8_t ver_ihl;
uint8_t tos;
uint16_t total_len;
uint16_t id;
uint16_t frag_off;
uint8_t ttl;
uint8_t proto;
uint16_t checksum;
uint32_t src;
uint32_t dst;
} __attribute__((packed));
struct tcp_hdr { /* RFC 9293:固定 20 字节 */
uint16_t sport;
uint16_t dport;
uint32_t seq;
uint32_t ack;
uint8_t data_off;
uint8_t flags;
uint16_t window;
uint16_t checksum;
uint16_t urgptr;
} __attribute__((packed));
_Static_assert(sizeof(struct eth_hdr) == 14, "eth 首部必须是 14 字节");
_Static_assert(sizeof(struct ipv4_hdr) == 20, "IPv4 首部必须是 20 字节");
_Static_assert(sizeof(struct tcp_hdr) == 20, "TCP 首部必须是 20 字节");
int main(void)
{
static unsigned char frame[128];
const char *payload = "GET / HTTP/1.1\r\n\r\n";
const size_t plen = strlen(payload);
struct eth_hdr *eth = (struct eth_hdr *)frame;
struct ipv4_hdr *ip = (struct ipv4_hdr *)(frame + sizeof(*eth));
struct tcp_hdr *tcp = (struct tcp_hdr *)((unsigned char *)ip + sizeof(*ip));
unsigned char *data = (unsigned char *)tcp + sizeof(*tcp);
memset(frame, 0, sizeof frame);
eth->ethertype = htons(0x0800); /* 分用:交给 IPv4 */
ip->ver_ihl = (4u << 4) | 5u; /* v4,20 字节首部 */
ip->ttl = 64;
ip->proto = 6; /* 分用:交给 TCP */
ip->total_len = htons((uint16_t)(sizeof(*ip) + sizeof(*tcp) + plen));
inet_pton(AF_INET, "127.0.0.1", &ip->src);
inet_pton(AF_INET, "127.0.0.1", &ip->dst);
tcp->sport = htons(51234);
tcp->dport = htons(8080);
tcp->data_off = 5u << 4; /* 20 字节首部 */
tcp->flags = 0x18; /* PSH | ACK */
tcp->window = htons(65495);
memcpy(data, payload, plen);
printf("ethernet off=%2zu len=%2zu type=0x%04x\n", (size_t)0,
sizeof(*eth), ntohs(eth->ethertype));
printf("ipv4 off=%2zu len=%2zu proto=%u\n",
(size_t)((unsigned char *)ip - frame), sizeof(*ip), ip->proto);
printf("tcp off=%2zu len=%2zu dport=%u\n",
(size_t)((unsigned char *)tcp - frame), sizeof(*tcp), ntohs(tcp->dport));
printf("payload off=%2zu len=%2zu\n", (size_t)(data - frame), plen);
printf("frame total=%zu bytes\n", (size_t)(data - frame) + plen);
printf("mss ipv4=%zu ipv6=%zu\n", 1500 - sizeof(*ip) - sizeof(*tcp),
1500 - 40 - sizeof(*tcp));
return 0;
}
编译并运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g pkt_layout.c -o pkt_layout
./pkt_layout
输出与封装图逐行对应:
ethernet off= 0 len=14 type=0x0800
ipv4 off=14 len=20 proto=6
tcp off=34 len=20 dport=8080
payload off=54 len=18
frame total=72 bytes
mss ipv4=1460 ipv6=1440
关键在 off 那一列:14、34、54。每加一层,偏移量就增加上一层的首部长度。偏移量是理解封装最直接的证据——它告诉你「第 35 个字节开始是 TCP 段」,而不是「大概在中间」。
程序里有两个细节值得注意。ver_ihl 的高 4 位是版本、低 4 位是首部长度(单位 4 字节),所以 0x45 表示 IPv4、20 字节首部;如果带 4 字节选项,就是 0x46,共 24 字节。data_off 同理,高 4 位是 TCP 首部长度,5 << 4 表示 20 字节。长度字段的单位是 4 字节而不是 1 字节,这是很多人第一次读 RFC 时会卡住的地方。
假设 TCP 开启时间戳选项(10 字节)并填充到 4 字节对齐,实际 12 字节。那么 TCP 首部是 20 + 12 = 32 字节,data_off = 32 / 4 = 8,二进制 1000,整个字节是 0x80。
以太网 14 + IP 20 = 34,TCP 首部 32 字节,所以应用数据从偏移 66 开始。若 MTU 1500,一个帧最多 1500 字节 IP 包,应用数据上限是 1500 − 20 − 32 = 1448。握手通告的 MSS 仍是 1460(MSS 只计 TCP 数据,不含选项);带 12 字节时间戳时,Linux 的 tcp_current_mss 会把有效数据段降到 1448。抓包里 mss 1460 与「每段 1448 字节数据」并不矛盾。
4.1.7 MTU、路径 MTU 与分片
MTU 是这一篇里最容易引发「玄学故障」的参数。典型症状是:小请求正常,大响应卡死;ping 通,但 curl 超时;SSH 能登录,scp 传大文件断掉。原因通常是路径上某一段链路的 MTU 小于发送端认为的值,而 ICMP 的「需要分片」消息被防火墙挡掉了。
先看本机 MTU:
ip link show lo | head -2
ip -br link show
回环的 MTU 是 65536,不是 1500——这也是为什么本机测试永远发现不了 MTU 问题。真实网卡通常是 1500,云环境的 VXLAN 网卡可能是 1450(减去 50 字节封装开销)。
测路径 MTU 的标准做法是用 ping 加「禁止分片」标志,二分查找最大可通尺寸:
# -M do 禁止分片,-s 指定 ICMP 载荷大小(不含 8 字节 ICMP 首部和 20 字节 IP 首部)
for s in 1472 1400 1200; do
ping -M do -s $s -c 1 -W 1 192.0.2.1 >/dev/null 2>&1 \
&& echo "$s ok (MTU=$((s+28)))" || echo "$s too big"
done
1472 + 8 + 20 = 1500,所以 -s 1472 通就说明路径 MTU 至少 1500。这个式子要记住:ping 的 -s 是 ICMP 载荷,加 8 字节 ICMP 首部、20 字节 IP 首部才是 IP 包大小。
当 IP 包大于出接口 MTU 时,IPv4 有两个选择:分片,或者置 DF 位并返回 ICMP Fragmentation Needed。现代实现默认走后者(路径 MTU 发现,PMTUD),因为分片的重组成本高、且丢一片就整包重传。Linux 的相关开关是 /proc/sys/net/ipv4/ip_no_pmtu_disc,默认 0 表示启用 PMTUD。
分片本身长这样:一个 3000 字节的 IP 包(20 字节首部 + 2980 字节载荷)走 1500 MTU 的链路,会被切成三片:第一片 MF=1, offset=0,载荷 1480;第二片 MF=1, offset=185,载荷 1480;第三片 MF=0, offset=370,载荷 20。片偏移的单位是 8 字节,所以除首片外每片载荷必须是 8 的倍数。用 tcpdump 能直接看到这个标志:
# 在本机 netns 里发一个大包,制造分片
sudo ip netns add lab
sudo ip netns exec lab ip link set lo up
sudo ip netns exec lab ping -M dont -s 3000 -c 1 127.0.0.1
# 另一个终端抓分片:IP 首部第 6 字节起的 2 字节是标志 + 片偏移
sudo tcpdump -i any -nn 'ip[6:2] & 0x3fff != 0'
最后一条过滤表达式读作「标志位或片偏移不为 0」,即「这是分片或还有更多分片」。
ping通但业务不通。ICMP 的Fragmentation Needed被中间防火墙丢了,PMTUD 失效,发送端一直以为能发 1500。解法是调小 MTU 或放行 ICMP type 3 code 4。- 隧道越多,MTU 越小。VXLAN 减 50,GRE 减 24,IPsec 减 50–70。容器网络里频繁出现「宿主机通、Pod 不通」,多半是 overlay 的 MTU 没调。
ip link set mtu只改本机。路径上任何一跳的 MTU 都可能更小,改本机只解决「本机发不出去」,不解决「中间过不去」。- IPv6 没有路由器分片。中间设备只会回 ICMPv6
Packet Too Big,如果这个报文被挡,表现是「TCP 能握手但大包永远卡住」,比 IPv4 更难查。
IPv4 允许中间路由器分片,这意味着路由器要维护重组状态、要处理分片攻击(大量不完整分片耗尽内存)。IPv6 把分片责任完全推给源端:中间路由器遇到过大包只能回 ICMPv6 Packet Too Big,由源端自己调整。这个设计把「状态」从网络核心推到了边缘,符合互联网的端到端原则。
4.1.8 时延、带宽、吞吐与丢包
描述一条网络路径,四个数就够了。它们的组合决定了你该优化什么。
| 指标 | 定义 | 典型值(实验环境) | 观测命令 |
|---|---|---|---|
| 传播时延 | 信号在介质中走一趟的时间,受光速和距离限制 | 同城 0.5 ms,跨洲 70 ms | ping 的 RTT / 2 |
| 传输时延 | 把比特推上链路的时间 = 包长 / 带宽 | 1500 B / 1 Gbps ≈ 12 µs | 计算,不易直测 |
| 排队时延 | 在 qdisc 或交换机缓冲区里等待的时间 | 0 到数百 ms,随负载剧烈变化 | tc -s qdisc show |
| 处理时延 | 协议栈、驱动、应用的处理时间 | 本机回环 20–60 µs | perf、bpftrace |
带宽(bandwidth) 是链路理论上限,单位 bit/s;吞吐(throughput) 是实际达到的速率,永远小于等于带宽。两者的差距来自协议开销、丢包、窗口限制和接收端处理能力。
一个必须记住的公式是带宽时延积(BDP):
BDP = 带宽 × 往返时延
一条 1 Gbps、RTT 40 ms 的链路,BDP = 1e9 × 0.04 / 8 = 5 MB。意思是:发送端必须能「在途」5 MB 数据,才能填满这条链路。如果 TCP 窗口只有 64 KB,吞吐上限就是 64 KB / 40 ms ≈ 13 Mbps,只有带宽的 1.3%。这就是为什么长肥管道(long fat network)上必须开窗口缩放——4.6 会讲。
丢包的影响取决于协议。对 TCP,丢包被解释为拥塞信号,触发重传和窗口收缩;对 UDP,丢包就是丢了,应用自己决定要不要重传。1% 的随机丢包对 TCP 吞吐的伤害远大于 1%,因为拥塞控制会把窗口砍半,而恢复要花好几个 RTT。
本机能看到的最直接的两个数,是 ss -ti 里的 rtt 和 retrans:
# 建一条本机连接,然后观察它的 RTT 与重传
python3 -m http.server 8080 --bind 127.0.0.1 &
curl -sS -o /dev/null http://127.0.0.1:8080/ &
ss -tin dst 127.0.0.1:8080 | head -4
输出里的 rtt:0.05/0.02 是「平滑 RTT / 方差」,单位毫秒;retrans:0/0 是「已重传段数 / 总段数」。这两个数在 4.6 会展开讲,这里先记住它们是判断「网络慢」还是「应用慢」的第一手证据。
从上海到法兰克福,直线距离约 9000 km,光纤中光速约 2×10⁸ m/s,单程传播时延 45 ms,RTT 至少 90 ms。一个 HTTPS 请求需要:TCP 握手 1 RTT + TLS 1.3 握手 1 RTT + HTTP 请求/响应 1 RTT = 270 ms,这还没算服务器处理时间。
如果改用 TLS 1.2,握手要 2 RTT,总时间变成 360 ms。如果 TCP 用了 TCP_FASTOPEN 且 TLS 用了 session resumption,可以压到 1 RTT 左右。这解释了为什么 CDN 的第一价值是「把 RTT 从 90 ms 降到 5 ms」,而不是「带宽更大」。
4.1.9 抓包:把分层地图和真实字节对上
tcpdump 是这个领域最重要的观测工具。它的原理值得先说清楚:它在链路层注册一个 packet_type,通过 AF_PACKET socket 收到进入协议栈之前的帧副本。所以在 ip_rcv 里被丢弃的包,tcpdump 照样能抓到;反过来,被网卡硬件过滤掉的包(比如目的 MAC 不是本机的单播),它抓不到。
先看一个最完整的抓包命令,把三层首部一次打出来:
# -e 显示以太网首部,-n 不做名字解析,-nn 端口也不解析,-X 打十六进制
sudo tcpdump -i lo -enn -c 4 -X 'tcp port 8080'
输出里每一行都对应封装图的一层:
12:01:02.345678 00:00:00:00:00:00 > 00:00:00:00:00:00, ethertype IPv4 (0x0800), length 74:
127.0.0.1.51234 > 127.0.0.1.8080: Flags [S], seq 3123456789, win 65495,
options [mss 65495,sackOK,TS val 1234567 ecr 0,nop,wscale 7], length 0
00:00:00:00:00:00 > 00:00:00:00:00:00是以太网首部。回环设备没有真实 MAC,全是 0。ethertype IPv4 (0x0800)是分用依据,告诉内核把载荷交给 IPv4。127.0.0.1.51234 > 127.0.0.1.8080是 IP 首部的地址加 TCP 首部的端口。Flags [S]是 TCP 标志位,S表示 SYN。options [mss 65495,...]是 TCP 选项,回环的 MSS 等于 MTU 减首部。
这里 mss 65495 值得注意:它等于 65536 减去 IP 首部、TCP 首部和一点对齐余量。如果这是一块 1500 MTU 的网卡,这里会是 1460。同一个协议在不同设备上的参数不同,抓包看到的值才是真相。
常用的过滤表达式按层组织,记住几个就够日常用:
sudo tcpdump -i any -nn 'tcp port 443 and host 192.0.2.10' # 按五元组过滤
sudo tcpdump -i eth0 -nn 'icmp or arp' # 按协议过滤
sudo tcpdump -i eth0 -nn 'tcp[tcpflags] & tcp-syn != 0' # 只抓 SYN
sudo tcpdump -i eth0 -nn -w /tmp/cap.pcap 'port 8080' # 存盘后用 Wireshark 看
tcp[13] & 2 != 0 是最原始的 SYN 过滤写法,因为 SYN 标志位是 TCP 首部第 13 字节的 bit 1。tcp-syn 只是它的可读别名。理解这一点,就能自己写出任意标志位组合的过滤器。
抓包本身有代价:每个包都要复制到用户态,高流量下会丢包并推高 CPU。生产环境优先用 -w 存盘再离线分析,或者用 bpftrace 只统计不复制。
-i any看不到以太网首部。Linux 的any是伪设备,聚合了所有接口,链路层首部在这一层已经被抹掉。要看 MAC 必须指定具体接口。- 抓不到包不等于没有包。可能是网卡做了硬件过滤、可能是走了 offload(TSO 让大包在抓包点还没分段)、也可能是流量走了另一条路径(比如容器里的 veth)。
- 抓包时间戳不是精确的发送时间。它是在内核收包点打的,受中断合并、NAPI 批处理影响,微秒级抖动很正常。
tcpdump抓到的包可能比实际发出去的大。开启了 TSO/GSO 时,协议栈交给网卡的是超大包,网卡才切分。这类包在抓包里表现为length远大于 MTU。- 过滤表达式写错不会报错,只会抓不到。
port 80和tcp port 80差别很大,前者同时匹配 TCP 和 UDP。写完先用-c 5试抓,确认有输出再放后台。
4.1.10 端到端原则:分层之上的设计哲学
分层是结构,端到端原则(end-to-end principle)是这套结构的价值观。它的原始表述是:一个功能如果必须由通信双方自己正确实现,那么把它放在网络中间实现是浪费——中间节点做同样的事,既不能减少端点的负担,又增加了自己的复杂度。
这条原则解释了互联网的很多设计选择:
- 可靠性放在端点。IP 层不保证送达,TCP 在两端做重传。中间路由器不需要维护「哪个包到了、哪个没到」的状态。
- 拥塞控制放在端点。路由器只做丢弃(或者 ECN 标记),由发送端根据丢包信号调整速率。
- 分片责任在 IPv6 里推给源端。中间节点不再重组。
- NAT 是反例。NAT 让中间设备维护连接状态,破坏了端到端可达性,代价是今天排查 P2P 和 VoIP 问题时要处理各种打洞。
把复杂度放在端点,意味着网络核心只需要做转发,可以做得极快、极简单;新功能(加密、压缩、多路复用)只需要改两端,不需要升级所有路由器。反过来,把智能放进网络(比如 ATM 的复杂信令),每加一个功能都要全网升级。这不是技术优劣,是部署成本的算术。
理解这条原则,你在读后面的内核路径时会不断看到它的影子:内核协议栈本身也是「端点」,它只对自己这一端负责,不假设网络里有什么。
4.1.12 首部的字节账:封装到底花掉多少带宽
4.1.3 讲了「一层套一层」,这一节把每层的字节数算清。同样的应用载荷,封装不同,有效吞吐(goodput)能差一倍以上。
| 层 | 字段 | 字节 | 备注 |
|---|---|---|---|
| 以太网 | 目的 MAC + 源 MAC | 12 | 不计前导码 |
| 以太网 | 类型/长度 | 2 | 0x0800=IPv4,0x86dd=IPv6,0x8100=VLAN |
| 以太网 | 载荷 + 填充 | 46–1500 | 最小帧 64 字节(含 FCS) |
| 以太网 | FCS | 4 | 网卡通常不交给主机 |
| VLAN | TPID + TCI | 4 | 802.1Q;QinQ 再 4 字节 |
| IPv4 | 固定首部 | 20 | 选项最多再加 40 |
| IPv6 | 固定首部 | 40 | 扩展首部另算 |
| TCP | 固定首部 | 20 | 选项最多 40(时间戳+SACK 常见 12) |
| UDP | 首部 | 8 | — |
| TLS 1.3 | 记录头 + AEAD tag + 内层类型 | 5 + 16 + 1 | 每条记录都有 |
| HTTP/2 | 帧头 | 9 | 每帧 |
| QUIC | 头 + AEAD tag | 1–30 + 16 | 可变长头 |
按「以太网 + IPv4 + TCP(含 12 字节选项)」算,每包固定开销是 14 + 20 + 32 = 66 字节。有效载荷与 goodput 的关系:
| 应用载荷 | 线上字节 | 头部占比 | 1 Gbps 链路的 goodput |
|---|---|---|---|
| 64 | 130(含最小帧填充) | 50.8% | 约 492 Mbps |
| 512 | 578 | 11.4% | 约 886 Mbps |
| 1460 | 1526 | 4.3% | 约 957 Mbps |
| 8192(GSO) | 8258 | 0.8% | 约 992 Mbps |
这张表解释了两件事:
- 小包是带宽杀手。64 字节载荷时一半带宽花在首部和填充上;金融行情、游戏、DNS 这类小包应用,PPS 往往比带宽更早成为瓶颈。
- GSO/TSO 的价值在减少「每包固定成本」,而不只是减少拷贝。一个 64 KB 的 GSO 包在协议栈里只走一次,到了网卡才切成 44 个帧;首部仍然要发,但函数调用、锁、qdisc 次数大幅下降。
再看时延。一个包从 A 到 B 的时间由四段组成:
总时延 = 传输时延 + 传播时延 + 排队时延 + 处理时延
传输时延 = 帧长 / 链路速率 (把比特推上线路的时间)
传播时延 = 距离 / 光速 (信号在介质里跑的时间)
以 10 Gbps、1000 km 光纤为例:
传输时延(1500 B)= 12000 bit / 10 Gbps = 1.2 µs
传播时延(1000 km,光速约 2×10^8 m/s)= 5 ms
传播时延比传输时延大三个数量级。所以长距离链路的瓶颈是「光速」,不是「带宽」;把 10 Gbps 升到 100 Gbps,RTT 一点也不会降。
BDP(带宽时延积)是「管道里能装多少数据」:
BDP = 带宽 × RTT
10 Gbps × 100 ms = 1 Gbps·s = 125 MB
要让这条链路跑满,发送方必须有至少 125 MB 的在途数据(窗口)。这就是 4.6 里 cwnd 与窗口缩放存在的理由:16 位窗口最大 64 KB,在 10 Gbps/100 ms 链路上只能发挥约 0.05% 的带宽(64 KiB / 125 MB)。
假设 HTTP 响应 10 KB,TCP 已连接,MSS 1460:
- 10 KB 需要 7 个 TCP 段(6×1460 + 1240)。
- 每段线上开销 66 字节,7 段共 462 字节。
- 加上 TLS 记录(假设 2 条记录,各 22 字节)与 HTTP 头(约 200 字节)。
- 总线上字节 ≈ 10000 + 462 + 44 + 200 = 10706,goodput ≈ 93%。
如果响应只有 200 字节(一个 API 的 JSON),线上字节 ≈ 200 + 66 + 22 + 200 = 488,goodput 只有 41%。这就是「API 响应越小,越该压缩头部/复用连接」的定量依据。
4.1.13 内核里的分层:每层对应哪些结构体与函数
协议图是抽象的,内核里的分层是具体的:每一层都有「操作表 + 首部结构 + 分用键」。这张表把 4.1.2 的四层落到代码:
| 层 | 首部结构 | 操作表 | 分用键 | 入口函数 |
|---|---|---|---|---|
| 应用 | 无(由库定义) | struct proto_ops | fd | sock_sendmsg |
| 传输 | struct tcphdr/udphdr | struct proto(tcp_prot/udp_prot) | 协议号 + 端口 | tcp_v4_rcv/udp_rcv |
| 网络 | struct iphdr/ipv6hdr | struct packet_type(ip_packet_type) | EtherType | ip_rcv |
| 链路 | struct ethhdr | struct net_device_ops | 设备 | netif_receive_skb |
三个「操作表」是关键抽象:
/* net/ipv4/tcp_ipv4.c,节选 */
struct proto tcp_prot = {
.name = "TCP",
.owner = THIS_MODULE,
.close = tcp_close,
.connect = tcp_v4_connect,
.disconnect = tcp_disconnect,
.accept = inet_csk_accept,
.ioctl = tcp_ioctl,
.sendmsg = tcp_sendmsg,
.recvmsg = tcp_recvmsg,
.splice_read = tcp_splice_read,
.shutdown = tcp_shutdown,
.setsockopt = tcp_setsockopt,
.getsockopt = tcp_getsockopt,
.sendpage = tcp_sendpage,
...
};
socket() 创建时,inet_create() 按 SOCK_STREAM 找到 tcp_prot,把 sk->sk_prot 指向它;此后 sendmsg 走 sk->sk_prot->sendmsg,不需要每层 if/else。这就是「分层」在代码里的样子:不是函数调用栈,而是函数指针表。
网络层的分用靠 packet_type 链表:
/* net/ipv4/af_inet.c,节选 */
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
};
dev_add_pack() 把它挂进 ptype_base[] 哈希;netif_receive_skb() 按 EtherType 查找并调用 ip_rcv。注册与分用是分离的:驱动不需要知道 IP,只要把帧交给 netif_receive_skb。
链路层的分用靠 net_device 与 netdev_ops:
/* drivers/net/loopback.c,节选 */
static const struct net_device_ops loopback_ops = {
.ndo_init = loopback_dev_init,
.ndo_start_xmit = loopback_xmit,
.ndo_get_stats64 = loopback_get_stats64,
.ndo_set_mac_address = eth_mac_addr,
};
ndo_start_xmit 是「把 skb 交给硬件」的唯一入口;不同设备实现不同,但上层只认这个函数指针。
把一次 write() 的路径按层连起来:
write()
→ sock_sendmsg → sk->sk_prot->sendmsg(tcp_sendmsg)
→ tcp_transmit_skb(加 TCP 首部)
→ ip_queue_xmit(加 IP 首部、查路由)
→ dev_queue_xmit(qdisc)
→ ndo_start_xmit(驱动)
接收方向反过来:
驱动中断 → napi_poll → netif_receive_skb(链路层分用)
→ ip_rcv(网络层)
→ tcp_v4_rcv(传输层分用)
→ sk_receive_queue → 唤醒进程
每一层只改自己那一层的首部,并把 skb 的指针前移/后移,数据本身不搬。这是 4.8.17 里 skb_push/pull 的意义,也是「分层不牺牲性能」的实现基础。
分层不是免费的:每层一次函数调用、一次首部检查、一次分用查找。一个 64 字节的包走完四层,固定开销可能超过载荷本身。DPDK/XDP 的思路就是「绕过部分层」:XDP 在链路层之前处理,DPDK 在用户态重实现协议栈。它们用「失去通用性」换「减少层数」,适合单一功能的转发面,不适合通用主机协议栈。
4.1.14 程序一:算一张 goodput 表
这段程序把 4.1.12 的字节账变成可执行的计算:给定 MTU、是否 VLAN、IP 版本、是否 TLS,打印不同载荷下的线上字节与 goodput。它验证「小包的有效吞吐远低于链路速率」。
/* goodput.c:按封装组合计算 goodput */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct encap {
int eth; /* 14 字节以太网头 */
int vlan; /* 4 字节 802.1Q */
int ip; /* 20 或 40 */
int l4; /* TCP 20 + 选项 12,或 UDP 8 */
int tls; /* TLS 记录开销:5 + 16 + 1 */
};
static int wire_len(const struct encap *e, int payload, int mtu)
{
int hdr = e->eth + e->vlan + e->ip + e->l4 + e->tls;
int len = hdr + payload;
int min_frame = 60; /* 不含 FCS 的最小帧 */
if (len < min_frame)
len = min_frame; /* 以太网填充 */
if (len > mtu + 14)
return -1; /* 需要分片,本程序不处理 */
return len + 4; /* 加 FCS */
}
int main(int argc, char **argv)
{
int mtu = argc > 1 ? atoi(argv[1]) : 1500;
int ipv6 = argc > 2 && strcmp(argv[2], "v6") == 0;
int use_tls = argc > 3 && strcmp(argv[3], "tls") == 0;
struct encap e = {
.eth = 14,
.vlan = 0,
.ip = ipv6 ? 40 : 20,
.l4 = 32, /* TCP 20 + 时间戳/SACK 12 */
.tls = use_tls ? 22 : 0,
};
int payloads[] = { 64, 128, 256, 512, 1024, 1460, 8192, 16384 };
printf("MTU=%d %s %s\n", mtu, ipv6 ? "IPv6" : "IPv4", use_tls ? "TLS" : "plain");
printf("%8s %10s %10s %8s\n", "payload", "wire", "overhead", "goodput");
for (size_t i = 0; i < sizeof payloads / sizeof payloads[0]; i++) {
int w = wire_len(&e, payloads[i], mtu);
if (w < 0) {
printf("%8d %10s %10s %8s\n", payloads[i], "frag", "-", "-");
continue;
}
double ratio = 100.0 * payloads[i] / w;
printf("%8d %10d %9.1f%% %7.1f%%\n", payloads[i], w,
100.0 - ratio, ratio);
}
return 0;
}
运行:
cc -std=c17 -Wall -Wextra -Werror -O2 goodput.c -o goodput
./goodput 1500 v4
./goodput 1500 v4 tls
./goodput 9000 v4
MTU=1500 IPv4 plain
payload wire overhead goodput
64 134 52.2% 47.8%
128 194 34.0% 66.0%
512 578 11.4% 88.6%
1460 frag - -
8192 frag - -
(wire_len 对小于 60 字节的帧填充到 60,再加 4 字节 FCS;64 字节载荷的 wire=134 已含 14 以太网 + 20 IP + 32 TCP + 4 FCS。真实线上还有前导码 8 字节与帧间隙 12 字节,链路利用率还要再降。)
关键行是 min_frame 与 mtu + 14:前者体现「小包要填充到 60 字节」,后者体现「超过 MTU 要分片」。把 payload 改成 8192,程序会打印 frag——在 MTU 1500 下,8192 字节的应用写必须由 TCP 分段(MSS 1460),而不是 IP 分片;程序里没有建模 TCP 分段,只建模了单包封装,这正是「GSO 包在网卡前是 64 KB、在网卡后是 1500」的区别。
4.1.15 程序二:用 TCP 握手估 RTT 与 BDP
ping 测的是 ICMP RTT,很多网络对 ICMP 限速或屏蔽。更可靠的办法是测 TCP 握手:connect() 返回的时间约等于一个 RTT(SYN → SYN-ACK)。
#!/usr/bin/env python3
"""bdp.py:用 TCP 握手估 RTT,并算不同带宽下的 BDP(Linux/macOS 通用)"""
import socket
import sys
import time
def tcp_rtt(host: str, port: int, samples: int = 5) -> float:
best = float("inf")
for _ in range(samples):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(2.0)
t0 = time.perf_counter()
try:
s.connect((host, port))
except OSError as exc:
print(f"connect {host}:{port} 失败: {exc}", file=sys.stderr)
s.close()
return -1.0
dt = (time.perf_counter() - t0) * 1000.0
best = min(best, dt)
s.close()
return best
def main() -> None:
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 80
rtt = tcp_rtt(host, port)
if rtt < 0:
sys.exit(1)
print(f"{host}:{port} 握手 RTT(最小值)≈ {rtt:.3f} ms")
print(f"{'带宽':>10} {'BDP':>14} {'64KB窗口可利用率':>18}")
for mbps in (100, 1000, 10000, 40000):
bdp_bytes = mbps * 1e6 / 8 * (rtt / 1000.0)
util = min(1.0, 65535 / bdp_bytes) * 100 if bdp_bytes else 100
print(f"{mbps:>8} Mbps {bdp_bytes / 1024:>12.1f} KiB {util:>16.2f}%")
if __name__ == "__main__":
main()
运行:
python3 bdp.py 127.0.0.1 18080
# 对一个跨地域服务测(示例,需外网)
python3 bdp.py example.com 443
example.com:443 握手 RTT(最小值)≈ 12.480 ms
带宽 BDP 64KB窗口可利用率
100 Mbps 152.3 KiB 42.00%
1000 Mbps 1523.4 KiB 4.20%
10000 Mbps 15234.4 KiB 0.42%
40000 Mbps 60937.5 KiB 0.11%
这张表是「为什么长肥管道需要窗口缩放」的定量答案:RTT 12.5 ms、1 Gbps 时,BDP 约 1.5 MB,而 16 位窗口只有 64 KB,最多只能发挥 4.2% 的带宽。窗口缩放把上限抬到约 1 GB,才让高 BDP 链路可用。
注意程序用「最小值」而不是平均值:握手 RTT 受排队和调度影响,最小值最接近传播+传输时延,平均值会把拥塞排队算进去。
4.1.16 实验:把分层地图和真实字节对上
实验一用 tcpdump -e -X 逐层标注首部,实验二用 ss 与 bpftrace 把「时延/BDP」落到一条真实连接上。
实验一:一帧的逐层拆解。
# -e 显示以太网头,-X 打十六进制,-c 1 只抓一个包
sudo tcpdump -i lo -e -nn -X -c 1 'tcp port 18080'
# 抓一个带 VLAN 的包(需要 veth/物理网卡;回环没有 VLAN)
sudo tcpdump -i eth0 -e -nn -X -c 1 'vlan and tcp port 443'
回环上的典型输出:
12:00:00.000000 af:00:00:00:00:00 > af:00:00:00:00:00, ethertype IPv4 (0x0800), length 74:
0x0000: 4500 003c 1c46 4000 4006 b1e6 7f00 0001 E..<.F@.@.......
0x0010: 7f00 0001 9c40 1f90 0000 0001 0000 0000 .....@..........
0x0020: 5018 2000 0000 0000 0000 0000 P. ..........
逐行读:
ethertype IPv4是链路层的分用键;回环的 MAC 是00:00:00:00:00:00(或af:...的伪 MAC),没有真实硬件地址。4500是 IP 首部:版本 4、首部长度 5(20 字节)、TOS 0;003c是总长 60。1c46是标识;4000是标志/片偏移(DF 置位);40是 TTL 64;06是协议号 TCP;b1e6是首部校验和。7f00 0001两次出现,是源/目的 127.0.0.1。9c40 1f90是源/目的端口(40000 → 8080)。5018 2000是 TCP 首部:50表示首部 20 字节,18是 PSH+ACK,2000是窗口 8192。
实验二:时延与窗口。
# 握手 RTT 与 cwnd/snd_wnd
ss -tinm 'dport = :443'
# 用 ping 对比 ICMP RTT(可能被限速)
ping -c 5 example.com
# 用 bpftrace 看收包在协议栈里花了多久
sudo bpftrace -e '
kprobe:ip_rcv { @start[tid] = nsecs; }
kretprobe:ip_rcv /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }
interval:s:5 { print(@ns); clear(@ns); exit(); }'
判据:
ss -tinm的rtt与ping的avg差很多:说明 ICMP 与 TCP 走了不同队列/策略,或 ping 被限速。cwnd × mss远小于 BDP:窗口是瓶颈,查snd_wnd(接收端)与cwnd(网络)。ip_rcv的直方图长尾到几十微秒:netfilter 规则过多或 cgroup BPF 拖慢。
4.1.17 本章陷阱清单
- 把带宽当吞吐:带宽是链路速率,吞吐是实际 goodput;小包、协议开销、丢包都会让吞吐远低于带宽。
- 把 RTT 当「带宽的倒数」:RTT 由传播距离与排队决定,升级带宽不会降低 RTT。
- 用 16 位窗口算高 BDP 链路:64 KB 在 1 Gbps/12 ms 上只能发挥 4%,必须窗口缩放。
- 忽略以太网最小帧填充:小于 60 字节的帧要填充,小包开销比首部账更大。
- 把 MTU 当 MSS:MSS = MTU − IP 首部 − TCP 首部;IPv6 要减 40。
- 在回环上测 goodput 推断物理网卡:回环 MTU 65536、无 VLAN、无前导码,开销模型完全不同。
- 只看首部字节,不算前导码与帧间隙:每个以太网帧还有 8 字节前导码 + 12 字节 IFG,线速利用率上限约 98.7%。
- 认为分层一定慢:分层的成本是函数调用与分用查找,DPDK/XDP 绕过分层换性能,但失去通用性。
- 把
ping的 RTT 当 TCP RTT:ICMP 可能被限速、走不同队列,甚至被中间盒伪造。 - 用一次
ss快照判断窗口:窗口随 ACK 动态变化,要看趋势或tcp_info采样。 - 忽略 TLS/HTTP2 的每记录开销:TLS 每条记录 22 字节、HTTP/2 每帧 9 字节,小响应下占比很高。
- 把「带宽升级」当性能优化首选:先看
app_limited、cwnd、snd_wnd与重传,很多时候瓶颈不在带宽。
4.1.18 时延的四个分量:一次可复现的测量
这一节把 4.1.12 的时延公式变成可测量的四步。目标是让你在看到「慢」时,能说出慢在哪一段,而不是笼统地说「网络问题」。
| 分量 | 典型量级 | 怎么测 | 能否优化 |
|---|---|---|---|
| 传输时延 | 1.2 µs(1500 B @ 10 Gbps) | iperf3 看线速,按帧长算 | 提高带宽、加大包 |
| 传播时延 | 5 ms / 1000 km | ping 的 RTT 下限 | 换路径/CDN,无法超过光速 |
| 排队时延 | 0 到数百 ms | tc -s qdisc 的 backlog、ss 的 rtt 方差 | 降载、AQM、调队列 |
| 处理时延 | 几 µs 到几十 µs | bpftrace 打点、perf | 减少 netfilter/BPF、绑核 |
一次测量练习:
# 1) 传播时延的下限:ping 的最小 RTT(越小越接近传播+传输)
ping -c 20 -q example.com | tail -1
# 2) 排队时延:给回环加 50 ms 延迟,观察 RTT 与 cwnd 的变化
sudo tc qdisc add dev lo root netem delay 50ms
ping -c 5 127.0.0.1
ss -tinm state established | head -3
sudo tc qdisc del dev lo root
# 3) 处理时延:抓 ip_rcv 的耗时分布
sudo bpftrace -e 'kprobe:ip_rcv { @s[tid]=nsecs; }
kretprobe:ip_rcv /@s[tid]/ { @ns=hist(nsecs-@s[tid]); delete(@s[tid]); }
interval:s:3 { print(@ns); exit(); }'
读法:
ping的min接近「传播 + 传输 + 固定处理」,avg - min主要是排队与调度。netem delay 50ms后 RTT 变成约 100 ms(往返各 50 ms),ss的rtt同步变大,cwnd可能因 BDP 变大而显得不够。ip_rcv的直方图若在 10 µs 以上,先查 netfilter 规则数与 cgroup BPF,再看是否开了CONFIG_PREEMPT_NONE(软中断不能被抢占)。
排队时延是最容易被忽视的一项。在没有拥塞的空闲链路上,RTT 几乎等于传播时延;一旦交换机队列堆满,RTT 会先于丢包上升。看到「RTT 缓慢上升、吞吐不变」,通常是缓冲区在积累(bufferbloat),而不是链路变长。这时 tc -s qdisc 的 backlog 会持续非 0,ss 的 rtt 方差也会变大。
4.1.19 首部字段速查:写过滤器时该数第几个字节
4.1.3 给的是封装图,这一节给一张「偏移表」。它的用途很具体:当你要写 tcpdump 过滤器、用 bpftrace 读某个字段、或者手工解析一段十六进制时,能立刻说出「这个字段在第几个字节、占几位、掩码是多少」。
| 协议 | 字段 | 位宽 | 相对偏移 | 常用过滤器 | 说明 |
|---|---|---|---|---|---|
| 以太网 | 目的 MAC | 48 | 0 | ether[0:6] | 下一跳的 MAC,不是最终目的 |
| 以太网 | 源 MAC | 48 | 6 | ether[6:6] | 发送方网卡 |
| 以太网 | 类型 | 16 | 12 | ether[12:2] | 0x0800 IPv4、0x86dd IPv6、0x0806 ARP、0x8100 VLAN |
| IPv4 | 版本 + IHL | 4 + 4 | 0 | ip[0] | 0x45 = v4 + 20 字节首部 |
| IPv4 | DSCP + ECN | 6 + 2 | 1 | ip[1] | 低 2 位是 ECN |
| IPv4 | 总长度 | 16 | 2 | ip[2:2] | 首部 + 载荷,最大 65535 |
| IPv4 | 标识 | 16 | 4 | ip[4:2] | 分片归组用;DF 包可为 0 |
| IPv4 | 标志 + 片偏移 | 3 + 13 | 6 | ip[6:2] | 0x4000 DF、0x2000 MF、低 13 位偏移 |
| IPv4 | TTL | 8 | 8 | ip[8] | 每跳减 1 |
| IPv4 | 协议 | 8 | 9 | ip[9] | 6 TCP、17 UDP、1 ICMP |
| IPv4 | 首部校验和 | 16 | 10 | ip[10:2] | 只覆盖首部 |
| IPv4 | 源地址 | 32 | 12 | ip[12:4] | NAT 会改 |
| IPv4 | 目的地址 | 32 | 16 | ip[16:4] | NAT 会改 |
| TCP | 源端口 | 16 | 0 | tcp[0:2] | 相对 TCP 首部 |
| TCP | 目的端口 | 16 | 2 | tcp[2:2] | 分用键 |
| TCP | 序号 | 32 | 4 | tcp[4:4] | 字节流编号 |
| TCP | 确认号 | 32 | 8 | tcp[8:4] | 期望的下一个字节 |
| TCP | 首部长度 + 标志 | 4 + 12 | 12 | tcp[12:2] | 高 4 位是长度,单位 4 字节 |
| TCP | 标志字节 | 8 | 13 | tcp[13] | 0x02 SYN、0x10 ACK、0x01 FIN、0x04 RST |
| TCP | 窗口 | 16 | 14 | tcp[14:2] | 未缩放时的接收窗口 |
| TCP | 校验和 | 16 | 16 | tcp[16:2] | 覆盖伪首部 + 首部 + 载荷 |
| TCP | 紧急指针 | 16 | 18 | tcp[18:2] | 基本废弃 |
| UDP | 源端口 | 16 | 0 | udp[0:2] | 可填 0 |
| UDP | 目的端口 | 16 | 2 | udp[2:2] | 分用键 |
| UDP | 长度 | 16 | 4 | udp[4:2] | 首部 + 载荷 |
| UDP | 校验和 | 16 | 6 | udp[6:2] | IPv4 可填 0 表示不校验 |
这张表有两个容易踩的偏移陷阱:
ip[...]的偏移从 IP 首部第一个字节算起,tcp[...]从 TCP 首部算起。但 TCP 首部的位置取决于 IHL:IHL=5 时 TCP 从帧偏移 34 开始,IHL=6 时从 38 开始。tcpdump的tcp[...]关键字会自动按 IHL 计算,所以写tcp[13]不用手工加 IHL;如果你用ether[34]这种绝对偏移,就会在带选项的包上读错。ip[6:2]是一个 16 位大端整数。0x4000是 DF,0x2000是 MF,低 13 位是片偏移。所以「分片」的过滤器是ip[6:2] & 0x3fff != 0,不是ip[6] & 0x20(后者只看了 MF 的高字节)。
几个常用过滤器与它们的「字节等价写法」:
| 语义 | 可读写法 | 字节写法 |
|---|---|---|
| TCP 443 | tcp port 443 | tcp[0:2] = 443 or tcp[2:2] = 443 |
| 只抓 SYN | tcp[tcpflags] & tcp-syn != 0 | tcp[13] & 0x02 != 0 |
| 只抓 ICMP | icmp | ip[9] = 1 |
| 只抓 ARP | arp | ether[12:2] = 0x0806 |
| VLAN 100 | vlan 100 | ether[12:2] = 0x8100 and ether[14:2] & 0x0fff = 100 |
| 分片 | ip[6:2] & 0x3fff != 0 | 同上(没有更短的可读别名) |
| DF 置位 | ip[6:2] & 0x4000 != 0 | 同上 |
tcpdump -d 可以把过滤器编译成 BPF 指令,-ddd 输出十进制指令列表。想确认「过滤器到底匹配了哪几个字节」,这是最直接的办法:
# 看 tcp[13] & 2 != 0 编译成哪些 BPF 指令
tcpdump -i lo -d 'tcp[13] & 2 != 0'
# 看 VLAN 过滤器如何先检查 0x8100,再读 TCI
tcpdump -i lo -d 'vlan 100'
读 -d 的输出不用懂每条指令,只要确认它先 ldh 了哪个偏移、再和什么常量比较。能对上偏移表,就说明过滤器写对了。
4.1.20 端到端时延:把四个分量算成一个数
4.1.8 给了时延的四个分量,4.1.18 给了测量方法。这一节回答一个更实际的问题:给定一条路径的距离、速率、负载和处理开销,怎么算出「一个 1500 字节的包从 A 到 B 要多久」,以及哪一项占主导。
先统一符号。一个包经过 n 跳,每跳的距离为 d_i,链路速率为 C_i,包长为 L 字节,处理时延为 P_i,到达速率为 λ_i:
传输时延 tx_i = 8L / C_i
传播时延 prop_i = d_i / v (v ≈ 2×10^8 m/s,光纤)
排队时延 q_i = ρ_i / (μ_i (1-ρ_i)),ρ_i = λ_i/μ_i,μ_i = C_i/(8L)
处理时延 proc_i = P_i
端到端 T = Σ(tx_i + prop_i + q_i + proc_i)
排队公式是 M/M/1 的平均排队时间。它有两个边界:ρ → 0 时 q → 0;ρ → 1 时 q → ∞。真实网络不是 M/M/1(包长不是指数分布、到达不是泊松),但这个公式足以说明「负载接近 1 时,排队会指数级上升」,以及「为什么拥塞时 RTT 先涨、丢包后到」。
把默认参数代进去:3 跳,每跳 1000 km、1 Gbps、负载 0.7、处理 5 µs。
tx = 1500×8 / 1e9 = 12 µs
prop = 1000×1000 / 2e8 = 5 ms
μ = 1e9 / 12000 = 83333 包/秒
q = 0.7 / (83333×0.3) = 28 µs
T = 3×(12 + 5000 + 28 + 5) µs = 15.135 ms
单程 15.135 ms,RTT 约 30.27 ms。传播时延 15 ms 占了 99% 以上,传输、排队、处理加起来不到 0.2 ms。长距离链路上,优化协议栈的收益远小于换一条更近的路径;这也是 CDN 把缓存放到离用户 5 ms 的地方,比把服务器 CPU 升级一倍更有效的原因。
把链路换成 10 Gbps,其他不变:
tx = 1.2 µs
μ = 833333 包/秒
q = 0.7 / (833333×0.3) = 2.8 µs
T = 3×(1.2 + 5000 + 2.8 + 5) µs = 15.027 ms
只少了 0.108 ms。带宽翻十倍,时延几乎不变——因为瓶颈是光速,不是速率。这个结论在 4.1.8 出现过一次,这里用数字再确认一次。
下面这段程序把公式变成可执行的计算器。每跳参数用 距离,速率,负载,处理时延 表示,输出每一项的微秒数和总计,并算 BDP。
#!/usr/bin/env python3
"""e2e_delay.py:逐跳累加传输、传播、排队、处理时延,并算 BDP。
用法:
./e2e_delay.py # 默认 3 跳,每跳 1000 km / 1 Gbps / 负载 0.7
./e2e_delay.py 1000,1,0.7,5 200,10,0.3,2
每跳参数:距离(km),速率(Gbps),负载(0-1),处理时延(us)
"""
import sys
LIGHT = 2.0e8 # 光纤中的光速,m/s
PKT_BYTES = 1500 # 单个 IP 包长度
def one_hop(d_km: float, rate_gbps: float, load: float, proc_us: float):
bits = PKT_BYTES * 8
rate = rate_gbps * 1e9
tx_us = bits / rate * 1e6
prop_us = d_km * 1000.0 / LIGHT * 1e6
mu = rate / bits # 包/秒
if not 0.0 <= load < 1.0:
raise ValueError(f"load 必须在 [0,1) 内: {load}")
queue_us = load / (mu * (1.0 - load)) * 1e6
return tx_us, prop_us, queue_us, proc_us
def main() -> int:
if len(sys.argv) > 1:
hops = []
for spec in sys.argv[1:]:
parts = spec.split(",")
if len(parts) != 4:
print(f"参数格式错误: {spec}", file=sys.stderr)
return 2
hops.append(tuple(float(x) for x in parts))
else:
hops = [(1000.0, 1.0, 0.70, 5.0)] * 3
total = [0.0, 0.0, 0.0, 0.0]
print(f"{'hop':>3} {'serialize':>10} {'propagate':>10} {'queue':>10} {'process':>9}")
print(f"{'':>3} {'us':>10} {'us':>10} {'us':>10} {'us':>9}")
for i, (d, r, load, p) in enumerate(hops, 1):
v = one_hop(d, r, load, p)
total = [a + b for a, b in zip(total, v)]
print(f"{i:>3} {v[0]:>10.2f} {v[1]:>10.2f} {v[2]:>10.2f} {v[3]:>9.2f}")
print(f"{'sum':>3} {total[0]:>10.2f} {total[1]:>10.2f} {total[2]:>10.2f} {total[3]:>9.2f}")
one_way_ms = sum(total) / 1000.0
rtt_ms = one_way_ms * 2.0
bottleneck = min(r for _, r, _, _ in hops)
bdp_bytes = bottleneck * 1e9 * rtt_ms / 1000.0 / 8.0
print(f"\none-way = {one_way_ms:.3f} ms RTT = {rtt_ms:.3f} ms")
print(f"bottleneck = {bottleneck:g} Gbps BDP = {bdp_bytes/1e6:.2f} MB "
f"({bdp_bytes/1024/1024:.2f} MiB)")
print(f"64 KiB 窗口可利用率 = {min(1.0, 65535.0/bdp_bytes)*100:.2f}%")
return 0
if __name__ == "__main__":
raise SystemExit(main())
运行:
python3 e2e_delay.py
python3 e2e_delay.py 1000,10,0.7,5 1000,10,0.7,5 1000,10,0.7,5
hop serialize propagate queue process
us us us us
1 12.00 5000.00 28.00 5.00
2 12.00 5000.00 28.00 5.00
3 12.00 5000.00 28.00 5.00
sum 36.00 15000.00 84.00 15.00
one-way = 15.135 ms RTT = 30.270 ms
bottleneck = 1 Gbps BDP = 3.78 MB (3.61 MiB)
64 KiB 窗口可利用率 = 1.73%
最后一行是 BDP 的直接用途:RTT 30.27 ms、瓶颈 1 Gbps 时,管道里能装 3.78 MB;16 位窗口只有 64 KB,最多发挥 1.73% 的带宽。这就是 4.6 讲窗口缩放的原因。
程序里有两个刻意的选择。用 min(rate) 算 BDP,而不是第一跳的速率:端到端管道由最慢的一跳决定,虽然排队时延按每跳分别算。用 RTT 而不是单程时延:BDP 的定义是「发送方在收到第一个 ACK 之前能发多少数据」,所以乘的是往返时间。
BDP = 10e9 × 0.08 / 8 = 100 MB。如果接收窗口只有 6 MB(默认 tcp_rmem 上限的常见值),单流吞吐上限 = 6 MB / 80 ms = 75 MB/s = 600 Mbps,只有带宽的 6%。要跑满,窗口至少 100 MB,或者用多条流并行。这个例子说明「带宽升级」和「单流吞吐」是两件事。
4.1.21 实验:ping、traceroute、ss 三件套互证
ping 测 ICMP、traceroute 测路径、ss 测 TCP 端点状态。三者测的不是同一个东西,所以数值对不上是常态;对得上反而说明路径简单。这个实验用一条脚本把三个视角放在一起,训练「看到差异能解释」的能力。
#!/usr/bin/env bash
# rtt_trio.sh:用 ping / traceroute / ss 三种视角测同一条路径
set -euo pipefail
HOST="${1:-127.0.0.1}"
PORT="${2:-443}"
command -v ping >/dev/null || { echo "需要 ping" >&2; exit 1; }
echo "== 1) ICMP: ping =="
ping -c 20 -q -W 2 "$HOST" | tail -1
echo "== 2) path: traceroute/tracepath =="
if command -v tracepath >/dev/null; then
tracepath -n -m 8 "$HOST" || true
elif command -v traceroute >/dev/null; then
traceroute -n -m 8 "$HOST" || true
else
echo "未安装 tracepath/traceroute,跳过"
fi
echo "== 3) TCP: ss -ti =="
if command -v ss >/dev/null; then
ss -tinm "dst $HOST:$PORT" || true
else
echo "未安装 ss,跳过"
fi
用法:
chmod +x rtt_trio.sh
./rtt_trio.sh example.com 443
# 如果没有已建立的连接,先在一个终端保持一条:
# curl -sS -o /dev/null https://example.com/
示例输出(数值因环境而异):
== 1) ICMP: ping ==
20 packets transmitted, 20 received, 0% packet loss, time 19032ms
rtt min/avg/max/mdev = 11.982/12.345/13.001/0.321 ms
== 2) path: traceroute/tracepath ==
1?: [LOCALHOST] pmtu 1500
1: 192.168.1.1 0.512ms
2: 10.0.0.1 2.104ms
3: 203.0.113.9 11.820ms
== 3) TCP: ss -ti ==
ESTAB 0 0 192.168.1.10:51234 93.184.216.34:443
cubic wscale:7,7 rto:204 rtt:12.4/0.8 ato:40 mss:1448 cwnd:10 ...
三个数对不上时,按这张表解释:
| 现象 | 可能原因 | 下一步 |
|---|---|---|
ping 12 ms,ss 的 rtt 40 ms | TCP 连接空闲过,rtt 是历史平滑值;或应用层排队 | ss -ti 看 lastsnd/lastrcv,在传输中再采一次 |
ping 12 ms,traceroute 第 3 跳 11.8 ms | 正常:第 3 跳已经接近目的 | — |
traceroute 中间全是 *,最后有响应 | 中间路由器不回 ICMP 或限速 | 不代表断,继续看后续跳 |
ss 的 rtt 0.05 ms,应用延迟 40 ms | 回环/Nagle/延迟 ACK | 看 4.6.8,查 TCP_NODELAY |
ping 通,ss 没有连接 | 连接没建立,或 ss 过滤条件写错 | 先 ss -tn 不加过滤,确认五元组 |
脚本里用 ping -W 2 是 Linux 语义(秒);macOS 的 -W 单位是毫秒,跨平台跑要改。tracepath 比 traceroute 更适合这个实验,因为它在每跳顺带做 PMTU 探测,输出里的 pmtu 能直接告诉你路径 MTU。
- 先
ping:确认三层可达,拿到 RTT 的下限。ICMP 被禁时直接跳到第 2 步。 - 再
traceroute/tracepath:确认路径和 PMTU,区分「不通」与「绕路」。 - 最后
ss:确认 TCP 端点状态、窗口、重传。只有连接存在时才有意义。
4.1.22 内核路径:一个帧从 ip_rcv 到 tcp_v4_rcv 的十个检查点
4.1.13 把四层映射到结构体和操作表,这一节跟一遍接收路径,重点不是「调用了哪些函数」,而是「每一步可能因为什么被丢」。排障时你看到的 InErrors、InDiscards、InUnknownProtos,都对应这条链上的某个检查点。
| # | 阶段 | 函数 | 文件 | 检查/动作 | 失败计数 |
|---|---|---|---|---|---|
| 1 | 驱动收包 | napi_poll | 驱动 | 从 RX ring 取描述符,建 skb | rx_dropped |
| 2 | GRO 合并 | napi_gro_receive | net/core/dev.c | 合并同流的小包 | — |
| 3 | 链路分用 | __netif_receive_skb_core | 同上 | 按 skb->protocol 查 ptype_base | rx_dropped |
| 4 | 剥以太网头 | eth_type_trans | net/ethernet/eth.c | 前移 14 字节,设 pkt_type | — |
| 5 | IPv4 入口 | ip_rcv → ip_rcv_core | net/ipv4/ip_input.c | 版本、IHL、长度、首部校验和 | InHdrErrors |
| 6 | 路由决策 | ip_rcv_finish → ip_route_input_noref | 同上 / route.c | 查 FIB,决定本机/转发/丢弃 | InAddrErrors |
| 7 | 分片重组 | ip_local_deliver → ip_defrag | 同上 / ip_fragment.c | 有分片就先重组 | ReasmFails |
| 8 | 协议分用 | ip_local_deliver_finish | 同上 | 查 inet_protos[protocol] | InUnknownProtos |
| 9 | TCP 入口 | tcp_v4_rcv | net/ipv4/tcp_ipv4.c | 查 ehash/lhash2,找 socket | TCPReqQFullDrop 等 |
| 10 | 交付应用 | tcp_rcv_established → tcp_data_queue | net/ipv4/tcp_input.c | 序号检查、入接收队列、唤醒 | TCPOFOQueue/TCPOFODrop |
ip_rcv() 本身很短,真正的检查在 ip_rcv_core():
/* net/ipv4/ip_input.c,Linux 6.8,节选 */
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt,
struct net_device *orig_dev)
{
struct net *net = dev_net(dev);
skb = ip_rcv_core(skb, net);
if (skb == NULL)
return NET_RX_DROP;
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, net, NULL, skb,
dev, NULL, ip_rcv_finish);
}
ip_rcv_core() 依次检查:
pskb_may_pull(skb, sizeof(struct iphdr)):连 20 字节首部都不够,丢。iph->ihl < 5 || iph->version != 4:版本或首部长度非法,丢。ip_fast_csum((u8 *)iph, iph->ihl):首部校验和错误,丢。len = ntohs(iph->tot_len);skb->len < len说明被截断,丢;len < ihl*4说明长度自相矛盾,丢。skb->len > len时pskb_trim_rcsum(skb, len),把以太网填充裁掉。
这五步对应 InHdrErrors 的增长。如果 tcpdump 能抓到包但 InHdrErrors 在涨,说明包到了协议栈但首部坏了——先查网卡 offload、VLAN 剥离、抓包点的快照长度,不要先怀疑上层。
ip_rcv_finish() 做路由决策。它调用 ip_route_input_noref(),根据 fib_result 把 skb 分成三类:
RTN_LOCAL:目的地址是本机,走ip_local_deliver()。RTN_UNICAST且ip_forward打开:走ip_forward(),TTL 减 1,重新查路由。RTN_BROADCAST/RTN_MULTICAST:走广播/组播路径,可能复制多份。
没有路由时,ip_rcv_finish() 会调用 icmp_send() 回 type 3 code 0(网络不可达),并增加 InAddrErrors。如果 InAddrErrors 在涨,先看 ip route 是否有默认路由,而不是看 TCP。
ip_local_deliver() 先处理分片(4.3.11 会展开),再交给 ip_local_deliver_finish()。后者查 inet_protos[protocol],查不到就回 ICMP type 3 code 2,并增加 InUnknownProtos。抓到一个 proto=47(GRE)的包但没有 GRE 模块,就是这条路径丢的。
最后是 tcp_v4_rcv():它先用四元组查 ehash(已建立连接),查不到再查 lhash2(监听 socket)。两步都查不到就回 RST,并增加 TCPReqQFullDrop 或 TCPAbortOnNoListener 之类的计数(具体计数器随内核版本不同)。找到 socket 后,tcp_rcv_established() 检查序号,tcp_data_queue() 把数据放进 sk_receive_queue,最后 sk_data_ready() 唤醒 epoll_wait/read。
# 一次看完整条链的丢弃计数
nstat -az | grep -E 'InHdrErrors|InAddrErrors|InUnknownProtos|InDiscards|ReasmFails|TCPReqQFullDrop'
# 用 tracepoint 看内核丢包原因(Linux 5.10+ 的 skb_drop_reason)
sudo bpftrace -e 'tracepoint:skb:kfree_skb { @[args->reason] = count(); }'
bpftrace 的 args->reason 是 enum skb_drop_reason,输出可能是数字或符号;常见值对应 SKB_DROP_REASON_IP_INHDR、IP_NOPROTO、IP_CSUM、TCP_INVALID_SEQUENCE、SOCKET_FILTER 等。先用它把「丢在哪一层」缩小,再去看那一层的代码,比盲目加日志快得多。
tcpdump 通过 AF_PACKET 在 __netif_receive_skb_core() 里注册 packet_type,拿到的是「交给协议栈之前」的帧副本。ip_rcv_core() 的丢弃发生在它之后,所以 tcpdump 照样能看到。反过来,被网卡硬件过滤(目的 MAC 不是本机、VLAN 过滤、RSS 分流到别的队列)的帧,tcpdump 看不到。这个顺序解释了「抓包有、协议栈没收到」和「抓包没有、但应用收到了」两类怪现象。
4.1.23 八个新坑:把「测出来的数」用对
- 把
ping -s当 IP 包长:-s是 ICMP 载荷;IPv4 加 28 字节(20 IP + 8 ICMP),IPv6 加 48 字节。算路径 MTU 时漏掉这 28/48 字节,会得出「MTU 1472」这种错误结论。 - 把
traceroute每跳 RTT 相加当端到端 RTT:每跳探针是独立发的,路径可能不同,路由器对 ICMP 的限速也不同;端到端 RTT 要用ping或 TCP 握手测。 - 把
ss的rtt当应用层延迟:rtt是 TCP 的平滑 RTT,不含应用排队、调度、Nagle/延迟 ACK;应用慢要看app_limited和lastrcv。 - 把 BDP 当 socket 缓冲区目标:BDP 是在途字节,缓冲区还要容纳「已收到但应用没读」的数据;
tcp_rmem自动调优会自己找平衡,手动设死反而可能更慢。 - 在回环上验证 MTU/分片:
lo的 MTU 是 65536,永远不会因为 1500 而分片;MTU 实验必须在 veth 或物理网卡上做。 - 把以太网 FCS 计入 MTU:MTU 是 L3 载荷,FCS 是 L2 尾部;
MTU 1500对应帧长 1518(含 14 首部 + 4 FCS),不是 1500。 - 用线速算小包 PPS:64 字节帧加 20 字节前导码/帧间隙是 84 字节,1 Gbps 线速约 1.488 Mpps,不是 1.953 Mpps;小包场景先算 PPS,再算带宽。
- 把
tcpdump的length当线上字节:它不含 FCS、前导码、帧间隙;开启 TSO/GSO 时抓到的还是 64 KB 的「超级包」,网卡才切分。
4.1.24 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出四层各自负责什么 | 能举出每层一个协议和一个设备 | 4.1.2 |
| 画出一次请求的封装过程 | 标出每层首部长度与关键字段 | 4.1.3 |
| 用 C 量出三层首部的偏移 | 能解释 off=14/34/54 怎么来的 | 4.1.6 |
| 解释 MTU 与 MSS 的关系 | 会算 IPv4/IPv6 下的典型 MSS | 4.1.3、4.1.7 |
| 用 BDP 判断窗口是否够大 | 给定带宽和 RTT 能算出所需窗口 | 4.1.8 |
| 用 tcpdump 同时看到三层首部 | 能指出输出里哪段是哪层 | 4.1.9 |
说清 -i any 与 -i eth0 的差别 | 知道链路层首部在哪一层丢失 | 4.1.9 |
| 算一次封装的 goodput | 能说出 64 B 与 1460 B 的差别 | 4.1.12 |
| 把四层落到结构体与函数 | 知道 sk_prot/packet_type 的作用 | 4.1.13 |
| 用 TCP 握手估 RTT 与 BDP | 能算出窗口利用率 | 4.1.15 |
| 区分时延的四个分量 | 知道哪一项能优化 | 4.1.18 |
| 用偏移表写 tcpdump 过滤器 | 能解释 ip[...] 与 tcp[...] 的相对基准 | 4.1.19 |
| 算一条路径的端到端时延 | 能指出传播时延占主导 | 4.1.20 |
| 用 ping/traceroute/ss 互证 | 能解释三个数值的差异 | 4.1.21 |
| 跟一遍 ip_rcv 到 tcp_v4_rcv | 知道每一步的丢包计数 | 4.1.22 |
Q1 一个以太网帧长 1514 字节(含 14 字节首部和 4 字节 FCS),MTU 1500。它承载的 TCP 段里,应用数据最多多少字节?如果加了 12 字节的 TCP 时间戳选项呢?
Q2 为什么 ping -s 1472 能通就说明路径 MTU 至少 1500?如果对端是 IPv6,这个数应该是多少?
Q3 一条 10 Gbps、RTT 20 ms 的链路,BDP 是多少字节?如果 TCP 窗口只有 256 KB,吞吐上限是多少?
Q4 tcpdump -i any 抓到的包没有 MAC 地址,这是工具的限制还是内核的限制?为什么?
Q5 同一个 HTTP 请求,走 127.0.0.1 和走真实网卡,协议栈路径差在哪几层?
Q6 端口号只有 16 位,为什么一台服务器能同时接受几万条连接?瓶颈到底在哪?
答案
A1 1514 − 14 − 4 = 1496 字节是 IP 包,减去 20 字节 IP 首部、20 字节 TCP 首部,剩 1456;再减 12 字节选项(选项按 4 字节对齐),应用数据最多 1444 字节。注意这里用的是「帧长 1514」,如果按 MTU 1500 算则是 1460 和 1448。
A2 ping 的 -s 是 ICMP 载荷,加 8 字节 ICMP 首部、20 字节 IP 首部,共 1472 + 28 = 1500,正好等于 MTU。能通说明这一整包没有被分片就过去了。IPv6 的 IP 首部是 40 字节,ICMPv6 首部 8 字节,所以最大 -s 是 1500 − 48 = 1452。
A3 BDP = 10e9 × 0.02 / 8 = 25 MB。窗口 256 KB 时,吞吐上限 = 256 KB / 20 ms = 12.8 MB/s ≈ 102 Mbps,只有带宽的 1%。
A4 是内核的限制,但根源在设备抽象。any 是一个 packet_type 挂在所有设备之上的伪设备,它在 netif_receive_skb 之后才拿到包,此时链路层首部已经被解析并剥离。要看 MAC 必须绑定到具体设备,在驱动层之前抓。
A5 回环走完整的 IP 和 TCP,但跳过以太网的 ARP 解析、网卡的 DMA 和 TX/RX ring,也跳过 qdisc 的排队(回环默认 noqueue)。此外回环的发送和接收在同一个软中断上下文里完成,没有跨 CPU 的中断开销。所以本机 RTT 是几十微秒,而真实网卡是几十到几百微秒。
A6 服务端不靠「端口号多少」区分客户,而靠四元组:源 IP、源端口、目的 IP、目的端口。服务端只用固定端口(如 443),每条连接的源 IP 或源端口不同,四元组就不同。理论上限是 2³² × 2¹⁶ 量级,实际瓶颈在文件描述符数量、内核内存和 somaxconn,这三件事 4.11 逐个拆。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| W(谢希仁) | 第 1 章 概述(分层、协议、时延、体系结构) | 4.1.1–4.1.8 | 完整:四层模型、封装分用、四类时延、BDP |
| N(深入理解 Linux 网络) | 第 1 章 绪论(工作中的困惑、内容结构) | 4.1 导语、4.1.5 | 背景:把「协议知识」转成「内核问题」的动机 |
| C(CSAPP) | 第 11 章 11.1–11.3(客户端-服务器模型、网络、全球 IP 因特网) | 4.1.2、4.1.5 | 精选:分层视角与 socket 抽象的前置 |
| K(Kerrisk) | 第 58 章 58.2–58.4(联网协议和层、数据链路层、网络层) | 4.1.2、4.1.4 | 完整:分层与端口多路复用的系统编程视角 |
| K(Kerrisk) | 第 61 章 61.8(使用 tcpdump 监视 TCP 流量) | 4.1.9 | 改写:过滤表达式与抓包边界 |
| W(谢希仁) | 第 1 章(概述:分层、时延、带宽,PDF p12–52) | 4.1.12–4.1.21 | 补充:字节账、端到端时延与测量 |
| K(Kerrisk) | 第 58 章 58.2(协议与层,PDF p1013–1019) | 4.1.13 | 背景:分层的内核映射 |
| N(深入理解 Linux 网络) | 第 1 章(术语与约定,PDF p17–24) | 4.1.13 | 背景:收发包路径的术语 |
| N(深入理解 Linux 网络) | 第 2 章 2.2(网卡到协议栈,PDF p27–49) | 4.1.22 | 补充:ip_rcv 到 tcp_v4_rcv 的检查点 |
下一站:分层地图上最底下那两层——网线、网卡、MAC 地址、ARP 和交换机——是报文真正「落地」的地方。进入 4.2 物理层与数据链路层,看一个帧在局域网里怎么找到它的下一跳。