线上 Java 或 Go 服务 P99 从 12ms 变成 200ms,指标盘却很干净:CPU 40%,内存稳定,GC 正常,下游 RT 也没变。这种尖刺经常出在内核路径:线程被调度器挂起、futex 等待、或一次 fsync 卡住写路径。bpftrace 能在不改代码、几乎不增加负载的前提下,把这些事件按进程和堆栈捞出来。
先用 30 秒判断战场
是调度延迟还是系统调用阻塞
在目标节点以 root 跑:
bash
bpftrace -e '
profile:hz:99 /comm == "order-api"/ {
@[kstack, ustack] = count();
}
interval:s:30 { exit(); }
'
如果热点全是 schedule、finish_task_switch,优先看 CPU 限流、CFS、cgroup。如果热点是 futex_wait、blk_mq_start_request、tcp_recvmsg,再往锁、磁盘、网络拆。
对照表:
| 栈顶特征 | 方向 |
|---|---|
futex_wait |
用户态锁或线程池排队 |
ep_poll / io_uring_wait |
正常等待,不一定是问题 |
blk_mq_start_request |
磁盘或云盘带宽 |
hrtimer + 长空隙 |
停机迁移或 CPU throttle |
抓系统调用耗时分布
不要一上来就 dump 全量栈
全量 tracepoint:syscalls:sys_enter_* 在高 QPS 节点上会把自己打满。先按进程过滤,只看超过阈值的调用:
bash
bpftrace -e '
tracepoint:syscalls:sys_enter_fsync /comm == "order-api"/ { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_fsync /@start[tid]/ {
$d = nsecs - @start[tid];
if ($d > 5000000) { printf("fsync %d us pid %d\\n", $d/1000, pid); }
delete(@start[tid]);
}
'
5ms 以上的 fsync 在云盘上很常见。业务线程一旦同步刷盘,P99 直接被单次 IO 拖走。修复通常是把刷盘交给专门的 WAL 线程,或把 synchronous=full 改成组提交。
对 futex 同样处理:
bash
bpftrace -e '
tracepoint:syscalls:sys_enter_futex /comm == "order-api"/ { @s[tid] = nsecs; }
tracepoint:syscalls:sys_exit_futex /@s[tid]/ {
@us = hist((nsecs - @s[tid]) / 1000);
delete(@s[tid]);
}
interval:s:15 { print(@us); clear(@us); }
'
直方图如果在 1000us 附近有长尾,说明锁竞争已经进内核。再结合 offcputime 或 Go 的 mutex profile 定位具体锁。
容器里还有一层 cgroup
CPU throttle 会伪装成"偶发卡顿"
K8s 里 cpu.limit 设成 500m 时,CFS 配额用完线程会被挂起,表现和死锁很像,但栈里是 throttle。
bash
bpftrace -e '
kprobe:throttle_cfs_rq {
@[comm] = count();
}
interval:s:10 { print(@); clear(@); }
'
某容器持续出现在这个计数里,先把 limit 去掉或改成只设 request。很多"优化了代码但 P99 不变"的案例,最后是 limit 卡住了突发计算。
操作纪律
- 先过滤
comm或pid,禁止全机跟踪所有 syscall。 - 采样优于追踪:
profile:hz:99比逐次 kprobe 便宜。 - 生产窗口控制在 30 到 60 秒,看完立刻停。
- 结论必须能对应到改动:去掉 fsync、放大 CPU limit、拆锁,而不是留下一堆火焰图。
总结
延迟尖刺优先用 bpftrace 区分调度、锁、磁盘三条路径。30 秒栈采样定方向,再对 fsync 和 futex 做耗时直方图,容器环境额外看 CFS throttle。工具本身很便宜,贵的是没有过滤条件的全量探针。定位到具体系统调用后,修复通常是工程约束,而不是再加一台机器。