火焰图(flame graph)是一种调用栈采样的可视化方法,它展示的是程序把 CPU 时间花在了哪里。2011 年由 Brendan Gregg 发明,它把采样到的函数调用从下往上堆叠,让你在几秒钟内就能读出一个程序最热的代码路径。所有火焰图都遵循四条规则:宽度是该函数在采样中的占比,不是持续时间;y 轴是调用深度;顶边才是真正 on-CPU 的位置(根在上的渲染方向则相反,看底边);x 轴按字母序排列,不是时间轴。如果只能记住一件事,记住这四条。

你需要这张图的时刻,通常是这样的:告警响了,top 显示某个进程 CPU 占用 100%,然后……就没有然后了。top 能告诉你是哪个进程在烧 CPU,却回答不了真正的问题——是哪个函数、哪个文件、哪一行代码。火焰图补上的正是这一步:它把排查从"进程级"推进到"代码行级"。本文会先给出四条读图规则和常见形态,再用三个真实生产案例(PHP、Go、Erlang)带你从一张真实的火焰图一路走到罪魁代码行。

如何读懂火焰图

大多数指南告诉你火焰图"展示 CPU 使用情况"。这句话正确但无用。以下是你真正需要的读图方法。

四条规则

1. 宽度 = 采样占比,不是持续时间。 一个方框占据图的 40%,意味着该函数出现在 40% 的调用栈采样中。它不意味着该函数运行了 40% 的时钟时间。对 CPU 性能分析来说,两者的区别通常不大;但当有人问"为什么这个框在周二的图上更宽了"时,答案是采样次数,不是挂钟时间。

2. y 轴是调用深度。 底部是入口(mainstart_thread、运行时的引导函数),顶部是 CPU 实际在执行的叶函数。中间每个方框都是调用链上的调用者。

3. 顶边是 on-CPU 的位置。 想知道什么在烧你的 CPU?看图的顶边。顶边最宽的方框就是正在 CPU 上执行的函数。它们下面的所有东西都是"怎么走到这里的"。注意渲染方向:经典火焰图根在底、火苗向上;不少现代工具(包括本文案例所用的 OpenResty XRay)渲染为根在顶、叶函数向下——这时本条规则镜像到底边。两种方向的数据完全一样,详见后文变体一节。

4. x 轴是字母序,不是时间。 这是最常见的误读。火焰图不是时间线。水平排列是按字母序(或地址序),目的是让相同的栈帧合并成更宽的方框。两个并排的方框不代表"A 先发生,然后 B 发生"。举个例子:decode 出现在 encode 左边,并不意味着解码先于编码发生——只是字母 d 排在 e 前面而已。

形态识别

内化四条规则之后,读火焰图就变成了看形状:

  • Plateau(宽而平的顶部):一个函数独占 CPU。这是最简单的模式——看这个 plateau 函数做了什么,你就找到了瓶颈。下面 PHP 的例子中,preg_match 就是这样的瓶颈——配套报告显示以它为叶函数的路径占 56.2% 的 CPU。

  • Tower(高而窄的尖刺):一个很深的调用栈,但触发频率低。除非多个 tower 汇聚成一个宽底座,否则它们通常不是你的问题。一张全是 plateau 的火焰图里出现一个孤立的 tower,那只是噪声。

  • Hair(顶边上大量细小的毛刺):每个只出现在几次采样中的短暂调用——内核中断、定时器 tick、信号处理函数。Hair 是正常的。如果 hair 很宽,那你遇到的是中断风暴(interrupt storm),不是应用 bug。

  • 底座平坦、顶部参差:你的框架或运行时(底座部分)是健康的,差异在应用层代码(上方的尖刺)。从底座分叉的地方开始往上读。下面 PHP 的例子就是典型——框架层每层等宽,热点全在更深的业务代码里。

这些形态不必死记,下面「三个真实案例」一节里都能对上号:PHP 是深底座、Erlang 是叶端分叉。先看真图,形态自然就内化了。

Self 与 total

火焰图里每个方框有两个量:total(总计)是它的完整宽度——包含它调用的所有函数;self(自身)是该函数位于栈顶部的采样占比(即没有更上层的被调用者被采样到)。分析器里也常把这两个量写作 “self time” 和 “total time”,但在采样式火焰图里这个 “time” 并不准确——和规则 1 一样,底层量是采样次数/占比,不是挂钟时间。Go 的 pprof 把它们叫作 flat(self)和 cum(cumulative,即 total)就更贴切:名字里没有 “time”。(Chrome DevTools 确实叫 “Self Time”/“Total Time”,但那是因为它产出的是 flame chart,x 轴真的是时间——参见后文变体一节。)

total 很大但 self 很小的函数是调度器(dispatcher):它自己不烧 CPU,只是把工作分发给下面的子函数。

如果你的 profiler 支持按 self(pprof 里的 flat)排序,用它。Self 最高的函数才是 CPU 真正在跑的——它们是你的优化目标。

三个真实案例:从火焰图到罪魁代码行

前面的规则和形态是词汇表,真实案例才教你做判断。以下三个案例来自通过动态追踪对真实运行中的进程进行剖析的结果——无需修改代码,无需重启。先说清两件事:OpenResty XRay 渲染的火焰图是根在顶、叶函数在底的方向(即后文的 icicle 方向),并用红色高亮标出最热回溯——所以读下面的图要看底部和红色路径;案例中的百分比数字来自与火焰图同一次分析生成的代码路径报告。每个案例遵循同一个三步读图法:你看到什么 → 怎么认出它 → 它意味着什么

案例一:PHP preg_match — 56.2% CPU 花在循环里的正则

PHP 语言级别 CPU 火焰图(根在上),可见部分为 Laravel 框架从 server.php 到中间件管道的引导调用链,最热路径继续向下延伸至业务代码

你看到什么: 一张根在上的 PHP 语言级火焰图。可见部分是 Laravel 框架的引导与中间件链——从 server.phpKernel::handle 到 Pipeline 中间件,一层层几乎等宽地往下传。这是「深底座」形态:框架本身不烧 CPU,只是把请求一路传下去,真正的热点在调用链更深处的业务代码里。

怎么认出它: 底座每层几乎等宽,说明 CPU 没有在框架层被分掉;顺着最宽的一条链往下追即可到达业务函数。配套的代码路径报告直接给出了答案:排名第一的最热 PHP 路径以 preg_match 为叶函数,经由 Laravel 的 callAction 到达 processOrders,占 56.2% 的 CPU 时间——叶函数就是 CPU 烧掉的地方。

第一号最热 PHP 代码路径报告:preg_match 为叶函数,经 processOrders 与 Laravel callAction,占 56.2% CPU 时间

它意味着什么: processOrdersProductServiceProvider.php 第 437 行)的一个循环里在反复匹配同一个正则表达式,每次迭代都支付一次 PCRE 匹配的代价。修复方向也来自源案例:把正则表达式预编译好并复用,让它不必在每次迭代时重新构建。

同一个进程的 C 语言级别代码路径报告印证了这一结论:

C 语言级别最热代码路径报告,第一名为 pcre2_match_8,经 php_pcre_match_impl 与 Zend 执行器调用,占 34.7% CPU

pcre2_match_8 是最热的 C 函数,占 34.7%,经由 php_pcre_match_impl 调用。当两个语言级别指向同一个瓶颈时,你可以确信这个诊断是对的。

完整排查过程:PHP 高 CPU 使用率:定位最热的 PHP 代码路径

案例二:Go regexp.MustCompile — 36.8% CPU 花在正则编译

Go 语言级别 CPU 火焰图(根在上),红色高亮的最热回溯经 prev_processor.go 第 17 行的 CheckMessage 进入 regexp.MustCompile 的编译调用链

你看到什么: 一张根在上的 Go 语言级火焰图,最热回溯被红色高亮标出:HTTP 处理链经 chat.Handle 走到 prev_processor.go 第 17 行的 CheckMessage,再进入 regexp.MustCompile,其下展开一整片红色的 regexp/syntax 编译调用。配套报告显示这条正则编译路径占 36.8% 的 CPU 时间。

怎么认出它: 跟着红色高亮走到叶端,函数名说的是另一个故事:这不是正则匹配(执行)——而是正则编译(构建自动机)。编译比匹配昂贵得多,在热路径里做编译是 Go 代码的常见错误。

它意味着什么: prev_processor.go 第 17 行的 CheckMessage 函数每次调用都在执行 regexp.MustCompile。通行做法是每个正则只编译一次并复用编译结果——例如放在包级变量里初始化。把编译移出热路径,这条占 36.8% CPU 的编译调用链就不复存在。

完整排查过程:Go 高 CPU 占用:正则表达式编译消耗了 36.8% 的 CPU 时间

案例三:Erlang erts_pcre_execlists:filter 循环中的 PCRE 回溯

Erlang 语言级别 CPU 火焰图(根在上),从 cowboy 请求处理链经 check_resp_content 与 lists:filter 的匿名函数到达底部的 erts_pcre_exec,红色高亮 C:match.constprop.1 为最热回溯

你看到什么: 根在上的 Erlang 语言级火焰图:从 cowboy 的请求处理链走到业务函数 check_resp_content,再经 lists:filter 的匿名函数到达底部的 erts_pcre_exec,红色高亮的 C:match.constprop.1 就是最热回溯的终点。C 语言级别的代码路径报告确认同一条链——最热 C 路径是 matcherts_pcre_execre:run

C 语言级别最热代码路径报告,第一名为 match ← erts_pcre_exec ← re_run;Erlang 级第一名路径经 check_resp_content 与 lists:filter 到达 C:match.constprop.1

怎么认出它: 叶级分叉形态。不同于 Go 案例的单一红色主干,这张图在最底部分出几条并列的叶路径(erts_iolist_sizeerts_iolist_to_buferts_pcre_exec——前两个在图中显示为截断后的标签)。规则不变:跟着最宽、被红色高亮的那条走——它通向 erts_pcre_exec,正则调用。

它意味着什么: 列表中的每一个元素都在与一个 PCRE 正则表达式做匹配。PCRE 使用回溯 NFA 引擎,包含 .*.+ 或嵌套选择分支的模式可能导致指数级匹配时间——这种失败模式叫做灾难性回溯。修复方案是优化正则模式以避免回溯,或者换用非回溯的正则引擎。

完整排查过程:Erlang 高 CPU 使用率:通过火焰图追踪 PCRE 正则瓶颈

三个案例的共同规律

三个瓶颈都和正则相关,但结论是通用的:

  1. 先看叶函数一端(本文三例即底边)。有红色高亮时直接跟着高亮走。最宽的叶方框就是你的起点。
  2. 往下读上下文。 下方的方框告诉你为什么这个函数是热的——谁在调用它、从哪里调用、在什么循环里。
  3. 跨语言级别交叉验证。 当应用层火焰图(PHP/Go/Erlang)和 C 层火焰图指向同一个瓶颈时,你得到的是一个确认的诊断,不是假说。

三个案例恰好都是正则瓶颈;想看其他形态的真实图——锁等待、内存分配等——可以从下文技术栈路由表最后一列的各技术栈案例文章,以及后文的 off-CPU 与内存火焰图变体入手。

从 100% CPU 到罪魁代码行

回到开头那个场景:top 停在了进程级。把前面的读图方法串起来,四步就能走完从 100% CPU 到具体代码行的全程:

第 1 步:找到进程。 tophtop 告诉你哪个进程在烧 CPU。记下 PID。

第 2 步:获取火焰图。 用 profiler 从那个 PID 采集调用栈采样。下一节会介绍每种技术栈用什么工具。

第 3 步:读叶函数一端。 找到火焰图叶端最宽的方框——那就是直接烧 CPU 的函数。悬停或点击即可看到它的源文件和行号。注意:要找的是叶端最宽(self 最高)的框,不是整体最宽的——底部那些贯穿全宽的方框往往是框架或调度器,total 很大但自己不烧 CPU(见上文「Self 与 total」一节)。

第 4 步:读代码。 打开文件,跳到那一行。现在你知道了什么在烧 CPU 以及为什么被调用,因为火焰图给了你完整的调用栈。

不管什么语言或运行时,这个流程都一样。变化的只是第 2 步用什么工具。

技术栈路由表

技术栈工具一行命令 / 入口火焰图案例文章
Linux(任意原生二进制)perfperf record -g -p PID; perf script | stackcollapse-perf.pl | flamegraph.pl
Gopprofgo tool pprof -http=:8080 http://host:port/debug/pprof/profileGo 高 CPU
Javaasync-profilerasprof -d 30 -f out.html PIDJava CPU 分析
Node.js0x / --prof0x app.jsNode.js CPU 分析
PHPExcimer(采样)PHP 高 CPU
PerlDevel::NYTProfperl -d:NYTProf script.plPerl 高 CPU
Erlang/BEAMeflameErlang 高 CPU
Rustcargo-flamegraphcargo flamegraph --pid PIDRust/Sled 高 CPU
Nginx/OpenRestyNginx CPU 最热请求Lua CPU 火焰图
C/C++(Envoy、llama.cpp 等)perf同 Linux 行Envoy CPUllama.cpp CPU

上表中的每种工具都需要某种形式的插桩、重编译或重启——下一节细说这个成本,以及如何绕开它。

如何为你的技术栈生成火焰图

如果你从未生成过火焰图,前面的技术栈路由表为主流运行时给出了工具入口。通用步骤是:

  1. 采集调用栈采样——从目标进程采集(工具因技术栈而异)。
  2. 折叠——把采样折叠成文本格式(每行一个唯一栈,带计数)。
  3. 渲染——把折叠后的栈渲染成 SVG 或交互式 HTML。

Brendan Gregg 的 FlameGraph 仓库提供了 stackcollapse-*.plflamegraph.pl 用于第 2、3 步。大多数现代工具(pprof、async-profiler、cargo-flamegraph)一条命令完成全部三步。

所有这些工具的共同代价是访问权限:你需要修改进程(加 flag、用 frame pointer 重编译、重启以启用 debug agent)或拥有 root/CAP_SYS_ADMIN 权限来使用 perf/eBPF。在生产环境中,这个代价是真实存在的——尤其是在事故当中,你最不想做的事就是重启。

OpenResty XRay 消除了这个代价。它通过动态追踪挂接到一个正在运行的进程——不需要改代码、不需要重编译、不需要重启——可以为上面路由表中列出的所有技术栈生成火焰图。本文三个案例中的火焰图就是这样采集的:来自正在运行的进程,零停机。

Icicle 图、flame chart 与其他变体

Icicle Graph(冰柱图)

Icicle graph(也叫倒火焰图)把调用栈翻转:根在顶部,叶函数向下生长。数据完全一样——相同的采样、相同的宽度——只是方向变了。

大多数现代性能分析工具(Grafana Pyroscope、Polar Signals、Datadog 的 continuous profiler)默认使用 icicle 方向。如果你用的工具在顶部显示根节点,你看到的就是 icicle graph——本文三个案例中的 OpenResty XRay 火焰图正是这个方向。读图规则相同:宽度是采样占比,底边变成了 on-CPU 的位置,x 轴仍然不是时间。

Flame chart 与 flame graph 的区别

Flame chart 看起来像火焰图,但 x 轴时间。Flame chart 由浏览器 DevTools(Chrome 的 Performance 面板)和一些 tracing 工具生成。在 flame chart 中,水平位置意味着"这件事先发生,那件事后发生"——左右顺序是有意义的。

最快的区分方法:如果相同的函数名出现在多个互不合并的方框里,那是 flame chart(按时间排列)。如果相同的名字总是合并成一个更宽的方框,那是 flame graph(按字母序合并)。

其他变体

火焰图不限于 CPU:

  • Off-CPU 火焰图展示程序在等什么——锁、I/O、sleep 调用。参见 Off-CPU 分析实战
  • 内存火焰图展示哪些调用路径在分配内存。参见 Django 内存分析
  • 差分火焰图(Differential flame graph) 对比两次采样,展示变化了什么——适合上线前后对比。

每种变体改变的是宽度代表什么(等待时间、分配字节、采样差值),但堆叠结构不变。

AI 能替你读火焰图吗?

能加速,但替代不了基本功。AI 对常见模式(循环里的正则、锁竞争、框架开销)的识别确实快,对第一次读图的工程师是有用的起点。但本文的四条规则足够简单,基本功不需要 AI。AI 真正增值的地方,是把火焰图关联到你的具体代码库和运行时——而这需要的不仅仅是一张 SVG 图片。

OpenResty XRayAI 助手正是这么做的:它不是分析一张静态图片,而是直接基于产生火焰图的动态追踪数据——原始的调用栈采样、进程元数据、运行时上下文。这意味着它可以跨语言级别关联(PHP + C、Go + 运行时),识别你所用框架特有的模式,给出引用你的源文件和行号的建议。

常见问题

火焰图中方框的宽度代表什么?

宽度代表包含该函数的调用栈采样占总采样的比例。一个方框占图宽的 30%,意味着在整个采样期间,该函数出现在 30% 的采样中。它是 CPU 时间份额的近似值,不是挂钟时间的测量。

火焰图中的颜色有含义吗?

在 Brendan Gregg 的原始火焰图中,颜色是随机的暖色调——没有语义含义。一些工具会按模块、语言级别或包名分配颜色(例如红色表示内核、绿色表示应用代码)。查看你的工具是否有图例说明。如果没有图例,颜色只是装饰。OpenResty XRay 的火焰图采用橙色和蓝色两种配色:橙色的是 CPU 火焰图,蓝色的是 off-CPU 火焰图。

怎样在火焰图中找到瓶颈?

看图的顶边(如果是 icicle graph 则看底边)。顶边 self 占比最高的方框就是那个函数——它是直接在执行的,不只是分发工作。那就是你的瓶颈。往下读可以看到到达它的调用链。

我在 top 里看到 100% CPU 但不知道是哪段代码造成的,怎么办?

top 告诉你哪个进程在烧 CPU,但不告诉你是哪个函数或哪一行。你需要一张火焰图。把 profiler 挂接到那个进程的 PID 上(参见上面的技术栈路由表),采集 10–30 秒的调用栈采样,然后渲染火焰图。叶函数一端(经典方向在顶边,根在上的方向在底边)最宽的 plateau 就是罪魁函数。悬停或点击它即可看到源文件和行号。如果你是在追查高 P99 延迟、且 CPU 占用偏高,火焰图正是这一步——完整的诊断路径与根因谱系见 P99 延迟详解

关于 OpenResty XRay

OpenResty XRay 是一个动态追踪产品,它可以自动分析运行中的应用,以解决性能问题、行为问题和安全漏洞,并提供可行的建议。在底层实现上,OpenResty XRay 由我们的 Y 语言驱动,可以在不同环境下支持多种不同的运行时,如 Stap+、eBPF+、GDB 和 ODB。

关于作者

章亦春是开源 OpenResty® 项目创始人兼 OpenResty Inc. 公司 CEO 和创始人。

章亦春(Github ID: agentzh),生于中国江苏,现定居美国湾区。他是中国早期开源技术和文化的倡导者和领军人物,曾供职于多家国际知名的高科技企业,如 Cloudflare、雅虎、阿里巴巴, 是 “边缘计算”、“动态追踪 “和 “机器编程 “的先驱,拥有超过 22 年的编程及 16 年的开源经验。作为拥有超过 4000 万全球域名用户的开源项目的领导者。他基于其 OpenResty® 开源项目打造的高科技企业 OpenResty Inc. 位于美国硅谷中心。其主打的两个产品 OpenResty XRay(利用动态追踪技术的非侵入式的故障剖析和排除工具)和 OpenResty Edge(最适合微服务和分布式流量的全能型网关软件),广受全球众多上市及大型企业青睐。在 OpenResty 以外,章亦春为多个开源项目贡献了累计超过百万行代码,其中包括,Linux 内核、Nginx、LuaJITGDBSystemTapLLVM、Perl 等,并编写过 60 多个开源软件库。

关注我们

如果您喜欢本文,欢迎关注我们 OpenResty Inc. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:

我们的微信公众号

翻译

我们提供了英文版原文和中译版(本文)。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!