CPU 只有 20%,为什么请求 P99 仍然从 20 ms 涨到了 2 s?

时间:2026/05/04

关键词:尾延迟、排队、stracessperftcpdump、压力测试、可观测性

一次线上告警可能长这样:

1
2
3
4
5
QPS:基本不变
平均延迟:45 ms
P99:2.1 s
错误率:开始升高
CPU:20%

CPU 看起来很空,于是团队开始提出各种猜测:增加线程、调大 backlog、修改 socket 缓冲、把 epoll 换成别的模型。

但 CPU 低只能说明进程没有持续占用 CPU,不能说明它没有变慢。线程可能正在:

  • 等待任务队列;
  • 等待 mutex 或条件变量;
  • 等待磁盘、DNS、数据库或下游服务;
  • 被慢客户端的发送背压阻塞;
  • 在某个偶发路径中暂停很久;
  • 因文件描述符耗尽而不断失败重试。

服务端排障的关键不是掌握最多命令,而是形成一条证据链:先定义现象,再提出可以被证伪的假设,用最小开销的指标缩小范围,最后才使用侵入性工具。

本文会从一个低 CPU、高尾延迟的可运行实验出发,逐层使用应用指标、stracessperf、抓包和调试器定位问题。


一、平均延迟为什么会掩盖真正的问题?

假设 100 个请求中,95 个几乎立即完成,5 个等待 80 毫秒:

1
2
95 个请求:约 0 ms
5 个请求:约 80 ms

平均值只有约 4 ms,看起来非常健康;P99 却接近 80 ms。对用户而言,一次页面加载可能并行发出多个请求,只要其中一个命中长尾,整体体验就会被最慢请求决定。

排障前至少要固定这些事实:

  • 问题发生的精确时间窗口;
  • 受影响的版本、实例、接口和用户范围;
  • 请求量、并发数和连接建立速率;
  • P50、P95、P99,而不只是平均值;
  • 错误码、超时发生在哪一端;
  • 队列长度、在途任务和资源饱和度。

如果连“慢的是连接、排队、业务执行还是响应发送”都没有区分,任何系统调参都只是猜测。


二、最小可运行实验:平均值很低,P99 却很高

下面的 C11 程序执行 100 个模拟请求,每 20 个请求中有一个通过 nanosleep() 等待约 80 ms。它几乎不消耗 CPU,却会产生明显尾延迟。

运行条件:Linux 或 macOS,支持 POSIX clock_gettime()nanosleep()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#define _POSIX_C_SOURCE 200809L

#include <time.h>

#include <errno.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

static uint64_t monotonic_microseconds(void) {
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) < 0) {
perror("clock_gettime");
exit(EXIT_FAILURE);
}
return (uint64_t)now.tv_sec * 1000000ULL +
(uint64_t)now.tv_nsec / 1000ULL;
}

static int compare_u64(const void *left, const void *right) {
uint64_t a = *(const uint64_t *)left;
uint64_t b = *(const uint64_t *)right;
return (a > b) - (a < b);
}

static uint64_t percentile(const uint64_t *sorted,
size_t count,
unsigned percent) {
size_t rank = ((size_t)percent * count + 99) / 100;
if (rank == 0) {
rank = 1;
}
return sorted[rank - 1];
}

int main(void) {
enum { SAMPLE_COUNT = 100 };
uint64_t samples[SAMPLE_COUNT];
uint64_t total = 0;

for (size_t i = 0; i < SAMPLE_COUNT; ++i) {
uint64_t start = monotonic_microseconds();

if ((i + 1) % 20 == 0) {
struct timespec remaining = {.tv_sec = 0,
.tv_nsec = 80000000L};
while (nanosleep(&remaining, &remaining) < 0 &&
errno == EINTR) {
}
}

samples[i] = monotonic_microseconds() - start;
total += samples[i];
}

qsort(samples, SAMPLE_COUNT, sizeof(samples[0]), compare_u64);
printf("average=%" PRIu64 " us, p95=%" PRIu64
" us, p99=%" PRIu64 " us, max=%" PRIu64 " us\n",
total / SAMPLE_COUNT,
percentile(samples, SAMPLE_COUNT, 95),
percentile(samples, SAMPLE_COUNT, 99),
samples[SAMPLE_COUNT - 1]);
return EXIT_SUCCESS;
}

编译运行:

1
2
3
cc -std=c11 -O2 -Wall -Wextra -Wpedantic \
tail_latency.c -o tail_latency
./tail_latency

一次可能的输出:

1
average=4004 us, p95=1 us, p99=80070 us, max=80083 us

具体数值受调度和计时精度影响,但关系应保持:平均值很低,P99 和最大值接近慢路径时间。

CLOCK_MONOTONIC 不受人工修改系统墙上时间的直接影响,适合测量持续时间。生产监控还要统一百分位定义和聚合方式;不能简单把各实例 P99 再取平均,因为那通常不能还原全局请求分布。


三、怎样证明程序是在等待,而不是在计算?

先使用 time 比较墙钟时间和 CPU 时间:

1
/usr/bin/time -p ./tail_latency

如果 real 约 0.4 秒,而 user + sys 很小,说明大部分时间不在 CPU 上执行。下一步在 Linux 上查看系统调用:

1
2
strace -f -tt -T \
-e trace=clock_nanosleep,nanosleep ./tail_latency

-tt 输出时间戳,-T 输出每次系统调用耗时,-f 跟踪后续线程或子进程。实验中会看到慢路径停在 sleep 相关系统调用附近。

这个证据链回答了:

1
2
3
4
CPU 低
→ 墙钟时间明显高于 CPU 时间
→ strace 显示线程在阻塞系统调用中等待
→ 问题不是“CPU 算不动”,而是等待路径

真实服务器不一定睡在 nanosleep()。可能出现:

  • futex():等待 mutex、条件变量或运行时锁;
  • connect():同步连接下游;
  • recvfrom() / read():等待网络、pipe 或文件;
  • fsync():等待持久化;
  • 反复 accept() 返回 EMFILE:fd 耗尽后的错误循环。

strace 会拦截系统调用并改变程序时序,高调用频率服务可能受到显著影响。线上应限定 PID、系统调用集合和观察时长,先在副本或预发布复现;不要把被 strace 放大的延迟当成原始性能数据。

统计概览可使用:

1
strace -f -c -p <PID>

Ctrl-C 停止后查看调用次数、错误和耗时摘要。它适合生成假设,不代替应用阶段计时。


四、第一层证据为什么应该来自应用自身?

操作系统能看到线程睡眠,却不知道“这是等待登录队列,还是等待订单数据库”。最有解释力的证据通常由应用产生。

一次请求可以拆成:

1
2
3
4
5
6
7
连接接入
→ 输入缓冲等待完整消息
→ 任务队列等待
→ 业务执行
→ 下游调用
→ 输出队列等待
→ socket 发送

每个阶段记录耗时和数量,P99 升高时才能判断延迟在哪一段累积。与当前主题直接相关的指标包括:

指标 能回答的问题
当前连接数与建连速率 负载形态是否改变
任务队列长度与排队时长 worker 是否处理不过来
活跃 worker / 总 worker 是否被慢任务占满
输入、输出缓冲总量 是否存在慢读或慢写积压
各下游调用 P99 和错误率 延迟是否来自依赖服务
event loop lag I/O 线程是否被阻塞
拒绝、超时、取消数 过载策略是否开始生效

日志应带请求 ID、连接 ID、阶段和错误码,使单次慢请求能与指标时间窗口对应。不要为每个数据包打印同步日志;观测行为本身也可能成为延迟来源。


五、进程指标怎样区分 CPU、调度、内存和 I/O?

先做低开销快照:

1
2
3
pidstat -p <PID> -t 1
vmstat 1
mpstat -P ALL 1

这些工具通常来自 sysstat 等软件包,目标机器未必预装;不要在生产机临时全局安装,按现有运维方式提供。

观察时不要只看总 CPU:

  • 单个 I/O 线程可能打满一核,但整机显示仍很低;
  • sy 可能提示系统调用、网络栈或内核工作增加;
  • 大量自愿上下文切换可能来自阻塞等待;
  • 大量非自愿切换可能来自 CPU 竞争;
  • vmstat 的 swap in/out 持续增长可能造成严重尾延迟;
  • 磁盘 I/O wait 高只是一条线索,仍需结合具体设备与进程 I/O。

内存上涨时先区分:连接数增长、每连接缓冲、任务队列、页缓存、匿名内存还是泄漏。单看 RSS 曲线不能自动得出“内存泄漏”。


六、文件描述符耗尽怎样伪装成网络问题?

查看目标进程而不是当前 shell 的限制:

1
2
cat /proc/<PID>/limits
ls /proc/<PID>/fd | wc -l

RLIMIT_NOFILE 达到后,accept()open()pipe() 等可能返回 EMFILE。这会表现为新连接失败、日志文件打不开,甚至诊断工具本身无法获得资源。

进一步检查:

1
2
lsof -p <PID>
lsof -i :8080

lsof 可能需要额外权限,也未必默认安装。它适合发现:

  • 哪个进程占用端口;
  • 某类 fd 是否持续增长;
  • 已删除日志文件是否仍被进程持有;
  • pipe、socket 和普通文件分别占多少。

一次 /proclsof 快照不能证明泄漏。需要在相同负载阶段连续观察 fd 类型和数量,确认业务结束后资源是否回落。


七、ss 的 Recv-Q 和 Send-Q 能直接指出谁慢吗?

先查看监听和已建立连接:

1
2
3
ss -lntp 'sport = :8080'
ss -tinm 'sport = :8080'
ss -s

-i 显示 TCP 内部信息,如 RTT、重传超时和拥塞窗口;-m 显示 socket 内存。查看其他进程信息通常需要足够权限。

队列列必须结合 socket 状态解释:

socket 状态 Recv-Q Send-Q
已建立 TCP 应用尚未取走的接收数据 尚未被远端确认的发送数据
监听 TCP 当前等待应用 accept 的连接 配置的队列上限相关值

对已建立连接而言,Recv-Q 长期增长常说明应用读取不及时;Send-Q 长期很大可能来自对端慢读、接收窗口、网络拥塞或丢包。它们是方向线索,不是唯一根因。

还应关注 TCP 状态分布:

  • 大量 CLOSE-WAIT:本端收到 FIN 后没有及时关闭;
  • 大量 TIME-WAIT:常见于主动关闭和短连接,本身不等于故障;
  • SYN-RECV 异常增长:连接握手或 accept 前链路需要进一步检查;
  • 重传、RTT 或 backoff 上升:需要与抓包、网络设备和对端指标关联。

不要看到 TIME_WAIT 就立即开启激进复用参数,也不要看到 Send-Q 就直接调大发送缓冲。更大的缓冲可能只让过期数据排得更久。


八、什么时候应该用 perf,什么时候它会误导?

CPU 持续较高时,采样 profiler 可以回答“CPU 时间花在哪里”:

1
2
3
perf stat -p <PID> -- sleep 10
perf record -F 99 -g -p <PID> -- sleep 15
perf report

需要匹配的符号、权限和内核 perf 配置。线上采样频率和持续时间应受控。

perf record 的常规 CPU 采样主要看线程正在运行时的调用栈。如果线程 99% 时间在等待 mutex、I/O 或队列,它几乎不会出现在 on-CPU 热点顶部。这时“perf 没有热点”不是“程序没有问题”,而是问题可能在 off-CPU 时间。

可以结合系统调用追踪、调度事件、eBPF/off-CPU 工具或应用阶段指标继续定位。bpftrace、BCC 等能力依赖内核版本、调试信息和权限,应使用团队现有工具链,避免在事故现场临时运行未经验证的高开销脚本。

性能分析应尽量使用接近生产的优化构建并保留符号,例如 -O2 -g-O0 便于逐行调试,却可能改变锁竞争、内联、内存访问和时序,不能代表真实性能。


九、抓包能回答什么,不能回答什么?

当问题可能位于应用与网络边界时,在 Linux 上使用:

1
tcpdump -nn -i any 'tcp port 8080'

保存原始数据供 Wireshark 等工具分析:

1
2
tcpdump -nn -i eth0 -s 0 -w /tmp/incident.pcap \
'tcp port 8080'

抓包通常需要相应权限。pcap 可能包含业务数据和凭据,应限制过滤条件、文件权限、大小和保留时间。

抓包可以回答:

  • SYN 是否到达本机,三次握手在哪一步停止;
  • 请求数据何时到达,响应何时离开;
  • 哪一端先发送 FIN 或 RST;
  • 是否出现重传、重复 ACK、零窗口或明显 RTT 变化。

但单点抓包也有边界:网卡 offload 可能让主机上看到的分段与线路实际包不同;只在服务端抓不到中间网络设备的丢包位置;加密流量无法直接看到应用语义。必要时在客户端、服务端或中间观测点同时抓取,并校准时间。


十、什么时候需要 GDB,而不是继续看指标?

指标和采样适合回答“哪类请求、哪个阶段经常慢”,GDB 更适合查看某一时刻的具体线程状态或崩溃现场。

死锁或卡住时:

1
2
3
4
info threads
thread apply all bt
thread 3
bt full

多进程程序要先确认跟踪对象:

1
2
3
set follow-fork-mode child
set detach-on-fork off
info inferiors

直接 attach 活跃进程会暂停线程并改变时序,还可能受 ptrace 权限限制。生产崩溃更适合保留 core dump、精确二进制和调试符号,再离线分析。systemd-coredump 环境可评估 coredumpctl,具体命令和存储策略需结合发行版配置。

优化构建中的变量可能被优化掉、栈可能内联,但 -O2 -g 仍比完全没有符号有价值得多。调试产物必须与发生问题的二进制 build ID 对应。


十一、压力测试为什么会“证明一个错误结论”?

一次可信的压测至少要明确:

  • 请求模型是短连接、长连接还是连接复用;
  • 请求到达是固定速率,还是客户端收到响应后才发下一个;
  • 预热时间、采样时间和重复次数;
  • 并发、QPS、请求体和数据分布;
  • 压测机 CPU、端口、网卡是否先饱和;
  • 服务端 P50/P95/P99、错误率与资源指标;
  • 每次实验只改变一个主要变量。

尤其要警惕协调遗漏(coordinated omission):闭环客户端必须等上一个响应后才发下一次请求。服务器越慢,客户端发得越少,于是压测工具反而漏掉本应在等待的请求,低估真实尾延迟。

这不代表闭环模型永远错误;它可能正好模拟真实用户行为。但如果目标是验证固定到达速率下的容量,就应使用能够维持目标速率并正确记录计划发送时间的工具。

abwrkheyiperf3 或自定义客户端各自模拟的协议和负载不同。不要因为工具能输出一个 QPS,就认为它覆盖了业务连接、认证、请求体和响应消费行为。

不要直接在生产环境进行未审批的破坏性压测。


十二、为什么不能一看到异常就改 sysctl?

下面这些参数经常出现在调优文章中:

1
2
3
sysctl net.core.somaxconn
sysctl net.ipv4.tcp_max_syn_backlog
sysctl net.ipv4.ip_local_port_range

查看参数没有问题,问题在于把参数名当成根因。

“连接不上”可能来自完全不同的层次:

1
2
3
4
5
6
7
客户端临时端口耗尽
防火墙或安全组丢弃
SYN 队列压力
accept 队列积压
进程 fd 达到上限
应用 event loop 没有及时 accept
服务根本没有监听正确地址

修改 somaxconn 不能修复 fd 泄漏,增加线程不能修复防火墙,扩大 socket buffer 也不能修复业务线程不读取数据。

正确流程是记录修改前证据,明确某个限制确实被触达,只改一个变量,在相同负载下复测,并准备回滚。内核、容器和云环境还可能有多层限制,具体生效值需要在目标环境验证。


十三、一条可复用的排障路径

面对“CPU 不高但延迟高”,可以按下面的顺序推进:

1
2
3
4
5
6
7
8
9
10
11
12
13
1. 固定时间窗口、版本、接口、P99 和错误率

2. 看应用阶段:排队、执行、下游、输出分别多慢

3. 看进程:线程 CPU、上下文切换、fd、内存、swap

4. 看 socket:监听、状态分布、Recv-Q/Send-Q、RTT、重传

5. 用 strace 验证阻塞系统调用,用 perf 验证 CPU 热点

6. 需要时抓包或采集线程栈

7. 形成单一根因假设,做最小修改并在同负载下复测

每一步都应该产生可保存的证据:时间戳、命令、过滤条件、原始输出、构建版本和环境信息。事故后仅留下“重启后好了”,等于没有获得可复用知识。


十四、常见误区

误区 1:CPU 低说明服务器还有大量容量

线程可能在等待锁、I/O 或下游。容量要结合队列、并发、尾延迟和饱和指标判断。

误区 2:平均延迟正常就没有问题

少量极慢请求可能被平均值稀释。用户体验和超时通常更受尾延迟影响。

误区 3:perf top 没热点说明代码没问题

常规 CPU 采样看不到大部分 off-CPU 等待。应根据假设选择系统调用、调度或应用阶段观测。

误区 4:Recv-Q 或 Send-Q 大就能确定根因

它们提供数据积压方向,还需要结合 socket 状态、对端指标、RTT、重传和应用读取行为解释。

误区 5:压测 QPS 越高,服务性能越好

如果错误、尾延迟或积压失控,更高 QPS 可能只是客户端没有完整消费响应,或系统正在透支队列。

误区 6:线上 attach 工具只是“看一下”,不会影响程序

GDB 会暂停线程,strace 会改变系统调用时序,抓包和高频采样也消耗资源。必须控制范围并了解观测开销。


十五、总结

回到开头:CPU 只有 20% 而 P99 达到 2 秒,最合理的第一判断不是“CPU 还够”,而是“慢请求的墙钟时间花在了哪里”。

最重要的结论是:

  1. 平均值会掩盖长尾,应同时观察百分位、错误率、流量和饱和度;
  2. 应用阶段计时最能解释业务语义,系统工具负责验证更底层的等待与资源状态;
  3. strace 适合观察系统调用,perf 适合观察 on-CPU 热点,ss 与抓包适合观察网络路径;
  4. 所有工具都有开销与盲区,单个指标不能直接等同于根因;
  5. 压测要控制负载模型、压测机瓶颈和协调遗漏;
  6. sysctl 调整必须建立在限制已被证据证明的基础上,并通过相同实验复测。

可以直接用于事故处理的一条建议是:在任何修改之前,先保存同一时间窗口内的应用队列、线程状态、socket 队列和错误日志;这四类证据往往比十次无依据重启更接近根因。


参考资料