
线上有个服务CPU 使用率长期只有 12%但 P99 延迟从 80ms 悄悄涨到了 1.2s运维看监控面板一脸茫然——CPU 不忙、内存不涨、磁盘 IO 也不高那这一秒多到底耗在哪了。这类问题我在 Linux 性能分析里遇到过很多次答案几乎都指向同一个盲区大家习惯盯着 On-CPU 的热点函数看却忘了进程一生中真正跑在 CPU 上的时间往往只占很小一部分剩下的大头全是 Off-CPU 的等待。这篇东西就是围绕 On-CPU 和 Off-CPU 这两条主线把 Linux 上从工具选型、参数计算、采集命令到火焰图读图、等待类型分诊的完整链路捋一遍。内容偏向实操适合已经在用 perf、看过几张火焰图但还没搞明白CPU 不忙为什么还慢的同学也适合做稳定性保障、想给自己工具箱补上 Off-CPU 这块短板的同行。全文涉及的命令都能直接抄参数为什么这么定我也会一并说清楚。1. 概念厘清On-CPU 与 Off-CPU 的边界在哪1.1 用食堂打饭理解两种耗时一个线程从被创建到退出它的墙上时间wall time可以被切成两段一段是真正占着 CPU 核心执行指令的时间叫 On-CPU 时间剩下的全是 Off-CPU 时间也就是它没在跑的时间。用食堂打饭类比你从进门到端着饭坐下总共花了 20 分钟其中真正被阿姨打菜的时间只有 1 分钟剩下 19 分钟分别花在排队、找座位、等同伴、发现饭卡没钱回去拿钱上。On-CPU 分析找的是阿姨打菜为什么这么慢Off-CPU 分析找的是这 19 分钟堵在哪个环节了。绝大多数人做性能优化只做了前一半所以才会出现 CPU 使用率很低但系统很慢的怪现象。再把 Off-CPU 拆细一点它其实包含两类性质完全不同的等待第一类是可运行但没被调度runnable but not running线程状态是 R随时可以被扔上 CPU只是当前没有空闲核心或者被别的更高优先级任务抢了第二类是被阻塞blocked线程状态变成 S可中断睡眠或 D不可中断睡眠它连被调度的资格都没有得等某个事件把它唤醒。这两类的排查手法、优化方向完全不同前者要查 CPU 是否饱和、cgroup 有没有限流、优先级调度是否合理后者要查 IO、锁、网络、定时器。分不清这两类Off-CPU 分析就会变成一团浆糊。1.2 On-CPU 视角能回答什么不能回答什么On-CPU 分析的核心是采样以固定频率中断 CPU把当前正在执行的指令地址和调用栈抓下来跑一段时间后统计哪些函数被采到的次数最多。它的强项非常突出——能直接告诉你 CPU 时间被哪些代码吃掉了是用户态的算法热点还是内核态的系统调用和内存管理开销配合火焰图看调用链的宽度一眼就能定位到某个具体的计算或者某个低效的序列化逻辑。我之前优化过一个日志模块On-CPU 火焰图上snprintf下面挂着vfprintf的宽条改成预分配缓冲拼接后 CPU 直接降了三成这就是 On-CPU 的典型战绩。但它有个硬边界只统计在 CPU 上跑的那部分时间。如果瓶颈是等待On-CPU 火焰图上什么都看不见或者只会看到一个很矮很虚的结论——没什么热点。很多新手在这里会误判认为没热点就是没问题实际上代码可能 95% 的时间都在futex_wait或者epoll_wait里睡着采样根本抓不到。还有一个容易被忽略的点On-CPU 采样统计的是样本分布不是精确耗时。样本数太少时比如只采了几百个样本单个函数的占比误差可能到百分之十几做结论前一定要看样本总量够不够。经验值是想让占比误差控制在 1% 量级总样本数最好上千这也决定了后面采样时长和频率怎么定。1.3 Off-CPU 为什么总被漏掉Off-CPU 分析长期缺位原因很实际实现起来比 On-CPU 麻烦得多。On-CPU 采样只需要周期性打断而 Off-CPU 需要在进程发生上下文切换的瞬间记录时间戳等它下次被唤醒时再算差值这要求工具能挂到内核的调度事件上。早年能做这件事的基本只有 ftrace配置繁琐、输出需要自己写脚本解析几乎没有可视化。eBPF 成熟之后情况才彻底变了一个offcputime命令就能把带用户态和内核态调用栈的等待时间全抓出来还能折叠成火焰图。另一个原因是思维惯性。监控面板上 CPU、内存、磁盘、网络四大件都在显眼位置唯独进程在等什么没有直观指标。load average 算半个但它把 runnable 和 D 状态进程混在一起算信息量很粗。真正能直接反映 Off-CPU 的指标比如每个进程的 run queue latency、阻塞时长分布默认都不会被采集。所以下次遇到资源都不忙但就是慢先别怀疑人生八成是 Off-CPU 出了状况把这一侧的观测补上问题往往就现形了。2. 方案设计两种视角的观测矩阵与工具选型2.1 先做判断这次该看哪一边动手之前先花两分钟做个粗略分诊能省掉大量无效采集。把系统当成一个黑盒输入是请求量输出是延迟中间看四个信号CPU 整体使用率、load average 与核心数的比值、run queue 长度vmstat 1的 r 列、D 状态进程数。如果 CPU 使用率贴近核心数上限、load 大于核心数、r 列持续大于核数那基本是 CPU 争抢优先做 On-CPU顺带看 runqlat。如果 CPU 使用率低、load 却很高或者 D 状态进程一堆那重点在阻塞型 Off-CPU。如果 CPU 不高、load 也不高但延迟抖那大概率是 runnable 等待或者间歇性锁争抢得靠 Off-CPU 追踪按时间轴看。我通常的固定动作是三条命令快速摸底vmstat 1 5 mpstat -P ALL 1 5 ps -eo pid,stat,wchan:32,comm --sort-pcpu | awk NR1 || $2 ~ /^D/第一条看 r 列和 CPU 分布第二条看是不是少数核心被打满软中断或者单线程瓶颈经常是这种形态第三条专门捞 D 状态进程和它们卡在哪个内核函数上。这三条跑完该往哪个方向深入基本就有数了。别小看这个习惯它能避免在错误的维度上花几小时。2.2 工具矩阵perf、ftrace、eBPF 各自的位置Linux 上做这两类分析主力的三个工具各有所长不是替代关系。perf是 On-CPU 分析的标准答案。它基于硬件 PMU 或者软件时钟做采样开销可控能同时拿到用户栈和内核栈输出是perf.data可以用perf report交互式看也能折叠成火焰图。它的弱点是做 Off-CPU 不擅长perf sched能提供调度延迟数据但缺少完整的调用栈关联很难直接定位到代码行。eBPF通过 bcc 或 bpftrace是目前 Off-CPU 分析最顺手的方案。它可以在sched_switch这类调度点上挂程序精确记录每个进程从切出到切回的时间差并且同时抓内核栈和用户栈。bcc 的offcputime、offwaketime、runqlat、runqlen基本覆盖了 Off-CPU 的主流需求bpftrace 版本的offcputime.bt也能达到差不多的效果。前提是内核要够新4.x 以上一般没问题、有 BTF 信息、有足够的权限。ftrace是兜底方案。老内核、没有 eBPF 支持的环境、或者权限受限只能读 tracefs 的时候ftrace 是唯一的选择。它的原始输出是一堆文本事件需要用脚本加工但胜在几乎所有 Linux 内核都自带。trace-cmd这个前端工具能大幅降低使用难度perf sched本质上也是走的这条路。分析目标首选工具备选输出形态On-CPU 热点perf recordbpftrace profile.bt火焰图、perf reportOff-CPU 阻塞时长bcc offcputimebpftrace offcputime.btOff-CPU 火焰图唤醒链路bcc offwaketimeftrace sched_wakeup双向火焰图调度排队延迟bcc runqlatperf sched latency直方图调度事件明细perf sched timehisttrace-cmd report表格阻塞点粗筛ps wchan / /proc/PID/stack/proc/PID/schedstat文本2.3 采样参数怎么定频率、时长与开销估算参数不能拍脑袋得算一遍。以 perf 为例采样频率-F 99表示每秒钟对每个目标采样 99 次。为什么是 99 而不是 100因为 100Hz 容易和内核里的定时器周期形成锁步lockstep导致每次都在同一个代码位置被打断采样结果出现系统性偏差99 这种质数频率能有效错开。这是 Brendan Gregg 早年就总结出来的经验跟着用就行。开销估算分两块。以一台 64 核机器、-F 99 -a -g --call-graph dwarf为例采样率是 99 × 64 ≈ 6300 次/秒。如果启用 dwarf 栈展开每次采样要拷贝最多 8KB 的用户栈到 perf 的缓冲区内存带宽消耗大约是 6300 × 8KB ≈ 50MB/s这个量级对现代服务器毫无压力。CPU 开销主要来自展开过程本身一次 dwarf 展开大概几微秒到几十微秒按 20 微秒算6300 × 20µs ≈ 0.13 秒/秒摊到 64 个核上相当于单核 13% 左右。这个开销在生产环境的高峰期做 30 秒采集是可以接受的但不要一采就是半小时。推论出几个实用规则时长上99Hz 采 30 秒能得到约 3000 个样本对单核定位够用了如果要看低频函数占比不到 1% 的要么延长到 5 分钟要么提高频率到 999Hz但后者开销同步涨十倍。范围上能用-p PID限定进程就别用-a全系统能用--加时间窗口就别手动 Ctrl-C。栈深度上--call-graph dwarf,8192里的 8192 是拷贝字节数递归深的应用要调大但要记得每翻倍开销也翻倍。Off-CPU 这边的考量不太一样它记录的是事件而不是周期采样开销取决于上下文切换的频率一台每秒切换几十万次的机器上全系统采集会有明显压力所以优先用-p PID缩小范围。3. On-CPU 实操从采集命令到火焰图读图3.1 前置准备符号、权限与内核参数采集之前有三件事必须确认否则拿到的是废数据。第一是符号解析。用户态函数的符号来自二进制的符号表如果程序编译时加了-s或者做过 strip火焰图里只剩一串地址看了等于没看。生产环境的二进制一般会剥离符号这时候要准备带符号的版本或者独立的 debuginfo 包用perf buildid-cache -a /path/to/debuginfo加进去。内核函数的符号受kptr_restrict影响想看内核符号需要echo 0 /proc/sys/kernel/kptr_restrict。第二是权限。/proc/sys/kernel/perf_event_paranoid这个参数决定了普通用户能做什么值是 2多数发行版的默认时只能测自己进程的用户态改成 1 可以测内核态改成 -1 才完全放开。生产环境改这个参数要谨慎并且记得改回去更稳妥的办法是给采集账号单独授权或者用 root 在受控窗口内操作。第三是栈展开方式。-g默认走 frame pointer开销小但依赖编译选项而主流发行版的软件包普遍开了-fomit-frame-pointer栈会断所以实际使用建议显式写--call-graph dwarf。# 确认内核是否支持 PMU虚拟机里经常没有 perf list | grep -m1 cycles # 没有 PMU 时改用软件事件 perf record -e cpu-clock -F 99 -g -p 12345 -- sleep 30注意云主机、容器、部分虚拟化平台会屏蔽 PMU表现为perf record报 No supported events 或者采样数为 0。这时候换成-e cpu-clock或-e task-clock精度略降但能用。3.2 采集命令逐参数拆解一条典型的 On-CPU 采集命令长这样perf record -F 99 -g --call-graph dwarf,8192 -p 12345 -o /tmp/on-cpu.data -- sleep 60逐项解释。-F 99是采样频率理由前面说过。-g开启调用栈记录。--call-graph dwarf,8192指定用 dwarf 展开并且最多拷贝 8192 字节的栈Java、Go、深层递归的服务建议给到 16384。-p 12345只盯这一个进程比-a全系统干净得多也更容易在火焰图上读出来。-o指定输出文件放在/tmp是为了避免写到慢速磁盘影响结果。-- sleep 60是最优雅的限时方式比在另一个终端kill -INT靠谱perf收到子进程退出会自动收尾并落盘。如果确实需要全系统视角比如想搞清楚是哪个进程在偷 CPU用-a替换-pperf record -F 99 -g --call-graph dwarf -a -o /tmp/sys.data -- sleep 30采完之后先别急着出图用perf report快速扫一眼看样本总数和排名前列的符号是否符合预期perf report -i /tmp/on-cpu.data --stdio --no-children -g none | head -40如果发现样本数只有几百、或者前几名全是[unknown]说明参数或者符号有问题回去查而不是硬着头皮出图。顺带补一个很多人不知道的用法perf stat不做采样只做计数适合对比优化前后。perf stat -e cycles,instructions,cache-misses,context-switches,cpu-migrations -p 12345 -- sleep 10context-switches这个计数在这里格外有用它是 Off-CPU 的间接信号——切换次数高得离谱说明进程在频繁睡眠和唤醒该转头去看 Off-CPU 了。3.3 折叠与出图三步拿到火焰图火焰图的生成链是固定的三步导出文本、折叠栈、渲染 SVG。# 一次性准备工具 git clone --depth 1 https://github.com/brendangregg/FlameGraph.git /opt/FlameGraph export PATH$PATH:/opt/FlameGraph # 三步出图 perf script -i /tmp/on-cpu.data /tmp/on-cpu.perf stackcollapse-perf.pl /tmp/on-cpu.perf /tmp/on-cpu.folded flamegraph.pl --titleOn-CPU Flame Graph --width1600 /tmp/on-cpu.folded /tmp/on-cpu.svgperf script把二进制数据转成每行一个样本的可读文本量大的时候这一步会慢几十万样本大概要跑十几秒。stackcollapse-perf.pl把相同调用栈的样本合并计数输出格式是函数A;函数B;函数C 123这样的分号路径加计数。flamegraph.pl负责渲染--width调宽度是为了让长函数名不至于挤成一团。如果采的是 Java 程序额外加stackcollapse-perf.pl --all或者配合perf-map-agent生成/tmp/perf-PID.map否则 JIT 编译出来的方法名全是地址。火焰图的读法只有三条规则记住就不会读错。看宽度不看高度横向宽度代表样本占比也就是 CPU 时间占比纵向深度只是调用层级跟耗时无关。自上而下找瓶颈从一个宽条往下走看是哪条子路径贡献了大部分宽度。平顶山要警惕如果某个函数呈现宽而平的顶说明采样时大量时间停在这一层可能是死循环、可能是被内联展开的函数合并了也可能是符号缺失导致的假象需要结合perf annotate看具体指令。我见过一次典型的假象整张图顶部一片巨大的[unknown]查了半天是容器里的二进制做了 strip补上 debuginfo 后真凶是某个正则表达式编译。3.4 读图与典型案例用户态热点和内核态热点用户态热点和内核对热点的应对思路完全不同看图的第一个动作就是判断宽条落在哪一侧。火焰图左侧一大块是内核栈右侧是用户栈中间是分界。内核态的宽条集中在几个地方copy_user_enhanced_fast_string或memcpy这类内存拷贝通常意味着大量数据在用户态和内核态之间来回搬sys_read、sys_write下面挂着文件系统调用链说明在同步 IO 上耗 CPUtcp_sendmsg一类的网络栈函数配合__alloc_skb说明小包太多网络栈开销吃掉了 CPU。用户态热点更好办直接对应到代码。有个我印象很深的案例一个日志服务的火焰图上std::string::_M_mutate和operator new占了将近 40% 的宽度往下追是高频率地拼接字符串并且每条日志都新建对象。改成栈上缓冲加writev批量落盘CPU 使用率从 65% 掉到 28%P99 也跟着降了。这类优化收益大、风险低是 On-CPU 分析最舒服的场景。还有一种情况要特别提醒火焰图上看到kswapd、kcompactd这些内核线程占用不低别去优化它们本身那是内存压力的表现应该去看内存分配和页回收perf stat -e page-faults,minor-faults,major-faults能给出线索。同理softirq上来的网络收包开销真正该动的是收包方式和中断亲和性不是那个函数。看图的功夫有一半在于把现象映射回根因这需要一点系统知识的积累多看上几十张图就有感觉了。4. Off-CPU 实操把等待这件事量化4.1 offcputime 与 runqlat 的分工Off-CPU 工具里offcputime和runqlat解决的是两个不同的问题很多人会混着用。offcputime回答的是进程切出去之后睡了多久、睡在哪个调用栈上它统计的是阻塞时间也就是线程从主动让出 CPU 到被唤醒之间的时长输出带完整调用栈能定位到代码。runqlat回答的是进程已经就绪但等了多久才被调度上它统计的是排队时间输出是延迟直方图只能告诉你排队的严重程度不能告诉你为什么排。这两个视角是互补的。一种常见情况是 runqlat 的 P99 很高但 offcputime 的时间总和不大说明 CPU 争抢严重属于 runnable 型 Off-CPU处理方向是扩核、降优先级争抢、检查 cgroup 限流。反过来如果 offcputime 显示某线程 80% 的时间都在睡而 runqlat 很平那就是纯粹的阻塞问题方向是 IO 和锁。我在排查一个消息消费延迟问题时就吃过这个亏一开始盯着 runqlat 看觉得延迟不高没问题后来跑 offcputime 才发现消费线程大量时间卡在futex_wait上是一个共享计数器的锁争抢跟 CPU 一点关系都没有。runqlat的判读需要一些阈值经验这些数字不是标准是我自己积累的参考值P99 在 1ms 以内算健康1ms 到 10ms 之间要看业务对抖动的敏感度超过 10ms 基本可以断定 CPU 资源供给不足或者被限流尤其是容器环境要第一时间去看 cgroup 的 throttling 计数。还有一个进阶工具runqlen看的是队列长度而不是等待时长适合长期监控可以作为常态化指标采起来。4.2 采集与出图Off-CPU 火焰图bcc 版本的offcputime是上手最快的。Ubuntu 上装bpfcc-tools之后命令名一般带-bpfcc后缀其他发行版可能是裸名先which确认一下。# 采集指定进程 30 秒的阻塞栈-d 输出分隔符格式-f 输出折叠格式 offcputime-bpfcc -df -p 12345 30 /tmp/offcpu.folded # 出图注意用 --colorio 区分色系避免和 On-CPU 图混淆 flamegraph.pl --colorio --titleOff-CPU Time Flame Graph \ --countnameus /tmp/offcpu.folded /tmp/offcpu.svg几个参数值得单独说。-p 12345限定进程如果不加就是全系统输出会很大-f输出折叠格式直接喂给flamegraph.pl省掉一步转换-u只看用户态栈、-k只看内核态栈缩小数据量时有用-m 1000可以过滤掉小于 1 毫秒的等待压制噪声。采全系统时我一般会加-m 500否则那些频繁进出、每次只睡几十微秒的线程会把图刷得没法看。--countnameus这个细节值得说一下。offcputime的默认单位是微秒火焰图上每条栈的宽度代表的是累计微秒数所以图注必须标明单位否则看图和沟通时容易把宽度 5000误解成其他含义。Off-CPU 火焰图的读法和 On-CPU 一样看宽度但有一个重要差异底部那一层是进程名不是函数因为 Off-CPU 是从进程维度切进去的。所以如果你采的是全系统第一件事是从底部找出宽度最大的那个进程再往上看它的调用栈。没有 eBPF 环境时bpftrace 也能干这件事把下面这段存成offcpu.bt然后bpftrace offcpu.bt即可bpftrace -e tracepoint:sched:sched_switch /args-prev_state ! 0/ { start[args-prev_pid] nsecs; } tracepoint:sched:sched_switch { if (start[args-next_pid] ! 0) { us[kstack, ustack] sum((nsecs - start[args-next_pid]) / 1000); delete(start[args-next_pid]); } } interval:s:30 { exit(); } 这段逻辑是第一个探针在进程切出并且prev_state ! 0即不是可运行状态而是真的睡了时记下时间戳第二个探针在它被切回来的时候取出时间戳算差值按内核栈加用户栈聚合。prev_state ! 0这个判断是区分阻塞和抢占的关键去掉它runnable 等待也会被算进来得到的是广义 Off-CPU 时间——有些场景下这正是你想要的比如想量化 CPU 争抢那就故意去掉这个过滤。4.3 等待类型分诊从 wchan 和调用栈判断堵在哪拿到 Off-CPU 火焰图之后关键动作是分诊也就是判断这些等待属于哪一类因为不同类别对应完全不同的处理路径。我这里整理了一张对照表基本上覆盖了我遇到过的八九成情况。线程状态典型内核栈/wchan等待类型处理方向D__blkdev_direct_IO、rwsem_down_read_slowpath同步磁盘 IO、文件系统锁iostat 看 await考虑异步化或换存储Dnfs_*、cifs_*网络文件系统卡顿检查挂载参数、超时设置尽量本地化Sfutex_wait_queue_me用户态锁争抢找锁持有者缩小临界区或换无锁结构Ssk_wait_data、tcp_recvmsg网络读等待ss -ti 看重传和窗口检查对端Sdo_nanosleep、hrtimer_nanosleep业务主动 sleep、重试退避检查重试策略是否过于保守Sep_poll、poll_schedule_timeout事件驱动空闲等待正常现象但占比过高说明负载不足R无固定 wchan不在睡眠调度排队runqlat 量化查 CPU 饱和与 cgroup 限流一个容易被忽视的维度是状态码和内核栈的交叉验证。有些等待从内核栈上看不出来但从状态码能推断。比如大量 D 状态进程但内核栈指向一个看起来无害的函数很可能是驱动或者存储层的超时重试这时候去看 dmesg 有没有超时告警比盯着火焰图更有效。反过来S 状态的进程如果内核栈显示在futex上而用户栈指向一个很短的函数那基本可以确定是锁竞争接下来要做的是找出谁持有锁bpftrace挂futex相关探针或者直接看代码里的临界区范围。还有一类特殊情况是容器环境下的 CPU 限流它在 Off-CPU 火焰图上表现得很隐蔽——等待栈可能只是普通的调度相关函数看不出异常。这时候必须去看 cgroup 的统计cat /sys/fs/cgroup/cpu.stat # nr_periods / nr_throttled / throttled_usecthrottled_usec不为零就说明进程因为超出 quota 被强制掐掉了这就是纯粹的 Off-CPU 时间而且火焰图上看不出明显原因。这个坑我踩过排查了两天才想到去看 cpu.stat本质原因是一个批处理任务和在线服务共享了 cgroup quota。4.4 没有 eBPF 的兜底ftrace 与 perf sched有些环境上不了 eBPF内核太老、容器权限不够、或者安全策略禁止加载程序。这时候perf sched是最省事的选择它对权限要求低几乎所有能跑 perf 的地方都能用。# 记录调度事件范围限定到目标进程 perf sched record -p 12345 -- sleep 20 # 输出每个任务的调度延迟汇总 perf sched latency -i perf.data # 按时间轴看每次切换的等待时长 perf sched timehist -i perf.data | head -50perf sched latency会给出每个任务的wait time avg、wait time max、sch delay avg等指标其中 wait time 就是任务就绪但没上 CPU 的排队时间sch delay 是实际运行时间偏离理想值的程度。它缺少调用栈关联所以只能定位到线程不能直接定位到代码但配合代码走查通常够用了。timehist的输出是一行一次的调度事件适合看时间轴上的抖动模式比如每隔 10 秒出现一次长等待那就去找定时任务。ftrace 的路子更底层一些用trace-cmd封装之后也不算难# 同时抓切换和唤醒事件 trace-cmd record -e sched_switch -e sched_wakeup -P 12345 sleep 20 trace-cmd report | head -30原始输出是每个事件的明细行包含prev_comm、prev_state、next_comm等字段需要自己写脚本把切出和切回配对算时间差。我一般只在 eBPF 完全不可用时才走这条路因为写解析脚本的时间成本不低。另外还有一个零成本的方法值得常备/proc/PID/schedstat和/proc/PID/status。cat /proc/12345/schedstat # 三个数字在 CPU 上运行的时间(ns)、在运行队列上等待的时间(ns)、运行时间片次数 grep -E voluntary|nonvoluntary /proc/12345/statusschedstat的第二个数字就是该任务累计的就绪等待时间用它除以总运行时间能得到一个粗略的 Off-CPU 占比判断这个进程是不是大部分时间都在等。voluntary_ctxt_switches和nonvoluntary_ctxt_switches的比值也很有信息量前者远大于后者说明是主动阻塞后者大说明是被抢占。这两个文件的特点是开销几乎为零可以定时采样做长期监控比每次上手跑追踪工具高效得多。5. 常见问题与排查技巧实录5.1 符号与堆栈类问题现象一火焰图上大片[unknown]。排查顺序是先确认二进制有没有符号file和nm -C binary | head看符号表是否存在再确认 debuginfo 有没有加载perf buildid-cache -l列出已加载的最后确认是不是 JIT 语言Java、Node、部分 Go 场景需要额外的 perf map 文件。Java 服务最省事的做法是加-XX:PreserveFramePointer启动参数再配合perf-map-agent生成映射表否则栈里只有[unknown]加一堆地址。现象二栈是断的只有一两层。绝大多数是 frame pointer 被省略导致的改用--call-graph dwarf基本能解决。如果换了还是断检查--call-graph dwarf,8192的字节数够不够递归深的程序要加到 16384 甚至 32768。还有一种情况是内核栈被截断需要调/proc/sys/kernel/perf_event_max_stack默认值偏小。现象三内核符号显示成地址。十有八九是kptr_restrict没放开或者当前用户的 capability 不够。临时放开是echo 0 /proc/sys/kernel/kptr_restrict正式环境建议通过能力授权解决而不是长期把限制关掉。提示符号问题占了 On-CPU 分析失败原因的一大半。养成习惯出图之后先看底部有没有完整的用户栈和内核栈栈不完整就直接修别硬读。5.2 采集本身把系统搞挂了怎么办这个问题真实存在尤其是全系统高频采样叠加深度栈展开。我见过一次事故有人在 128 核机器上用-F 999 -a -g --call-graph dwarf采了十分钟结果显示开销把核心占满业务接口大面积超时。教训有两条一是先算开销再上二是先小范围试采再扩大。具体的规避手段有四个。第一用-p精确限定目标进程别动不动就-a。第二频率从 99 起步确认没问题再考虑提高。第三用-- sleep N严格限时避免忘了停。第四先在预发环境或者从库上验证一遍命令再上生产。对于 eBPF 工具offcputime的开销主要跟上下文切换次数相关一台每秒切换 50 万次的机器上全系统采集就是自找麻烦加-p或者用-m过滤短等待都能显著降低负载。还有一个容易忽略的点是输出文件的写入位置。perf.data可能很大几百 MB 起步如果写到业务所在的慢速盘上IO 压力会传导到业务。固定写到/dev/shm或者本地 SSD 的临时目录采完立刻拷走。5.3 容器、非 root 与老内核的坑容器里跑 eBPF 是最容易出问题的一环。需要确认几件事容器有没有CAP_SYS_ADMIN和CAP_BPF或者--privileged/sys/kernel/debug有没有挂进去/sys/kernel/btf/vmlinux存不存在btf 缺失时 bcc 会尝试用 kernel headers 编译失败就报错。如果容器和宿主机共享 PID namespace-p用的就是宿主机的 PID注意别搞混。权限实在拿不到退一步用宿主机上的特权容器做旁路采集或者改用perf sched这类低权限要求的工具。非 root 场景下perf_event_paranoid决定了能做什么。值是 2 时只能测自己进程的用户态热点这个其实已经能解决不少算法优化问题了想看内核态或者别的进程就得提权。我一般的建议是把采集脚本设计成两档低权限档只做用户态采样高权限档做全栈用一个开关控制这样开发同学在本地也能自助排查不用每次都找运维开权限。老内核的问题主要在 eBPF 支持不完整4.9 以下很多工具跑不起来。这时候的替代路径是perf sched加/proc/PID/schedstat加/proc/PID/wchan采样虽然原始但有效。wchan采样特别简单粗暴写个循环每 100ms 读一次目标进程的 wchan统计出现频率最高的几个值就能大致知道它在等什么几十行脚本搞定。5.4 问题速查表把上面这些整理成一张表出问题的时候按顺序排查。现象最可能的原因快速验证处理火焰图全是[unknown]符号缺失或 JIT 未生成映射nm -C binary看符号表补 debuginfo 或开 PreserveFramePointer栈只有一两层frame pointer 被省略换 dwarf 重采加--call-graph dwarf,16384采样数为 0无 PMU 或权限不足perf list、看 paranoid 值换-e cpu-clock或提权CPU 低但延迟高阻塞型 Off-CPUoffcputime -df -p PID 30按等待类型分诊处理延迟抖动无规律调度排队runqlat看 P99查 CPU 饱和与 cgroup 限流D 状态进程多磁盘或网络文件系统ps -eo stat,wchan,comm看 iostat 与 dmesg容器里延迟周期性尖刺cgroup CPU 限流cat /sys/fs/cgroup/cpu.stat调整 quota 或拆分组采集期间业务告警采样开销过大对比采集前后的 CPU降频率、限 PID、缩短时长6. 组合定位流程与我的几点心得6.1 一个可复用的排查顺序把 On-CPU 和 Off-CPU 拼起来用我固定走这么一条流程从粗到细每一步都能独立收敛问题。第一步建立基线。vmstat 1、mpstat -P ALL 1、ps捞 D 状态三条命令十秒钟跑完判断大方向是 CPU 型还是等待型。这一步的产出是一个假设比如怀疑是同步 IO 等待。第二步验证假设。如果是 CPU 型直接上perf record出 On-CPU 火焰图如果是等待型上offcputime出 Off-CPU 火焰图。第三步交叉验证。这一步很多人会跳过但很有价值用perf stat -e context-switches,cpu-migrations,page-faults看计数用/proc/PID/schedstat算 Off-CPU 占比如果 On-CPU 火焰图显示热点占比和perf stat的 CPU 时间对得上结论才可信。第四步量化收益。优化前把关键指标记下来火焰图占比、P99 延迟、CPU 使用率、每秒上下文切换数。改完之后用完全相同的命令重采一遍做对比别凭感觉说快了。我习惯把优化前后的两张火焰图并排放在一起看宽条是不是缩短了、移到了别处、还是消失变成了一块新的瓶颈这个对比过程本身经常能发现下一层的优化点。第五步沉淀成监控。一次性的排查价值有限把稳定可用的指标变成常态化采集才有复利。我一般会挑三个指标做长期监控runqlat的 P99反映调度健康度、目标进程/proc/PID/schedstat的等待占比反映 Off-CPU 趋势、cgroup 的throttled_usec反映限流。这三个指标的开销都极低可以每分钟采一次画成趋势图比事后救火强太多。6.2 几个我踩过的坑和私藏技巧先说几个真金白银换来的教训。别在业务高峰期第一次跑采集命令我见过太多人拿着网上抄来的命令直接在高峰执行然后引发二次故障。别只看平均值性能问题几乎都藏在长尾里Off-CPU 的等待时间尤其如此一个平均 2ms、P99 800ms 的等待平均值看上去人畜无害但用户体验就是被那 1% 毁掉的所以offcputime出来的图要配合runqlat的直方图一起看。别把工具输出当结论火焰图告诉你哪宽不告诉你为什么宽从现象到根因那一步得靠代码走查和系统知识这一步没有捷径。再分享几个我觉得很好用的技巧。第一个是用-m做噪声过滤。全系统offcputime采出来经常是一团乱麻加个-m 500只看 500 微秒以上的等待图立刻清爽宽条也会集中到真正的瓶颈上。第二个是用offwaketime看唤醒链路它能同时显示谁在等和谁唤醒的很多生产者消费者模型的延迟问题只有看双向栈才能定位到是消费端慢还是生产端没及时喂数据。第三个技巧是把wchan做成常驻的低成本采样。用一个一行的定时任务每隔 60 秒记录一次关键进程的 wchan跑上一周你就能得到一份这个服务平时都在等什么的画像。等哪天出问题直接和这份基线对比异常点自动浮现。这个方法的性价比高到离谱几乎零成本却能在关键时刻省下几小时。第四个是注意时间单位和时钟源。offcputime默认微秒runqlat默认也是微秒但可以-m切毫秒perf sched的输出单位不统一看的时候一定先确认表头。我就因为把某个工具的输出当毫秒读把一个 300 微秒的问题当成了 300 毫秒白紧张了半天。最后提一个容易被忽略的观测角度线程级而不是进程级。现代服务普遍是多线程模型进程整体看 CPU 使用率很健康但某个线程可能长期被饿着。perf record -t TID、offcputime -t TID都能精确到线程配合top -H -p PID看线程级的 CPU 分布很多进程级指标正常但业务就是慢的谜题在这里解开。我排查过一个 RPC 框架的延迟毛刺最后发现是某个负责心跳的线程被业务线程长期抢占而进程级 CPU 一直很平稳全程只有线程级视角才能看到。这套东西说到底就是一句话看性能不能只盯着 CPU 在忙什么更要盯着它在等什么。Tools 会用只是入门知道什么时候该用哪个、结果怎么解读、下一步往哪走才是真正拉开差距的地方。