CPU 只有 20%,为什么请求 P99 仍然从 20 ms 涨到了 2 s?
时间:2026/05/04
关键词:尾延迟、排队、
strace、ss、perf、tcpdump、压力测试、可观测性
一次线上告警可能长这样:
1 | QPS:基本不变 |
CPU 看起来很空,于是团队开始提出各种猜测:增加线程、调大 backlog、修改 socket 缓冲、把 epoll 换成别的模型。
但 CPU 低只能说明进程没有持续占用 CPU,不能说明它没有变慢。线程可能正在:
- 等待任务队列;
- 等待 mutex 或条件变量;
- 等待磁盘、DNS、数据库或下游服务;
- 被慢客户端的发送背压阻塞;
- 在某个偶发路径中暂停很久;
- 因文件描述符耗尽而不断失败重试。
服务端排障的关键不是掌握最多命令,而是形成一条证据链:先定义现象,再提出可以被证伪的假设,用最小开销的指标缩小范围,最后才使用侵入性工具。
本文会从一个低 CPU、高尾延迟的可运行实验出发,逐层使用应用指标、strace、ss、perf、抓包和调试器定位问题。
一、平均延迟为什么会掩盖真正的问题?
假设 100 个请求中,95 个几乎立即完成,5 个等待 80 毫秒:
1 | 95 个请求:约 0 ms |
平均值只有约 4 ms,看起来非常健康;P99 却接近 80 ms。对用户而言,一次页面加载可能并行发出多个请求,只要其中一个命中长尾,整体体验就会被最慢请求决定。
排障前至少要固定这些事实:
- 问题发生的精确时间窗口;
- 受影响的版本、实例、接口和用户范围;
- 请求量、并发数和连接建立速率;
- P50、P95、P99,而不只是平均值;
- 错误码、超时发生在哪一端;
- 队列长度、在途任务和资源饱和度。
如果连“慢的是连接、排队、业务执行还是响应发送”都没有区分,任何系统调参都只是猜测。
二、最小可运行实验:平均值很低,P99 却很高
下面的 C11 程序执行 100 个模拟请求,每 20 个请求中有一个通过 nanosleep() 等待约 80 ms。它几乎不消耗 CPU,却会产生明显尾延迟。
运行条件:Linux 或 macOS,支持 POSIX clock_gettime() 和 nanosleep()。
1 |
|
编译运行:
1 | cc -std=c11 -O2 -Wall -Wextra -Wpedantic \ |
一次可能的输出:
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 | strace -f -tt -T \ |
-tt 输出时间戳,-T 输出每次系统调用耗时,-f 跟踪后续线程或子进程。实验中会看到慢路径停在 sleep 相关系统调用附近。
这个证据链回答了:
1 | CPU 低 |
真实服务器不一定睡在 nanosleep()。可能出现:
futex():等待 mutex、条件变量或运行时锁;connect():同步连接下游;recvfrom()/read():等待网络、pipe 或文件;fsync():等待持久化;- 反复
accept()返回EMFILE:fd 耗尽后的错误循环。
strace 会拦截系统调用并改变程序时序,高调用频率服务可能受到显著影响。线上应限定 PID、系统调用集合和观察时长,先在副本或预发布复现;不要把被 strace 放大的延迟当成原始性能数据。
统计概览可使用:
1 | strace -f -c -p <PID> |
按 Ctrl-C 停止后查看调用次数、错误和耗时摘要。它适合生成假设,不代替应用阶段计时。
四、第一层证据为什么应该来自应用自身?
操作系统能看到线程睡眠,却不知道“这是等待登录队列,还是等待订单数据库”。最有解释力的证据通常由应用产生。
一次请求可以拆成:
1 | 连接接入 |
每个阶段记录耗时和数量,P99 升高时才能判断延迟在哪一段累积。与当前主题直接相关的指标包括:
| 指标 | 能回答的问题 |
|---|---|
| 当前连接数与建连速率 | 负载形态是否改变 |
| 任务队列长度与排队时长 | worker 是否处理不过来 |
| 活跃 worker / 总 worker | 是否被慢任务占满 |
| 输入、输出缓冲总量 | 是否存在慢读或慢写积压 |
| 各下游调用 P99 和错误率 | 延迟是否来自依赖服务 |
| event loop lag | I/O 线程是否被阻塞 |
| 拒绝、超时、取消数 | 过载策略是否开始生效 |
日志应带请求 ID、连接 ID、阶段和错误码,使单次慢请求能与指标时间窗口对应。不要为每个数据包打印同步日志;观测行为本身也可能成为延迟来源。
五、进程指标怎样区分 CPU、调度、内存和 I/O?
先做低开销快照:
1 | pidstat -p <PID> -t 1 |
这些工具通常来自 sysstat 等软件包,目标机器未必预装;不要在生产机临时全局安装,按现有运维方式提供。
观察时不要只看总 CPU:
- 单个 I/O 线程可能打满一核,但整机显示仍很低;
- 高
sy可能提示系统调用、网络栈或内核工作增加; - 大量自愿上下文切换可能来自阻塞等待;
- 大量非自愿切换可能来自 CPU 竞争;
vmstat的 swap in/out 持续增长可能造成严重尾延迟;- 磁盘 I/O wait 高只是一条线索,仍需结合具体设备与进程 I/O。
内存上涨时先区分:连接数增长、每连接缓冲、任务队列、页缓存、匿名内存还是泄漏。单看 RSS 曲线不能自动得出“内存泄漏”。
六、文件描述符耗尽怎样伪装成网络问题?
查看目标进程而不是当前 shell 的限制:
1 | cat /proc/<PID>/limits |
RLIMIT_NOFILE 达到后,accept()、open()、pipe() 等可能返回 EMFILE。这会表现为新连接失败、日志文件打不开,甚至诊断工具本身无法获得资源。
进一步检查:
1 | lsof -p <PID> |
lsof 可能需要额外权限,也未必默认安装。它适合发现:
- 哪个进程占用端口;
- 某类 fd 是否持续增长;
- 已删除日志文件是否仍被进程持有;
- pipe、socket 和普通文件分别占多少。
一次 /proc 或 lsof 快照不能证明泄漏。需要在相同负载阶段连续观察 fd 类型和数量,确认业务结束后资源是否回落。
七、ss 的 Recv-Q 和 Send-Q 能直接指出谁慢吗?
先查看监听和已建立连接:
1 | ss -lntp 'sport = :8080' |
-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 | perf stat -p <PID> -- sleep 10 |
需要匹配的符号、权限和内核 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 | tcpdump -nn -i eth0 -s 0 -w /tmp/incident.pcap \ |
抓包通常需要相应权限。pcap 可能包含业务数据和凭据,应限制过滤条件、文件权限、大小和保留时间。
抓包可以回答:
- SYN 是否到达本机,三次握手在哪一步停止;
- 请求数据何时到达,响应何时离开;
- 哪一端先发送 FIN 或 RST;
- 是否出现重传、重复 ACK、零窗口或明显 RTT 变化。
但单点抓包也有边界:网卡 offload 可能让主机上看到的分段与线路实际包不同;只在服务端抓不到中间网络设备的丢包位置;加密流量无法直接看到应用语义。必要时在客户端、服务端或中间观测点同时抓取,并校准时间。
十、什么时候需要 GDB,而不是继续看指标?
指标和采样适合回答“哪类请求、哪个阶段经常慢”,GDB 更适合查看某一时刻的具体线程状态或崩溃现场。
死锁或卡住时:
1 | info threads |
多进程程序要先确认跟踪对象:
1 | set follow-fork-mode child |
直接 attach 活跃进程会暂停线程并改变时序,还可能受 ptrace 权限限制。生产崩溃更适合保留 core dump、精确二进制和调试符号,再离线分析。systemd-coredump 环境可评估 coredumpctl,具体命令和存储策略需结合发行版配置。
优化构建中的变量可能被优化掉、栈可能内联,但 -O2 -g 仍比完全没有符号有价值得多。调试产物必须与发生问题的二进制 build ID 对应。
十一、压力测试为什么会“证明一个错误结论”?
一次可信的压测至少要明确:
- 请求模型是短连接、长连接还是连接复用;
- 请求到达是固定速率,还是客户端收到响应后才发下一个;
- 预热时间、采样时间和重复次数;
- 并发、QPS、请求体和数据分布;
- 压测机 CPU、端口、网卡是否先饱和;
- 服务端 P50/P95/P99、错误率与资源指标;
- 每次实验只改变一个主要变量。
尤其要警惕协调遗漏(coordinated omission):闭环客户端必须等上一个响应后才发下一次请求。服务器越慢,客户端发得越少,于是压测工具反而漏掉本应在等待的请求,低估真实尾延迟。
这不代表闭环模型永远错误;它可能正好模拟真实用户行为。但如果目标是验证固定到达速率下的容量,就应使用能够维持目标速率并正确记录计划发送时间的工具。
ab、wrk、hey、iperf3 或自定义客户端各自模拟的协议和负载不同。不要因为工具能输出一个 QPS,就认为它覆盖了业务连接、认证、请求体和响应消费行为。
不要直接在生产环境进行未审批的破坏性压测。
十二、为什么不能一看到异常就改 sysctl?
下面这些参数经常出现在调优文章中:
1 | sysctl net.core.somaxconn |
查看参数没有问题,问题在于把参数名当成根因。
“连接不上”可能来自完全不同的层次:
1 | 客户端临时端口耗尽 |
修改 somaxconn 不能修复 fd 泄漏,增加线程不能修复防火墙,扩大 socket buffer 也不能修复业务线程不读取数据。
正确流程是记录修改前证据,明确某个限制确实被触达,只改一个变量,在相同负载下复测,并准备回滚。内核、容器和云环境还可能有多层限制,具体生效值需要在目标环境验证。
十三、一条可复用的排障路径
面对“CPU 不高但延迟高”,可以按下面的顺序推进:
1 | 1. 固定时间窗口、版本、接口、P99 和错误率 |
每一步都应该产生可保存的证据:时间戳、命令、过滤条件、原始输出、构建版本和环境信息。事故后仅留下“重启后好了”,等于没有获得可复用知识。
十四、常见误区
误区 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 还够”,而是“慢请求的墙钟时间花在了哪里”。
最重要的结论是:
- 平均值会掩盖长尾,应同时观察百分位、错误率、流量和饱和度;
- 应用阶段计时最能解释业务语义,系统工具负责验证更底层的等待与资源状态;
strace适合观察系统调用,perf适合观察 on-CPU 热点,ss与抓包适合观察网络路径;- 所有工具都有开销与盲区,单个指标不能直接等同于根因;
- 压测要控制负载模型、压测机瓶颈和协调遗漏;
- sysctl 调整必须建立在限制已被证据证明的基础上,并通过相同实验复测。
可以直接用于事故处理的一条建议是:在任何修改之前,先保存同一时间窗口内的应用队列、线程状态、socket 队列和错误日志;这四类证据往往比十次无依据重启更接近根因。