这一章在做什么
网络一慢,人们先怪网络。这一章教你分清:慢在网络本身,还是慢在两头的主机。它给出几种延迟的量法(建连、首字节、往返、重传),讲清 TCP 的两个连接队列、收发缓冲区、重传和拥塞控制,网卡、中断和软中断怎样把包交给协议栈,再用 ss、nstat、ip、sar 这些不要 root 的工具逐层看计数,最后才动用 BPF 工具和抓包。写网络服务的人最该带走的:读懂 Recv-Q、Send-Q 和那几个丢包计数。
什么时候翻这一章
- 客户端偶尔连接要一秒、三秒才建立多半是服务端的监听队列满了,SYN 被丢,客户端按定时器重发。先看
ss -lnt和nstat的 ListenOverflows。§10.3.7§10.4.3场景手册 → - 请求延迟高或者抖,不知道是网络还是服务把延迟拆成建连、首字节、传输几段分别量,建连慢是网络或内核,首字节慢多半是你的服务。§10.3.5§10.5.4场景手册 →
- 吞吐上不去,或者重传很多看连接是被谁限住了:接收方读得慢、发送缓冲小、拥塞窗口小,还是链路本身满了。§10.6.1§10.5.2场景手册 →
- p99 偶尔跳到几百毫秒以上一次超时重传就是至少 200 毫秒,SYN 重传是 1 秒起步。平均值看不出来,要看分布或逐个事件。§10.4.1§10.6.11场景手册 →
- 收包多的机器上,某一个核的软中断跑满网卡的中断只打到一个 CPU,协议栈的处理都挤在那里。§10.4.3
这一章用到第 2 章的 USE 方法和负载特征,软中断和 mpstat 回第 6 章看,bpftrace 的写法在第 15 章。容器和云主机上的带宽限额在第 11 章。
先用中文过一遍
延迟有好几种:先说清量的是哪一段
说「网络延迟高」之前,先问清楚是哪一段。客户端发起一次请求,时间依次花在:域名解析、TCP 三次握手(建连延迟,connection latency)、服务端接受连接、调度线程、处理请求再发回第一个字节(首字节延迟,TTFB),然后才是传数据,最后连接关掉(连接寿命)。往返时间(RTT)是一个包在两端之间来回一趟的时间,ping 量的就是它的一个近似。
这几段的意义不一样。建连延迟里没有应用参与,它慢说明网络或两端的内核有问题;首字节延迟里包括服务端的处理时间和 CPU 排队,书里说它更像是在量应用和当前负载(§10.5.4)。重传会把任何一段拉长:数据包超时重传在 Linux 上至少 200 毫秒,握手阶段丢一个 SYN,客户端要等 1 秒才重发(§10.3.5、§10.4.1)。所以「平均 2 毫秒、偶尔 1 秒」这种分布,第一个要怀疑的就是重传。
两个连接队列:backlog 满了会怎样
服务端 listen() 之后,新连接先后经过两个队列:握手还没完成的放在 SYN 队列(半连接),握手完成、等你的程序 accept() 的放在 accept 队列(书里叫 listen backlog)。listen(fd, backlog) 的第二个参数定的是后一个队列的上限,它还会被系统的 net.core.somaxconn 截断(§10.4.3)。
应用 accept 得太慢,accept 队列就满。满了以后 Linux 丢掉新来的 SYN,计数 ListenOverflows 和 ListenDrops 各加一;客户端收不到回应,1 秒后重发,再不行就在第 3 秒、第 7 秒……(从第一次发 SYN 算起,间隔每次加倍)这就是「偶尔连接要一秒」的来历。书里把这两件事(backlog 丢弃和 SYN 重传)当作主机过载的信号(§10.3.7)。本站在 WSL2 上还看到另一条路:握手的最后一个 ACK 到达时队列刚好满了,服务端把 ACK 丢掉、连接留在 SYN-RECV,客户端却已经认为连接建立了。对客户端来说,connect() 成功不等于对方已经能 accept 它。下面的动手和模拟器都围绕这件事。
缓冲区、窗口和重传:吞吐被谁限住了
每个 TCP 套接字有一个发送缓冲区和一个接收缓冲区。你的 write() 只是把数据拷进发送缓冲区;内核按两个上限往外发:对端通告的接收窗口(rwnd,等于对端接收缓冲区还空着多少),和自己按网络状况估的拥塞窗口(cwnd)。对端的程序不读,接收缓冲区填满,窗口变成 0,发送方就只能停下来等,你的 write() 也会阻塞(§10.4.1、§10.4.3)。
所以「吞吐上不去」有好几种原因,ss -ti 会直接告诉你是哪一种:rwnd_limited 是被对端的接收窗口限住,sndbuf_limited 是自己的发送缓冲区太小,app_limited 是应用自己没给够数据(§10.6.1)。拥塞窗口在丢包后缩小,丢包靠两种方式发现:等重传定时器超时,或者收到三个重复 ACK 立即快速重传。拥塞控制算法决定窗口怎么涨怎么缩,Linux 默认是 CUBIC,BBR 是另一种思路:不像前几种那样主要靠丢包调窗口,而是通过探测估计路径的带宽和 RTT。
收包路径:网卡、中断和软中断
一个包从网线进来,网卡用 DMA 把它放进内核的环形缓冲区,然后发中断。Linux 的 NAPI 让中断只负责「叫醒」:真正的处理放到软中断(softirq)里,包多的时候干脆关掉中断、轮询着取,一次取一批(§10.4.3)。这样吞吐高,代价是一点延迟;网卡驱动的中断合并(interrupt coalescing)是同一种取舍。
和排查最相关的是:软中断在哪个 CPU 上跑,协议栈的处理就压在哪个 CPU 上。网卡只有一个队列、又没有在软件里分流时,所有收包都挤在一个核上,mpstat -P ALL 里那个核的 %soft 很高、别的核闲着(§6.6.3)。多队列网卡用 RSS 按连接哈希分到不同队列和 CPU;不支持的网卡可以开 RPS 在软件里分。书里提到负载均衡和代理这类收包特别多的程序尤其容易碰到这个问题。嵌入式板子上常见的单队列网卡也值得留意。
| 英文 | 中文 | 一句话 |
|---|---|---|
| bandwidth / throughput | 带宽 / 吞吐 | 带宽是链路的上限,吞吐是现在实际传了多少;全双工时每个方向各算 §10.1 术语页 |
| RTT (round-trip time) | 往返时间 | 一个包来回一趟的时间;ss -ti 里的 rtt、ping 量的都是它 §10.3.5 术语页 |
| connection latency | 建连延迟 | 从发 SYN 到握手完成;没有应用参与,包含 SYN 重传的时间 §10.3.5 |
| first-byte latency (TTFB) | 首字节延迟 | 连接建立到收到第一个字节;包含服务端的处理和排队 §10.3.5 |
| backlog (SYN / listen) | 连接队列 | 半连接和等 accept 的两个队列;后者的上限是 listen 的参数 §10.4.3 术语页 |
| socket buffer | 套接字缓冲区 | 每个连接的收发缓冲,Linux 会按连接的活动自动调大 §10.4.3 术语页 |
| receive window (rwnd) | 接收窗口 | 接收方告诉发送方还能收多少;为 0 时发送方停住 §10.4.1 |
| congestion window (cwnd) | 拥塞窗口 | 发送方自己估的、不等 ACK 最多能发多少;丢包后缩小 §10.4.1 术语页 |
| retransmit (RTO / fast) | 重传(超时 / 快速) | 定时器超时重发(Linux 至少 200 毫秒),或收到重复 ACK 立即重发 §10.4.1 术语页 |
| TIME_WAIT | TIME_WAIT 状态 | 主动关闭的一方要等一段时间,短连接太多会占满本地端口 §10.5.7 术语页 |
| MTU / MSS | 最大传输单元 / 最大报文段 | 一帧最多多大(常见 1500 字节);MSS 是扣掉头部后 TCP 一段的数据量 §10.3.4 术语页 |
| NAPI / interrupt coalescing | NAPI / 中断合并 | 包多时少发中断、改成轮询成批处理,换吞吐、费一点延迟 §10.4.3 |
| softirq | 软中断 | 协议栈收包处理跑在这里;mpstat 的 %soft §10.4.3 术语页 |
| RSS / RPS | 接收端分流(硬件 / 软件) | 按连接把收包分到多个 CPU,避免一个核被软中断压满 §10.4.3 |
| bufferbloat | 缓冲膨胀 | 路由器、交换机缓冲过大,包排很久的队,延迟变高 §10.3.6 |
方法:按什么顺序查
书里建议先用这四种:性能监控、USE 方法、静态性能调优、负载特征(§10.5);抓包代价高,放到最后。落到一次「网络好像慢」的排查上,本站整理成下面几步。前五步都不需要 root。
- 慢在哪一段?
先拆延迟:
curl -w的time_connect和time_starttransfer,或者你自己的客户端在 connect 前后、收到首字节时各记一次时间。建连慢往网络和服务端内核查;首字节慢、建连快,多半是服务自己慢,回第 5、6 章。要在空闲和负载下各量一次。§10.3.5§10.5.4 - 有没有丢、有没有错?
错误最好查也最好解释,先看。
nstat -z隔几秒跑两次,第二次显示的就是增量(加-a给的是开机以来的累计值):TcpRetransSegs比TcpOutSegs、TcpExtListenOverflows、TcpExtTCPSynRetrans。ip -s -s link看网卡的 errors、dropped、missed。§10.5.2§10.6.4 - 监听队列满没满?
ss -lnt:LISTEN 行的 Recv-Q 贴着 Send-Q,就是应用 accept 不过来。先查 accept 的那个线程为什么慢,再考虑把listen()的 backlog 和somaxconn调大。§10.3.7§10.6.1 - 某条连接被什么限住了?
ss -tiepm看重要的连接:rtt和minrtt比一比看抖动,retrans,rwnd_limited、sndbuf_limited各占了多少时间,有没有app_limited标记(它只是个标记,不带时间)。§10.6.1§10.5.7 - 链路满了吗?
sar -n DEV 1分方向看 rxkB/s、txkB/s,除以协商速率(ethtool或/sys/class/net/网卡/speed)。云主机上要按限额算,不是按网卡标称。顺便看mpstat -P ALL 1有没有一个核的%soft特别高。§10.5.2§10.6.6 - 重传落在哪些连接、什么状态?
tcpretrans-bpfcc(要 root):ESTABLISHED 状态的重传多,指向网络;SYN_SENT 状态的多,指向对端服务器来不及处理 SYN。tcplife-bpfcc记每条连接活了多久、传了多少。§10.6.9§10.6.11 - 还不清楚,再抓包
tcpdump只抓几秒、带上过滤条件、写到文件里再看。高包速下抓包本身就耗 CPU,内核还可能丢包不给 tcpdump,输出末尾会报丢了多少。§10.5.6§10.6.13
工具速查
| 想知道 | 敲什么 | 看哪里 | 节 |
|---|---|---|---|
| 一次请求慢在哪段 | curl -s -o /dev/null -w '%{time_connect} %{time_starttransfer}\n' URL | 两个数都是从开始算起的秒数;前者是建连完成,后者减前者主要是服务端处理 | §10.3.5 |
| 监听队列满没满 | ss -lnt | LISTEN 行 Recv-Q 是 accept 队列现在的长度,Send-Q 是上限;Recv-Q 贴着上限就是满了 | §10.6.1 |
| 丢没丢连接 | nstat -z TcpExtListenOverflows TcpExtListenDrops | 不带 -a 时显示自上次运行以来的增量(-a 给累计值);在涨就是 accept 队列溢出 | §10.6.4 |
| 某条连接卡在哪 | ss -tiepm dst 10.0.0.5 | rtt、rto、cwnd、retrans;rwnd_limited 是对端读得慢,sndbuf_limited 是自己发送缓冲小 | §10.6.1 |
| 重传率 | nstat -z TcpRetransSegs TcpOutSegs | 隔一段时间跑第二次,两个增量相除。多高算高看客户端:面向互联网的服务天然比机房内部高 | §10.5.1 |
| 协议栈的各种计数 | netstat -s | grep -iE 'listen|retrans|prune' | listen queue overflowed、segments retransmitted、因为缓冲区不够被裁掉的包 | §10.6.5 |
| 网卡错误和丢包 | ip -s -s link | RX 的 errors、dropped、missed、overrun,TX 的 errors、carrier;是自启动以来的累计值,看它涨不涨 | §10.6.2 |
| 吞吐和利用率 | sar -n DEV 1 | rxkB/s、txkB/s 分方向除以协商速率;%ifutil 取两个方向里大的那个 | §10.6.6 |
| 连接速率和重传的趋势 | sar -n TCP,ETCP 1 | passive/s 是每秒新进来的连接,retrans/s 是每秒重传的段 | §10.6.6 |
| 协商速率、offload、驱动计数 | ethtool eth0ethtool -k eth0ethtool -S eth0 | Speed、Duplex 有没有掉到低档;-k 看 TSO、GRO 开没开;-S 是驱动自己定义的计数 | §10.6.8 |
| 软中断压在一个核上没有 | mpstat -P ALL 1grep NET_RX /proc/softirqs | 某个核 %soft 远高于别的核,或 NET_RX 只在一列涨 | §10.4.3 |
| 每条连接活了多久 | tcplife-bpfcc | 每关掉一条连接打一行:进程、两端地址、收发 KB、寿命(毫秒);开销小,可以常开 | §10.6.9 |
| 谁在重传 | tcpretrans-bpfcc | STATE 列:ESTABLISHED 多,可能是网络的问题;SYN_SENT 多,可能是对端服务器来不及处理 SYN | §10.6.11 |
| 监听队列离满还有多远 | tcpsynbl-bpfcctcpsynbl.bt | 每个 backlog 上限一张队列长度直方图;.bt 版在队列超过上限时还会打印 dropping a SYN | §10.6.12 |
| 包的细节 | tcpdump -ni eth0 -w /tmp/x.pcap 'port 8080'tcpdump -nr /tmp/x.pcap -ttt | 短时间抓、带过滤;结束时看 dropped by kernel;-ttt 显示相邻两个包的时间差 | §10.6.13 |
ss、ip、nstat 在 iproute2 包里,是内核网络开发者维护的,最可能先支持内核的新特性;netstat 和 ifconfig 在 net-tools 包里,书里说内核开发者已经把它们当作过时的工具。sar、mpstat 在 sysstat 里;ethtool、tcpdump 各自一个包,tcpdump 要 root(或 CAP_NET_RAW)。tcplife、tcpretrans、tcpsynbl 在 Debian 的 bpfcc-tools 包里带 -bpfcc 后缀,bpftrace 包也带了 .bt 版本,都要 root。上表从 curl 到 sar 的几行都不需要 root。
动手:在自己的机器上试
backlog 很小、迟迟不 accept:看队列怎么满
服务端 listen(fd, 4),前 3 秒一个都不 accept,之后每半秒才 accept 一个;客户端同时发起 10 个非阻塞 connect,记下每个连接用了多久。先猜:10 个连接里有几个能马上建立?剩下的要等多久?
// slowacc.c:listen 的 backlog 很小,前 3 秒一个都不 accept,之后每 0.5 秒才 accept 一个
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
int main(int argc, char **argv) {
int backlog = argc > 1 ? atoi(argv[1]) : 4;
int fd = socket(AF_INET, SOCK_STREAM, 0), on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(30100) };
inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
if (bind(fd, (struct sockaddr *)&a, sizeof a) || listen(fd, backlog)) { perror("bind/listen"); return 1; }
alarm(9); // 9 秒后自己退出
sleep(3); // 假装忙:这 3 秒里完全不 accept
struct timespec half = { 0, 500000000 };
for (;;) { // 之后慢慢 accept,一个 0.5 秒
int c = accept(fd, NULL, NULL);
if (c >= 0) close(c);
nanosleep(&half, NULL);
}
}
// burst.c:同时发起 N 个非阻塞 connect,记下每个连接用了多久才建立
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
static double now_ms(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec * 1e3 + t.tv_nsec / 1e6;
}
int main(int argc, char **argv) {
int n = argc > 1 ? atoi(argv[1]) : 10;
if (n > 64) n = 64;
struct pollfd p[64];
double t0 = now_ms(), done[64];
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(30100) };
inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
for (int i = 0; i < n; i++) {
p[i].fd = socket(AF_INET, SOCK_STREAM, 0);
p[i].events = POLLOUT;
done[i] = -1;
fcntl(p[i].fd, F_SETFL, O_NONBLOCK);
connect(p[i].fd, (struct sockaddr *)&a, sizeof a); // 返回 EINPROGRESS
}
int left = n;
while (left > 0 && now_ms() - t0 < 8000) { // 最多等 8 秒
if (poll(p, n, 100) <= 0) continue;
for (int i = 0; i < n; i++)
if (done[i] < 0 && (p[i].revents & POLLOUT)) { done[i] = now_ms() - t0; p[i].events = 0; left--; }
}
sleep(2); // 连接先别关,留时间给 ss 看
for (int i = 0; i < n; i++)
if (done[i] < 0) printf("conn %2d: not connected after 8 s\n", i);
else printf("conn %2d: connected in %7.1f ms\n", i, done[i]);
return 0;
}
$ gcc -O2 slowacc.c -o slowacc && gcc -O2 burst.c -o burst
# 记下当前的计数,之后的 nstat 只显示这之后的增量
$ nstat -n
$ ./slowacc 4 & sleep 0.3; ./burst 10 > burst.out &
$ sleep 0.5; ss -lnt 'sport = :30100'
# 大约 3.7 秒时,分别从客户端和服务端两边数连接的状态
$ sleep 3; ss -tn 'dport = :30100' | awk 'NR>1{print $1}' | sort | uniq -c
$ ss -tan 'sport = :30100' | awk 'NR>1 && $1!="TIME-WAIT"{print $1}' | sort | uniq -c
$ sleep 6; cat burst.out; nstat | grep -E 'Listen|SynRetrans'
本站在 WSL2 上看到的(每段只留关键的几行):
ss -lnt: State Recv-Q Send-Q Local Address:Port
LISTEN 5 4 127.0.0.1:30100
客户端一侧: 8 ESTAB 2 CLOSE-WAIT
服务端一侧: 4 ESTAB 4 SYN-RECV 2 FIN-WAIT-2
conn 0: connected in 2.7 ms (0 到 4 号都是 2.7 ms)
conn 5: connected in 3060.5 ms (5 到 9 号都是 3060.5 ms)
TcpExtListenOverflows 21
TcpExtListenDrops 21
TcpExtTCPSynRetrans 19
listen(fd, 4)时,Send-Q 是 4,Recv-Q 却停在 5:在这台机器上,accept 队列实际能放 backlog + 1 个。前 5 个连接 2.7 毫秒就建立了,它们只是排在队列里,还没有人 accept。- 后 5 个的 SYN 被丢了,客户端 1 秒后重发,又被丢;3 秒时服务端 accept 了一个,队列空出位置,第二次重发的 SYN 得到了回应。所以它们的建连时间是整整 3 秒,这正是书里说的「连接延迟里包含重传」。
- 3.7 秒左右,客户端认为 10 个连接都建立了(8 个 ESTAB,加上 2 个已被服务端 accept 后关掉的),服务端却只有 4 个在 accept 队列里(ESTAB),另有 4 个停在 SYN-RECV:这 4 个连接握手的最后一个 ACK 到达时队列又满了,被丢掉。客户端这时发出的请求,要等这个连接真正进了服务端的 accept 队列才会被处理,中间可能还要重传。
- ListenOverflows 记了 21 次,比 5 个连接多得多:每一次重发的 SYN、每一个被丢的 ACK 都算一次。所以看这个计数要看它的增长速度,不要拿它当「丢了几个客户端」。
- 把 backlog 改成 100000 再跑
ss -lnt,Send-Q 显示 4096:这台机器的somaxconn是 4096,listen()的参数被它截断了,而且不报错。
- WSL2
- 本站实测过上面全部命令(Debian 13,内核 6.6,iproute2 6.15),不需要 root,只用 127.0.0.1 的 30100 端口。nstat 的增量是相对你自己上一次运行算的(它把上次的值存在一个历史文件里),机器上别的程序在 127.0.0.1 上的连接也会算进去。
- 服务器
- 别在线上对真实端口做这个实验。排查线上服务时,同样的三个动作:
ss -lnt看 Recv-Q 和 Send-Q,nstat -z TcpExtListenOverflows隔几秒看增量,客户端那边看TcpExtTCPSynRetrans。要看队列长度的分布用tcpsynbl-bpfcc(要 root)。 - ARM 板
- busybox 如果编进了
netstat,-lnt能看到 LISTEN 行的 Recv-Q,但不显示队列上限;有 iproute2 就用ss。没有 nstat 时直接读/proc/net/netstat:TcpExt 那两行,第一行是名字,第二行是值,ListenOverflows 在里面。gcc 交叉编译这两个程序就能在板子上跑。
对端不读:Recv-Q 涨满、窗口变零
接收方接受连接后 4 秒不读,发送方用阻塞的 write() 往里灌 32 MB。先猜:数据会卡在哪一边的缓冲区里?发送方的 write() 会怎样?
// lazyread.c:接受一个连接,然后 4 秒不读,之后把数据读完退出
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
int main(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0), on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(30101) };
inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
if (bind(fd, (struct sockaddr *)&a, sizeof a) || listen(fd, 16)) { perror("bind/listen"); return 1; }
int c = accept(fd, NULL, NULL);
sleep(4); // 应用忙别的去了,不读 socket
static char buf[65536];
long total = 0;
ssize_t n;
while ((n = read(c, buf, sizeof buf)) > 0) total += n;
printf("read %ld bytes\n", total);
return 0;
}
// blast.c:连上 30101,用阻塞的 write 往里灌 32 MB(另存一个文件)
int main(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(30101) };
inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
if (connect(fd, (struct sockaddr *)&a, sizeof a)) { perror("connect"); return 1; }
static char buf[65536];
for (int i = 0; i < 512; i++) // 512 × 64 KB = 32 MB
if (write(fd, buf, sizeof buf) < 0) { perror("write"); return 1; }
close(fd);
return 0;
}
# blast.c 用和 lazyread.c 一样的几个头文件 $ gcc -O2 lazyread.c -o lazyread && gcc -O2 blast.c -o blast $ nstat -n $ ./lazyread & sleep 0.3; ./blast & $ sleep 1.5; ss -tni 'sport = :30101 or dport = :30101' $ wait; nstat | grep -i window
本站在 WSL2 上看到的(ss -tni 每条连接一大行,只摘了要看的字段):
接收方: ESTAB Recv-Q 127680 Send-Q 0 发送方: ESTAB Recv-Q 0 Send-Q 2498752 发送方: rwnd_limited:1504ms(100.0%) backoff:2 发送方: notsent:2498752 TcpExtTCPWantZeroWindowAdv 1 TcpExtTCPToZeroWindowAdv 1 TcpExtTCPFromZeroWindowAdv 1
- 接收方的 Recv-Q 停在 12 万多字节:接收缓冲区满了。Linux 的接收缓冲区会随着应用读数据自动长大,应用一直不读,它就停在很小的值上。
- 发送方的 Send-Q 积了 2.4 MB,其中
notsent是还没发出去的。发送缓冲区也满了以后,blast的write()就阻塞在那里。书里说网卡饱和不好直接量,可以看应用线程阻塞在发送上的时间(§10.5.2);这里堵住它的不是网卡,是对端。 - 发送方
rwnd_limited占了 100% 的时间:一直被对端的接收窗口限着。nstat 里接收方通告过一次零窗口(ToZeroWindowAdv),后来读数据时又重新打开(FromZeroWindowAdv);backoff是退避次数,这里多半来自零窗口期间的探测。 - 换成真实场景:你的服务往下游写得越来越慢,先看下游连接的
ss -ti。看到rwnd_limited,调大自己的发送缓冲区没有用,问题在对端的读。
- WSL2
- 本站实测过,不需要 root,只用 127.0.0.1 的 30101 端口。loopback 的 MTU 是 65536,所以
mss比真实网卡大得多,数字只看相对关系。 - 服务器
- 对线上的连接用
ss -tiepm dst 对端IP看同样的字段,不影响业务。-m多给出skmem,能看到缓冲区实际分配了多少。 - ARM 板
- busybox 一般没有 ss;
cat /proc/net/tcp里每条连接一行,tx_queue:rx_queue那一列(十六进制)就是 Send-Q 和 Recv-Q。拿不到 rwnd_limited 这类细节,但能看出数据堆在哪一边。
动手:模拟器
先猜:新连接每秒平均来 200 个,应用每秒平均能 accept 250 个,backlog 是 16。平均处理得过来,会不会丢?再按一次「应用卡住 2 秒」(比如一次长时间的日志刷盘或锁等待),这 2 秒里来的约 400 个连接会怎样,客户端看到的建连时间分布会变成什么样?
连接队列溢出
新连接随机到达,进 accept 队列排队,应用一个一个 accept 走。队列满了,新 SYN 被丢,客户端按 1、3、7、15……秒重发。拖动到达速率、accept 速度,换 backlog,点「跑 10 秒」看结果;「应用卡住 2 秒」模拟 accept 线程短暂停顿。
accept 队列
最近 30 秒,每秒收到的 SYN(绿:进了队列,红:被丢)
客户端看到的建连时间
- 默认参数跑 10 秒,丢弃率接近公式算的 0.5%。然后按「应用卡住 2 秒」再跑 10 秒:卡住的 2 秒几乎全是红柱,卡住结束后好几秒里还在丢:被丢的客户端在 1、3、7 秒后成批回来重发,叠在正常到达上,又把队列挤满。平均处理得过来,不代表不丢。
- 把 backlog 从 16 换成 1024,重置后重复上一步。卡住 2 秒来的约 400 个连接都能排进队列,没有 SYN 被丢,建连时间也几乎全在 1 秒以下:队列吸收了突发,代价是排在后面的请求多等了一会儿。
- 取消「客户端重发 SYN」,把到达调到 300、accept 调到 250(到达比处理快),跑 30 秒:丢弃率趋向公式给的值(约 17%)。再勾上重发:被丢的连接回来再排队,实际负载比 300 还高,丢弃率比公式高,还在重发的连接越积越多。
成书之后
somaxconn 的默认值从 128 变成了 4096
- 书里
- 调优一节建议把
net.core.somaxconn从默认值调大,举的例子是 1024(§10.8.1);书里的示例跑在 5.3 内核上。 - 今天
- Linux 5.4 把 somaxconn 的默认值从用了二十多年的 128 提到 4096。读者的 WSL2(6.6)上读出来就是 4096。所以在新内核上看到 accept 队列上限很小,先查程序自己
listen()传的是多少,再考虑改 sysctl。
两个 TCP 参数早已不起作用:tcp_tw_recycle 和 tcp_fack
- 书里
- TCP 选项的示例里列了
tcp_fack = 1和tcp_tw_recycle = 0,并说 Linux 默认支持 FACK(§10.4.1、§10.8.1)。 - 今天
tcp_tw_recycle在 Linux 4.12 里删除了,配置文件里还写着它,sysctl 会报找不到这个文件。FACK 的丢包检测在 4.15 被移除,由 RACK 取代,tcp_fack的值从那时起被忽略。从网上抄 sysctl 配置时,这两行可以删掉。
QUIC 和 HTTP/3 已经成为标准
- 书里
- QUIC 由 Google 设计、跑在 UDP 上,IETF 正在标准化 QUIC 本身和 HTTP over QUIC(即 HTTP/3)(§10.4.1)。
- 今天
- QUIC 在 2021 年 5 月作为 RFC 9000 发布,HTTP/3 在 2022 年 6 月作为 RFC 9114 发布。对排查的影响:走 QUIC 的流量在内核看来是 UDP,ss、nstat 里的 TCP 计数和 tcpretrans 都看不到它的重传和拥塞状态,要靠应用或 QUIC 库自己的统计。
分节地图
这一章按书的固定套路走:背景(§10.1–10.3)、架构(§10.4)、方法(§10.5)、工具(§10.6)、实验(§10.7)、调优(§10.8)。先读的是延迟、缓冲、backlog 这几个概念,Linux 网络栈的实现(连接队列、缓冲区、NAPI 和多 CPU 分流),USE 和延迟分析,以及 ss、nstat、netstat、sar、tcpretrans 几个最常用的工具。协议的细节、硬件和各种调优参数,用到再查。
- As systems become more distributed,…约 300 词开篇网络常被当替罪羊;这一章的学习目标和六个部分。
- Terminology约 200 词术语接口、包和帧、带宽和吞吐、延迟的两种含义。
- Models约 100 词模型三个简单模型的引子。
- Network Interface约 100 词网络接口接口和物理端口的对应,写网络服务的人都知道。
- Controller约 100 词控制器网卡上的控制器是什么,同上,跳过不影响后面。
- Protocol Stack约 200 词协议栈TCP/IP 和 OSI 两种分层,各层的消息叫什么。
- Concepts约 100 词概念一组概念的引子。
- Networks and Routing约 300 词网络与路由子网、路由、单播和组播,网络基础课内容。
- Protocols约 200 词协议不同协议、不同版本走不同的内核路径,性能也不同。
- Encapsulation约 100 词封装加头加尾,基础知识。
- Packet Size约 400 词包的大小1500 的 MTU 从哪来,巨帧为什么推不开,offload 补了多少。
- Latency约 700 词延迟几种延迟各量的是哪一段,哪些包含服务端的处理时间。
- Buffering约 200 词缓冲两端的缓冲撑起吞吐;中间设备缓冲太大反而拖慢。
- Connection Backlog约 100 词连接队列backlog 满了丢 SYN,客户端重发;这是主机过载的信号。
- Interface Negotiation约 100 词接口协商速率和双工是协商出来的,坏线会让它掉档。
- Congestion Avoidance约 100 词拥塞避免以太网、IP、TCP 各自怎么避免拥塞。
- Utilization约 100 词利用率按方向、按协商速率算;只给包数的工具算不出利用率。
- Local Connections约 200 词本机连接本机进程之间用 127.0.0.1 还是 Unix 域套接字。
- Architecture约 100 词架构协议、硬件、软件三部分的引子。
- Protocols约 2200 词协议IP、TCP、UDP、QUIC 的性能特性。要查握手、重传、拥塞控制、Nagle 和延迟 ACK 时回来看。
- Hardware约 1000 词硬件网卡、控制器和 PCIe 带宽、交换机、路由器、防火墙各自可能成为瓶颈。
- Software约 1900 词软件Linux 网络栈:两个连接队列、缓冲区、GSO/TSO、qdisc、NAPI、多 CPU 分流、绕过内核。
- Methodology约 100 词方法方法总表和作者推荐先用的四种。
- Tools Method约 300 词工具法把几个工具挨个跑一遍各看什么。
- USE Method约 400 词USE 方法网卡每个方向查利用率、饱和度、错误;饱和度为什么难量。
- Workload Characterization约 300 词负载特征吞吐、包速、连接速率三个基本数,加一张进阶问题清单。
- Latency Analysis约 200 词延迟分析尽量多量几种延迟来缩小范围;重传造成的离群值怎么找。
- Performance Monitoring约 100 词性能监控长期该记哪几个网络指标。
- Packet Sniffing约 400 词抓包抓包为什么贵,内核里过滤和环形缓冲怎么省开销。
- TCP Analysis约 200 词TCP 分析该看的 TCP 行为,以及短连接太多时的临时端口冲突。
- Static Performance Tuning约 200 词静态性能调优一张检查配置的问题清单:速率、双工、MTU、路由、限额。
- Resource Controls约 200 词资源控制带宽限制、QoS 和人为加的延迟。
- Micro-Benchmarking约 100 词微基准测试先用基准工具确认网络本身跑得到预期速度。
- Observability Tools约 200 词观测工具工具总表;iproute2 和 net-tools 两套工具的关系。
- ss约 400 词ssss -tiepm 的各个字段,尤其是几种 limited 标志;ss 读的是 netlink。
- ip约 200 词ipip -s link 的错误计数和路由表。
- ifconfig约 100 词ifconfig已被 ip 取代,计数和 ip 一样。
- nstat约 200 词nstat按 SNMP 名字打印内核的网络计数,默认给增量。
- netstat约 500 词netstat-i 和 -s 两种用法,-s 里该盯哪几项;监控工具该读 /proc。
- sar约 200 词sar-n 的几组选项,按秒看吞吐和 TCP 计数,能存历史。
- nicstat约 200 词nicstat直接给出网卡利用率和饱和度的工具。
- ethtool约 100 词ethtool驱动计数、驱动信息和 offload 开关。
- tcplife约 200 词tcplife跟踪状态变化记录每条连接的寿命和流量,开销小。
- tcptop约 100 词tcptop按进程和连接排的 TCP 流量 top。
- tcpretrans约 200 词tcpretrans逐个打印重传和当时的 TCP 状态;状态说明问题在哪边。
- bpftrace约 1000 词bpftrace套接字和 TCP 的跟踪点,tcpsynbl 看监听队列长度。要自己写跟踪时回来查。
- tcpdump约 300 词tcpdump抓到文件再看,过滤表达式,时间戳选项。
- Wireshark约 100 词Wireshark图形界面看抓包文件,按连接归类。
- Other Tools约 100 词其他工具别的章和 BPF 那本书里的网络工具,以及 strace、lsof 等。
- Experimentation约 100 词实验主动发包测试的工具。
- ping约 100 词pingICMP 往返时间;路由器可能给 ICMP 不同的优先级。
- traceroute约 300 词traceroute逐跳的路径和 RTT;全是星号时换成 TCP 试。
- pathchar约 100 词pathchar测逐跳带宽的老工具,原版已经很难跑起来。
- iperf约 200 词iperf测两台机器之间的最大吞吐,可开多个并行连接。
- netperf约 100 词netperf测请求-响应的往返延迟。
- tc约 100 词tc用 netem 人为加丢包、加延迟,测试时模拟坏网络。
- Other Tools约 100 词其他工具内核发包器、组合测试、带统计的路由追踪、重放抓包:pktgen、Flent、mtr、tcpreplay。
- Tuning约 100 词调优默认值通常已经不错,先搞清负载再动参数。
- System-Wide约 900 词系统级参数缓冲区、两个 backlog、设备队列、拥塞控制、qdisc 的 sysctl。改之前对照自己内核的文档。
- Socket Options约 100 词套接字选项写服务的人能在代码里改的那几个:缓冲大小、REUSEPORT、NODELAY。
- Configuration约 100 词配置巨帧、链路聚合、防火墙打优先级标记。
- Exercises约 200 词习题习题;动手部分本页已经给了替代。
词与短语
只收先读和用到再查两档的节。斜体那一行是原句里连续的几个词,在电子书里搜它就能找到那一句。
§0 开篇
§10.3.4 Packet Size
§10.3.5 Latency
§10.3.6 Buffering
§10.3.7 Connection Backlog
§10.3.9 Congestion Avoidance
§10.3.10 Utilization
§10.3.11 Local Connections
§10.4.1 Protocols
§10.4.2 Hardware
§10.4.3 Software
§10.5.1 Tools Method
§10.5.2 USE Method
§10.5.4 Latency Analysis
§10.5.6 Packet Sniffing
§10.6.1 ss
§10.6.5 netstat
§10.6.11 tcpretrans
§10.8 Tuning
§10.8.1 System-Wide
诊断题:换成你来查
场景和输出都是本站编的。先自己判断,再点选项看解释。
场景:你的 C++ 网关监听 8080,高峰期客户端偶尔报连接超时。你在网关机器上隔 10 秒各跑一次:
ss -lnt: State Recv-Q Send-Q Local:Port
LISTEN 129 128 *:8080
nstat: TcpExtListenOverflows 3412
TcpExtListenDrops 3412LISTEN 行 Recv-Q 129 已经超过 Send-Q 128,十秒里 ListenOverflows 涨了三千多,这是 accept 队列溢出,丢 SYN 的是你自己的机器。只调 somaxconn 不一定够:Send-Q 显示的是程序 listen() 传的值和 somaxconn 中小的那个。新内核 somaxconn 默认 4096,这个 128 多半是代码里传的,要改代码;5.4 以前的内核 somaxconn 默认正好是 128,两处都得查(sysctl net.core.somaxconn)。更根本的是 accept 线程为什么取得慢(被别的工作占住、锁、事件循环阻塞)。SYN cookies 只对第一个队列管用,对 accept 队列满没有帮助。
场景:一个 ARM 网关板子每分钟上报数据,每次新建连接。你在客户端打了建连耗时的日志,统计一天:
connect time count < 5 ms 86012 1.0 - 1.1 s 402 3.0 - 3.1 s 37 > 7 s 3
建连时间集中在 1、3、7 秒这几个整数附近,正好是 SYN 重传的退避间隔,几乎可以断定是握手阶段丢了包。服务端处理慢会体现在首字节延迟上,不在建连时间里;DNS 超时一般是几秒到几十秒、和这几个点不对齐,而且如果是用 IP 直连就不存在;调度延迟不会这么整齐。下一步在服务端看 nstat 的 ListenOverflows 和 ip -s link 的 dropped,在客户端看 TcpExtTCPSynRetrans。
场景:你的服务通过一条长连接把日志推给日志服务器,最近 write() 常常阻塞。在你这边看这条连接:
ESTAB Recv-Q 0 Send-Q 4194304 rtt:0.4/0.1 cwnd:10 retrans:0/0 rwnd_limited:9120ms(91.2%)
rwnd_limited 占了 91%,说明大部分时间对端通告的窗口不够,数据堆在你的发送缓冲区里(Send-Q 4 MB)。RTT 很小、没有重传,网络是好的。调大发送缓冲只会让数据堆得更多、晚一点阻塞;换拥塞控制也没用,限住你的是接收窗口不是拥塞窗口。去日志服务器上看那条连接的 Recv-Q 和它的读线程。
场景:一台 8 核机器跑你写的 UDP 转发代理,包速一高就开始丢包,可 top 里总的 CPU 才用了 15%。mpstat 截了一秒:
CPU %usr %sys %soft %idle 0 2.0 3.0 94.0 1.0 1 9.0 4.0 0.0 87.0 2 8.0 5.0 0.0 87.0 (其余各核和 1 号相近)
0 号核的时间几乎全在 %soft:协议栈收包处理都挤在这一个核上,它满了就开始丢,别的核再闲也帮不上。多开应用线程没用,瓶颈在应用之前。看 /proc/interrupts 里网卡有几个队列、中断落在哪些核上,grep NET_RX /proc/softirqs 看是不是只有一列在涨;单队列网卡可以开 RPS,在软件里按连接分到多个 CPU。
场景:你的服务每个请求都新建一条到同一个下游(同一 IP、同一端口)的短连接,用完就主动关闭,平时每秒约 450 个请求。高峰时 connect() 偶尔失败。
ss -s: timewait 27950 sysctl: ip_local_port_range = 32768 60999
两端 IP 和下游端口都固定,能区分连接的只剩本地临时端口,这台机器只有 28232 个可用。每秒四百五十个主动关闭的连接都要在 TIME_WAIT 里待 60 秒左右,同时就有两万七千个左右,已经贴着端口的上限;高峰时请求稍一多,就挑不出不在 TIME_WAIT 里的端口,connect() 报 EADDRNOTAVAIL。书里给的办法有复用 TIME_WAIT(tcp_tw_reuse)、多用几个 IP;对写服务的人来说,最直接的是长连接或连接池,握手的成本也一起省了。
值得停下来的句子
This chapter will show how to figure out what is really happening, which may exonerate the network so that analysis can move on.— §10 开篇。本站译:这一章要教你弄清到底发生了什么,查完也许能还网络一个清白,分析好继续往下走。
写服务的人和运维、网络组之间最常见的一句话是「是不是网络的问题」。作者把这一章的目的定成先查清,而且直说结果多半是给网络洗清嫌疑。这改变了排查的姿态:不是去证明网络有错,而是用建连延迟、重传计数、队列溢出这些数,快速判断问题在不在网络,不在就回到自己的服务上。上面第一道诊断题就是典型:超时看起来像网络,丢包的却是自己的 accept 队列。
Backlog drops and SYN retransmits are indicators of host overload.— §10.3.7。本站译:backlog 丢弃和 SYN 重传,说明主机过载了。
短短一句,把两个看起来属于「网络」的现象归到了主机头上。对写 C/C++ 服务的人,这句话的意思是:ListenOverflows 在涨,第一个该查的是自己的 accept 循环和它所在的线程,而不是交换机。也别只盯着计数本身:模拟器里可以看到,平均处理能力够,一次短暂的停顿也会让队列溢出,被丢的客户端还会在 1 秒、3 秒后成批回来。
带走的几条
一句话:先拆延迟、先看计数,确认问题在不在网络;accept 队列和收发缓冲区,是写服务的人自己能管的两处。
建连慢查网络和内核,首字节慢多半是服务自己。curl -w 或在代码里打点,各量一次。
§10.3.5建连时间落在 1、3、7 秒附近,是 SYN 被丢后在重发;数据重传至少 200 毫秒。p99 突然很高,先想到它。
§10.4.1LISTEN 行 Recv-Q 是排队数,Send-Q 是上限。贴着上限、ListenOverflows 在涨,就是 accept 跟不上。
§10.6.1listen() 的参数会被 somaxconn 截断;新内核的 somaxconn 已是 4096,上限小时先查代码里 listen() 传了多少。
§10.4.3rwnd_limited 是对端读得慢,sndbuf_limited 是自己的发送缓冲小,app_limited 是应用没给够数据。
§10.6.1nstat 默认给自上次以来的增量;ip -s link、netstat -s 是累计值,隔几秒跑两次再比。
§10.6.4收包的软中断挤在一个核上,别的核闲着也帮不上。查网卡队列数,开 RSS 或 RPS。
§10.4.3