凌晨两点,告警群开始闪烁。接口 P99 从 40ms 涨到 2s,CPU 却只有 30%。你打开监控大盘,看到一堆红色但不知道红色在哪。重启?不敢,核心链路。扩容?成本在燃烧,指标不降。这时候最没用的就是「重启大法」——它治好了症状,治好了心情,唯独没治好问题。
这篇讲一套能真正落到生产环境的方法:用 eBPF 给线上进程做火焰图,在不重启、不改代码的前提下,看清它到底卡在哪个内核函数上。名字听着硬核,真上手会发现比「先加日志试试」快得多。
一、先分清:你到底是「CPU 忙」还是「在等」
很多人一上来就抓 CPU 火焰图,这是典型的坑。CPU 火焰图只能回答一个问题:时间花在计算上了吗。而线上大部分「服务变慢」不是算得慢,是等得久:等 IO、等网络、等锁、等下游、等 GC。
# vmstat 1
procs ---memory--- ---swap-- ---io---- -system-- --cpu--
r b swpd free cache bi bo in cs us sy id wa
2 0 0 ... ... 2 1 12 18 5 3 88 4
- us+sy 高(>60%):真·计算密集,上 CPU 火焰图。
- us+sy 不高但 wa(iowait)或 b 高:你在等 IO。这时候抓 CPU 火焰图只会得到「一切正常」——它确实正常,它只是躺着。
- sy 特别高:系统调用或上下文切换过多,典型的「每请求几百次 read/write」。
性能排查第一步永远不是「上什么工具」,而是「先证明问题属于哪一类」。工具是测谎仪,不是放大镜。
二、eBPF 能干什么:一句话版本
eBPF 是 Linux 内核提供的「在内核里跑一小段受验证沙箱程序」的能力。理解成:在系统调用入口和出口挂钩子,把每次调用的耗时现场记下来,且几乎不产生额外开销。
- off-CPU 火焰图:线程不跑 CPU、卡在锁或 IO 上的等待分析。我最常用,专治「CPU 不高但服务很慢」。
- 系统调用延迟分析:哪些 read/write 慢。
- 连接与流量观测:不抓包就能看每条连接的字节数、重传次数。
常用工具:bpftrace(写脚本,零学习成本)、perf(内核自带,采样型)、Parca(持续 profiling)、DeepFlow(eBPF 版全链路观测)。
跟「在 Java 里挂个火焰图插件」区别在哪?语言级火焰图只能看到「你的代码」,eBPF 能看到「你的代码 + 内核 + 库函数 + 系统调用」整条栈。绝大多数「莫名其妙变慢」的根因,都藏在语言 profiler 看不到的那半截栈里。
三、实战:一次 P99 飙高的排查全过程
抽象讲不如实操。假设你有个 Go 写的订单服务,跑在 K8s 里,P99 飙到 2s,CPU 只用了 30%。
第 1 步:确认它慢在哪一段
# 容器内看不到宿主机 PID,加 hostPID 或用 nsenter
nsenter -t <target_pid> -n -m -p -- bash -c 'cat /proc/1/stack'
# 直接看哪个 syscall 吃掉了时间
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_* { @st[probe] = nsecs; }
tracepoint:syscalls:sys_exit_* { @d[probe] = nsecs - @st[probe]; }
interval:s:10 { print(@d); exit(); }
'
如果 sys_enter_read、sys_enter_write 占了大头,那基本可以收工:问题在 IO,不在业务逻辑。
第 2 步:抓 off-CPU 火焰图
git clone https://github.com/firefart/nonblocking-offcpu-profiler.git /tmp/nonblock
sudo /tmp/nonblock/profiler.py -p <PID> -b /tmp/offcpu.svg -d 60
看图从最宽的那一层开始,宽度代表累计等待时间。最宽栈底写着 futex_wait,说明一大堆线程在等锁或等调度;写着 epoll_wait,那恭喜,你只是「没事干」而已;出现 blk_dev_issue 或具体设备名,就是磁盘的问题。
第 3 步:把「等锁」翻译成「哪把锁」
sudo bpftrace -e 'count(*) { @[comm] = count(); }' -c 'sleep 5'
Go 服务的经典现场:goroutine 上万、线程几百。这通常意味着任务队列被塞爆,线程疯狂做上下文切换。这时候再抓 CPU 火焰图,热点会是 runtime.futex 和调度器——根因是业务侧无限制接受请求,不是运行时的锅。
第 4 步:定位到具体是哪个下游
sudo ss -tanp | grep <PID> | awk '{print $1}' | sort | uniq -c
大量 SYN-SENT 说明 DNS 或对端地址不可达;CLOSE_WAIT 成堆是代码没关连接——这东西放几天能把临时端口耗光,属于「小泄漏演变成大故障」的经典剧本。
第 5 步:验证并落地
别急着重启。把结论落成一句可执行的假设,比如「连接池最大 5、空闲 5、超时 0,导致请求串行等待」:
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(50)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
改完再压一次,用同样的指标验证。生产环境的性能工作,说到底是「一次只改一个变量,然后证明它有效」的笨功夫,没有第二条路。
四、五个坑,踩过就别再踩
- 容器 PID 命名空间。宿主机看到的 PID 和容器内不是一回事,要么开
hostPID: true,要么nsenter,否则你抓到的全是不相干的进程。 - 内核版本低于 4.9 基本没戏。能力在 5.x 上才完整,老机器只能退回
perf采样。 - tracepoint 语法随内核版本变。脚本不通用,环境不同就重写,别指望一份脚本打天下。
- 采样本身有干扰。建议先 30 秒粗看,再 3 分钟细看,别一上来挂一小时。
- 别把 eBPF 当万能药。如果服务慢是因为 SQL 写成全表扫描,老实去看执行计划更直接。工具选错,等于用挖掘机挖花盆。
五、我的观点:可观测性的下一步不是更多日志
很多团队的「可观测性」本质是日志堆砌:出事翻日志,日志越存越多,真出事时找不到关键三行。我认为方向应该是从「记录发生了什么」转向「记录在哪里卡住了」。日志是事后叙事,火焰图是现场还原:日志说「订单接口超时了」,火焰图说「订单接口在等 futex,锁持有者在 payment_client.do() 里等 SELECT,而这个 SELECT 在等磁盘」——这两者的差距,就是从「一晚上没睡」到「十分钟定位」的差距。
好的可观测性,不是让你拥有更多数据,而是让你在凌晨两点不用猜。
但也别一上来上全家桶。务实的起步顺序:
- 第一周:学会
vmstat、iostat、ss,能判断问题属于哪一类。免费,且解决一大半判断问题。 - 第一个月:在预发环境跑通 bpftrace 的 syscall 统计和 off-CPU 火焰图,把抓图做成脚本。
- 三个月:接入持续 profiling,让火焰图变成常态化 dashboard,而不是出事才想起来的手电筒。
顺带一句:容器化之后,很多「神秘性能问题」的答案就藏在宿主机上——cgroup 限额、IO 争抢、overlayfs 写放大。多看一眼宿主机,往往比在容器内部抓十轮图都管用。
六、写在最后
性能优化的浪漫在于「找到那一行代码」的快感,但真实世界更残酷:你找到的往往不是一行代码,而是「一个组件 + 一个配置 + 一个坏习惯」的组合拳。eBPF 给你「看得见」的能力,剩下的判断和取舍还得靠人。
所以下次 P99 又飙了,别第一反应扩容,也别第一反应重启。先问三个问题:CPU 真的忙吗(vmstat)、它卡在谁的锁上(off-CPU 火焰图)、等的那个下游连接状态正常吗(ss)。三个问题,五分钟,比重启一次赌运气稳得多。
如果这篇有点用,欢迎在评论区告诉我你踩过哪几个坑。我赌你至少在第一条上栽过。