为什么用 top 看不出 Web 服务的真实瓶颈
服务器变慢时,最先被打开的工具是 top。它给出 CPU 占用、内存使用、负载三个数字,然后运维开始猜:负载高是不是 CPU 不够、内存是不是满了、是不是磁盘慢。这些猜测有一半是错的,因为在 Web 服务的场景里,慢的原因常常藏在 top 看不见的地方——进程明明只有百分之一 CPU,却在等一把锁、等一次 DNS 查询、等一个文件描述符。
eBPF 的价值就在这里:它能在内核里挂探针,直接观测一次系统调用的耗时、一个连接的生命周期、一次文件读写的分布,而不需要改应用代码、不需要重启服务、开销通常低于百分之一。这篇文章假设你有一台 Debian 12 的 Nginx + PHP-FPM 服务器,从安装工具到实际排查五个典型故障,走一遍可复制的路径。
先分清三个层级的观测工具
上机之前先把工具的角色分清楚,能省掉大量来回试错。观测工具大致分三层。
第一层是计数器型,比如 vmstat、iostat、ss -s、/proc/net/snmp。它们给的是「一段时间内发生了多少次」,成本可忽略,适合判断方向,但看不出单次操作耗时,也不知道是谁触发的。
第二层是把计数器按维度拆分。比如想确认是哪一块盘慢,就需要按设备名拆分;想知道是哪个队列满了,就要看每个 CPU 的软中断。工具是 iostat -x 1、mpstat -P ALL 1、sar 系列。
第三层是 eBPF 这一类,它的能力是「按调用栈、按进程、按函数参数」观测。比如「哪个 PHP 进程在 read 这个文件时等了最久」「Nginx 打开连接和读请求头分别花了多少微秒」,这些只有内核探针能给。代价是需要 root、可能需要安装内核头文件,而且一次挂载几十个探针的脚本会带来可测量的开销,所以用完要记得收。
apt-get install -y bpftrace linux-headers-$(uname -r)
bpftrace --version
# 若报 "BTF not found",说明内核未启用 BTF,退化到安装对应的 debug 包:
# apt-get install -y linux-image-$(uname -r)-dbgDebian 12 自带的内核一般是开启了 BTF 的,bpftrace 直接可用。如果手上是更老的系统或者自编译内核,没有 BTF 就会退回到安装 debuginfo,体积很大但功能等价。先跑 bpftrace -l 'tracepoint:syscalls:sys_enter_openat' 确认探针能列出来,再往下做。
案例一:慢请求到底慢在系统调用还是应用逻辑
现象是后台某些接口偶尔要五秒以上,但同一时刻的 CPU 和 IO 都很闲。第一步要判断慢在用户态还是内核态。最省力的方法是看进程处于什么状态:如果大量进程是 D 状态(不可中断睡眠),说明在等 IO 或锁;如果都是 S 状态并且 CPU 不高,说明可能在等网络或等信号。
确认之后,用 bpftrace 按进程统计系统调用耗时,一次只看超过五十毫秒的调用。
bpftrace -e '
tracepoint:syscalls:sys_enter_openat { @start[tid] = nsecs; @name[tid] = str(args->filename); }
tracepoint:syscalls:sys_exit_openat /@start[tid]/ {
$d = (nsecs - @start[tid]) / 1000000;
if ($d > 50) { @slow[comm, @name[tid]] = hist($d); }
delete(@start[tid]); delete(@name[tid]);
}
interval:s:30 { exit(); }'这段脚本用 tid 作为键,在进入 openat 时记下时间戳和文件名,退出时算差值,超过五十毫秒就按进程名加文件名做直方图。跑三十秒后它自己退出并打印结果。典型输出会指向某几个具体文件——这时真相往往很简单:要么是 NFS 挂载的目录在超时重试,要么是 PHP 的 session 目录在 /tmp 上被清理脚本锁住,要么是某个 include 路径在多层目录里逐个尝试。
顺带提一个非常常见的组合:如果统计出来 openat 占了大头但每次都不慢、只是次数极多,那就是文件打开次数问题,方向从「修慢调用」转成「开 opcache、调 realpath_cache_size、减少 include 路径层数」。同样是 openat 的观测结果,两种结论对应完全不同的处置。
案例二:连接数与文件描述符
Nginx 报 Too many open files 或者大量连接卡在 SYN_RECV、CLOSE_WAIT,通常不是「连接太多」这么简单。先把连接状态分布打出来,这一步用 ss -s 就够。
ss -s
ss -tan state established | wc -l
ss -tan state time-wait | wc -l
cat /proc/sys/net/core/somaxconn
ulimit -n; cat /proc/$(pgrep -o nginx)/limits | grep 'open files'四种状态对应四个完全不同的原因。TIME_WAIT 堆积说明是服务端主动关闭连接,量在每秒几百以内且不持续增长属正常,持续涨就是短连接太多,考虑 keepalive。CLOSE_WAIT 堆积说明对端已关闭而本端没关,几乎一定是应用代码没有正确关闭 socket,比如 PHP 里手写 fsockopen 没关、或者某个上游超时后连接未释放。SYN_RECV 堆积是 accept 队列满,看 somaxconn 和 nginx 的 worker 数。ESTABLISHED 远超预期而 CPU 不高,多半是后端慢导致前端连接堆积,要回到案例一去查。
另一处常见坑是文件描述符限制的层级。systemd 启动的服务受 unit 文件的 LimitNOFILE 控制,shell 里的 ulimit -n 改了也不影响已经由 systemd 拉起的进程。判断当前生效值一定要读 /proc/<pid>/limits,那里面的数字才是真的。
案例三:磁盘 IO 的假象
iostat -x 1 里 %util 到百分之百就代表磁盘满了?不一定。对 SSD 和云盘来说,%util 反映的是「有请求在进行的时间比例」,只要队列里一直有请求它就接近满,但吞吐可能远没有到上限。真正该看的是 await(平均等待毫秒)和 aqu-sz(平均队列深度)。await 超过十毫秒、aqu-sz 持续大于二,才是真的在排队。
进一步想知道是谁在写盘,用 bpftrace 统计块设备请求的发起位置。
bpftrace -e '
tracepoint:block:block_rq_issue { @bytes[comm] = sum(args->bytes); }
interval:s:10 { print(@bytes); clear(@bytes); }'输出按进程名汇总十秒内发起的 IO 字节数,很快就能看出是 MySQL 刷盘、是日志写入、还是备份脚本。这一步比猜省时间得多。如果发现是慢查询导致的临时表落盘,就该回数据库层去调 tmp_table_size 或改写 SQL,而不是去换磁盘。
还有一个直接影响 Web 服务的问题:文件系统层面的 fsync。假设某个版本发布后写日志的延迟变高,可以直接抓 fsync 的耗时分布。
bpftrace -e '
tracepoint:syscalls:sys_enter_fsync { @s[tid] = nsecs; }
tracepoint:syscalls:sys_exit_fsync /@s[tid]/ {
@ms = hist((nsecs - @s[tid]) / 1000000); delete(@s[tid]);
}
interval:s:15 { exit(); }'如果直方图的主峰落在几十毫秒以上,说明每次落盘都很贵,方向是减少 fsync 次数(比如日志改成批量刷)、或者把日志目录换到独立的盘,而不是继续怀疑 PHP。
案例四:DNS 与后端服务的连接建立
站点里配了 upstream 用域名而不是 IP 时,Nginx 会在启动时解析一次并缓存,域名换了 IP 而没重载,流量就会打到旧机器上;反过来如果每个请求都解析,DNS 慢了会直接拖慢整个请求。判断当前是哪种行为,先看配置里 upstream 是否写了 resolver。resolver 存在意味着运行期解析,不存在则只在启动或 reload 时解析一次。
要量化解析耗时,用 bpftrace 观测 connect 的成败和耗时,再配合 dig 的响应时间做对照。
bpftrace -e '
tracepoint:syscalls:sys_enter_connect { @c[tid] = nsecs; }
tracepoint:syscalls:sys_exit_connect /@c[tid]/ {
$ms = (nsecs - @c[tid]) / 1000000;
@conn[comm, args->retval == 0 ? "ok" : "fail"] = hist($ms);
delete(@c[tid]);
}
interval:s:20 { exit(); }'connect 的直方图如果出现明显的双峰,一峰在零点几毫秒、一峰在几百毫秒以上,基本可以断定是「本地回环快、远端有超时重试」。这时再去看 /etc/resolv.conf 里的 nameserver 数量、以及后端服务是否在前置防火墙层被限速。很多「偶发五秒延迟」的根因都在这里——三个 nameserver 顺序尝试,每个超时一点几秒,乘起来正好五秒。
案例五:软中断与网卡队列
流量高峰时 CPU 使用率整体不高,但单核被打到百分之百,表现为 mpstat -P ALL 1 里某一个 CPU 的 %soft 极高。这是软中断集中在一个核上,通常是网卡多队列没有开启或中断亲和性没配。先确认网卡队列数。
ethtool -l eth0
cat /proc/interrupts | grep eth0
cat /proc/softirqs | head -5
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlogethtool -l 显示的 Combined 队列数如果是 1,就说明只有一个队列,所有网络中断都落在同一个核上。/proc/interrupts 里对应行的计数分布也能印证——如果只有一个数字在涨,说明中断没有分散。/proc/softirqs 的 NET_RX 列则能看出哪个 CPU 在处理接收软中断。
处置方法是开启多队列、按 CPU 数量配置队列,再把每个队列的中断绑定到不同的核,并确保 nginx 的 worker 数和核数匹配。这里需要注意一个反直觉的点:在虚拟机上,多队列和宿主的网络配置相关,有时候开了队列反而因为宿主的限制变慢,改动后一定要用真实压测对比,不要只看核数对齐就认为变好了。
把观测变成习惯
eBPF 这类工具最大的问题不是不会用,而是平时的机器上没装,出事时才想起来装,而故障当下不敢在服务器上跑 apt。有三个约定能避免这个问题。
第一,把 bpftrace 和内核头文件写进基础镜像或初始化脚本。安装本身只占几十兆,比故障时手忙脚乱省事得多。装好之后跑一条最简单的 bpftrace -l 'tracepoint:syscalls:sys_enter_accept4' 验证可用。
第二,把常用的几段脚本存成文件,做成参数化的命令。上面五段脚本可以整理成一个 observe.sh,按子命令调用,避免每次现场敲单引号里的多行脚本敲错。同时给每段脚本都加自带的超时退出(interval:s:30 { exit(); }),防止忘了收探针。
第三,任何一次排查都记录「现象—工具—结论」三行。半年之后再次遇到同类问题,这三行比任何监控仪表盘都快。站长的运维经验值钱的地方不在于知道多少个工具,而在于知道「这个现象该先开哪个工具」。
最后补一句边界:eBPF 观测的是系统层的事实,它不会告诉你业务逻辑哪里写错了。它能把「慢」精确定位到某一次系统调用、某一次连接、某一次刷盘,但从那里到修改代码,仍然需要人。把它当成一把更细的尺子,而不是自动修复器,用起来会更踏实。