☰
用火焰图定位 Spark Executor 的 CPU 热点与性能瓶颈
2026/10/15 5:10:07 网站建设 项目流程

如果你的一个 Spark 作业,CPU 使用率已经冲得很高,几十个 executor 个个都在忙,可作业就是不出数,日志里也找不到明显报错,那么你现在最需要的可能是一张火焰图。火焰图可以把 executor JVM 里每个线程在 CPU 上的调用栈采样汇总成一张直观的图,让你清楚看到时间到底是花在序列化、shuffle、UDF、GC,还是某个你完全没料到的底层库调用上。这篇文章不讲抽象理论,直接讲怎么对一个 Spark executor 进程做 CPU profile,包括工具选型、完整操作命令、读图方法,以及我在实际环境里踩过的一些坑。适合正在排查 Spark CPU 瓶颈的开发者,也适合想从零上手火焰图的数据平台工程师。

1. 为什么监控里 CPU 都快跑满了,你就是看不到热点

1.1 监控和 Spark UI 只能看到“宏观结果”

集群监控页面能告诉你每个 executor 的平均 CPU 使用率,Spark Web UI 能告诉你每个 task 的运行时长短、GC 时间、读写字节数,但这些东西组合在一起,很难还原“CPU 时间到底消耗在哪一行代码”。

举个例子:某个 stage 的 200 个 task 平均耗时 5 分钟,监控显示 CPU 高,你看到了,可 5 分钟里 CPU 是被 Kryo 序列化吃掉的,还是被哈希聚合的排序逻辑吃掉的,或者被一个放在 UDF 里的正则表达式来回回溯吃掉的?这些细节在现有监控体系里几乎不体现。Spark UI 的任务页有spark.taskMetrics,里面记录了反序列化时间、shuffle 读时间等指标,但粒度仍然到不了“某个 Java 方法占了 30% CPU”这个级别。

1.2 分布式环境让常规 profiling 变得别扭

单机程序卡了,可以直接用 IDE 的 Profiler attach 上去,或者用jstack、perf一把梭。但 Spark executor 是一个常驻 JVM,常常跑在 YARN 容器或 Kubernetes Pod 里,你要在容器外盯一个容器内进程本身就麻烦,更别说这个 JVM 里同时有多个线程组在并发跑多个 task。

还有一层麻烦:executor 线程池里的任务线程,一会儿跑你的业务代码,一会儿跑 Spark 框架的排序和聚合逻辑,不是“一个线程对应一个任务”这么简单。如果你只是随意抓几次线程转储,很难形成统计意义,因为线程栈是瞬态快照,抓到的那一瞬间可能恰好落在某个 IO 等待上,回头一看全是epollWait,CPU 热点反而被淹没了。

1.3 火焰图的思路:让调用栈的频率说话

火焰图的核心思路很朴素:以固定的频率去采样当前线程正在执行的调用栈,采几千组后做聚合统计,哪个调用栈出现的样本多,就说明 CPU 时间主要花在那里。

这种“高频采样 + 统计聚合”的思路正好补上了前面的缺口:Spark UI 看不到单方法开销,jstack 单次抓取没有统计意义,而火焰图既有调用栈深度,又有定量占比。最终它长成一张上尖下宽的“火焰”形状,宽的地方就是时间消耗多的地方,你可以顺着宽度一路找下去,通常很快就能定位到具体热点代码。

2. 火焰图怎么画出来的:采样、折叠、读图三件事

2.1 调用栈采样:给线程“拍照片”

做 CPU profiling 时,采样器会每隔一个固定时间间隔(比如 10ms)去记录目标线程当前正在执行的调用栈。这个过程类似给一本高速翻动的书不停地拍照,每张照片记录的是那一瞬间书的页码,拍几百张之后,哪一页出现的频率高,哪一页就很可能被读者停留得久。

采样频率不是越高越好。频率太高,采集器本身会占用很多 CPU,甚至影响目标进程的运行节奏;频率太低,又可能错过短命的热点函数。实际工作中 CPU profile 采样频率常用 99Hz 或 1000Hz,折算下来每次采样开销很小,普通 Java 服务完全扛得住。

2.2 折叠与渲染:从一条条栈到一张图

每次采样得到的调用栈是一串逐层嵌套的函数名,从最底层的线程入口,到中间框架代码,再到最顶层的叶子方法。这些原始样本没法直接看,需要做“折叠”处理,也就是把相同的调用栈聚成一行并计数。

比如你有 5000 个样本,其中 2000 个都是run -> taskRun -> executeTask -> processRow -> extractField -> Regex.find,折叠之后就会得到一行表示“这个栈出现了 2000 次”。然后画图工具按样本占比横向铺设矩形,同一个调用栈跨度就特别宽。FlameGraph 脚本集就是干这件事的:它把折叠后的数据变成 SVG 或 HTML,浏览器打开后可以直接搜索、点击放大。

2.3 读图的第一直觉:宽是热度,高是深度,颜色别较真

火焰图从底部到顶部是调用栈的层次,父调用在下,子调用在上。横向宽度代表该函数在全部样本中出现的次数占比,越宽说明 CPU 时间占比越高。所以读图第一眼就要找“特别宽的横条”,而不是找“最高的尖”。

新手常犯的误区有两个。第一,只盯最顶层的叶子函数,其实如果一条很宽的横条下面压着另一条很宽的横条,往往是父函数总在调用那几个子函数,热点要从整条路径一起看。第二,纠结颜色。火焰图的颜色普遍是按函数名哈希生成的,某些工具会用不同色调区分 Java 方法、C 库、内核函数,但颜色本身不表示“好”或“坏”,不要一看是红色就觉得有问题,关键永远是宽度。

3. 采样开始前,先把工具和权限问题解决掉

3.1 三个常用工具怎么选:async-profiler / perf / jstack

对 Spark executor 这种 JVM 进程做 CPU profile,常见选项有三条路线,我把它们放在一张表里对比。

工具数据来源JVM 符号解析容器权限要求典型产物建议场景
async-profilerJVM 内部事件 / perf 事件自带 JIT 符号处理能力通常只需能 attach,不需要 rootHTML 火焰图、SVG、文本链JVM 进程首选
perf + FlameGraphLinux 内核事件需要额外符号映射通常需要 perf 权限,容器里受限较多SVG 火焰图底层内核栈、非 JVM 场景
jstack 多次 dumpJVM 线程转储天然就是 Java 方法名只需能 attach文本转储快速定性,不适合严格定量

实际选择上,我是默认 async-profiler 的。原因不是 perf 不好,而是给 JVM 进程做采样时,perf 拿到的是机器指令地址,Java 方法名还需要通过额外机制(比如生成符号映射文件)才能翻译出来,一旦 JIT 编译过的代码没有符号,整个火焰图全是裸地址,等于白干。async-profiler 写入了大量针对 HotSpot JVM 的处理逻辑,在采样时就尽量把 Java 方法区分开,省掉很多麻烦。

3.2 找到 executor 进程并确认 attach 条件

Spark 里的 executor 进程,对应类名一般是org.apache.spark.executor.CoarseGrainedExecutorBackend。在节点上执行下面命令就能看到。

jps -l | grep CoarseGrainedExecutorBackend

输出会给出 PID,比如12345 /org/apache/spark/executor/CoarseGrainedExecutorBackend。有时候jps不在 PATH 里,就用ps -ef | grep CoarseGrainedExecutorBackend同样能找到。

拿到 PID 之后,要确认当前用户对目标 JVM 有 attach 权限。async-profiler 的 attach 模式和jstack类似,要求操作者和目标进程是同一个用户,或者你有 sudo 能切到那个用户。如果你是在 YARN 容器里,executor 通常以容器内用户运行,先进容器再采样最稳;如果你人在宿主机,进程属于另一个账号,就得sudo -u 那个用户 ./profiler.sh ...。

3.3 采样时长和频率怎么定

CPU profile 的采样时长没有一个绝对标准。经验值是 30 秒到 2 分钟:太短了错峰严重,热点可能还没形成统计优势;太长了会拖住现场,而且环境负载一直在变,可能把你的结论搅浑。

频率方面,async-profiler 通常提供默认采样间隔,普通分析直接用默认值就行。想让火焰图更细腻,可以调短采样间隔,比如改成 5ms,但代价是 profiling 自身的 CPU 开销上升,在 executor 本身已经很忙的情况下,这种额外开销可能影响数据可信度。建议第一次先按默认参数跑 30 秒,确认能采到栈、能看到可读的方法名,再决定要不要加长或加密。

如果是在 Kubernetes 这种容器环境里,还要确认容器里有没有足够权限。attach 模式一般不需要额外内核权限,但如果容器以只读文件系统运行、没有/tmp权限,attach 时很可能会报错,这时候需要给 executors 挂载可写目录,或者改用 Java agent 方式在启动时加载采样器。

4. 一步步采集 executor CPU 栈并生成火焰图

4.1 主流程:用 async-profiler 一条命令拿到 HTML 火焰图

假设你已经下载好 async-profiler 并解压(它的 release 包里有profiler.sh),流程如下。

# 1. 找到目标 executor 的 PID jps -l | grep CoarseGrainedExecutorBackend # 2. 用 async-profiler 采样 30 秒,输出 HTML 火焰图 ./profiler.sh -d 30 -e cpu -o flamegraph -f /tmp/executor-cpu.html 12345

命令里的参数逐个拆开:-d 30表示采样 30 秒;-e cpu表示采样 CPU cycles 事件;-o flamegraph表示输出火焰图格式;-f /tmp/executor-cpu.html指定产物路径;最后跟 PID。命令结束之后,把/tmp/executor-cpu.html下载到本地,用现代浏览器打开,就能看到可交互的火焰图。

如果容器里对 perf 事件有访问限制,-e cpu可能跑不起来,可以退回用-e itimer。它不依赖内核 perf 事件,精度略粗一点,但同样能反映 CPU 上执行的 Java 调用栈。这个回退在云原生环境里经常能救命。

4.2 多 executor 并行采样:避免只看一个进程

一个 Spark 应用往往有几十个 executor,采一个进程可能恰好碰上一个数据倾斜的极端样本,或者恰好是一个负载不足的进程。比较稳的做法是挑两三个 CPU 使用率最高的 executor,同时做采样。

可以在跳板机上分别开几个会话,也可以写个简单循环串行跑:

for pid in 11111 22222 33333; do ./profiler.sh -d 30 -e cpu -o flamegraph -f /tmp/executor-$pid.html $pid done

最后把几张火焰图放在一起对比。如果每张图的热点都指向同一类函数,问题定位就很有信心;如果只有一张图显示异常热点,那就很可能是数据倾斜或负载不均,需要结合数据分布再看。

4.3 备选路线:perf + FlameGraph 脚本离线生成 SVG

虽然我推荐 async-profiler,但有些场景你能拿到 Linux 宿主机权限,也想看内核调用栈信息,那可以走perf加 FlameGraph 脚本的路线。

# 采样 30 秒,CPU event,记录调用栈 perf record -F 99 -g -p 12345 -- sleep 30 # 输出采样文本 perf script > /tmp/out.perf # 克隆并进入 FlameGraph 脚本集目录 git clone https://github.com/FlameGraph/FlameGraph cd FlameGraph # 折叠:把每条栈变成一行,并统计次数 ./stackcollapse-perf.pl /tmp/out.perf > /tmp/out.folded # 渲染:生成 SVG 火焰图 ./flamegraph.pl /tmp/out.folded > /tmp/flame.svg

这条路线要注意 JVM 符号问题。纯 Java 方法在 perf 里经常显示成十六进制地址,你必须先为 JVM 生成符号映射文件,让perf script能把地址翻译回方法名。这会多几步额外的配置,所以通常我只有在需要同时看内核栈时才走它。

4.4 顺便把原始折叠数据也留一份

生成 HTML 火焰图时我建议顺手输出一份折叠文本,也就是只包含调用栈和计数的文件。命令里加一个输出格式即可:

./profiler.sh -d 30 -e cpu -o collapsed -f /tmp/executor-12345.collapsed 12345

折叠文本没有图形界面,但占空间极小,而且可以再灌回火焰图脚本重新渲染。后续如果换了配色、想裁剪某些前缀、或者写脚本做热点差异对比,折叠文本比 HTML 灵活得多。我自己的习惯是 HTML 和 collapsed 都保留,采样命令也一并写进备注,否则三周之后你很难想起这张图对应的作业版本和参数。

5. 拿到火焰图之后,怎么判断热点到底在哪

5.1 从最宽的分支往下追,而不是追最高的框

打开 HTML 火焰图后,第一件事不是看顶部,而是找底部往上第一段特别宽的地方。火焰图底部通常是线程入口,比如Thread.run,它的宽度约等于这个线程在采样期间占用 CPU 的总比例。如果看到某个线程名对应的横条占了 60% 的宽度,那就说明大部分 CPU 时间都在这个线程里消耗。

然后顺着这个宽条往上走,看它的子调用链条里哪一步最宽。鼠标悬停在任意框上时,页面通常会显示这个框的完整调用路径和样本占比,比如org.apache.spark.sql.execution ... 完成比例 39.2%。你要找的不是“最高的尖峰”,而是“一路都很宽”的那条路径,那才是稳定的热点主路。

5.2 见栈识问题:几类典型的 executor CPU 热点

我总结过几张重复出现的火焰图特征,贴在这里方便你对照。

火焰图特征常见相关代码路径大致含义
很宽的Kryo/ObjectOutputStream系列com.esotericsoftware.kryo.io、java.io.ObjectOutputStream对象序列化开销大,考虑改用更精简的 schema、减少大对象复制
很宽的ExternalSorter/PartitionedPairBuffer/UnsafeShuffleWriterorg.apache.spark.util.collection.*shuffle 排序或合并压力大,考虑调spark.shuffle.sort.bypassMergeThreshold、增大分区粒度
很宽的业务代码包名你自己写的 UDF 类或scala.collection迭代热点在业务代码里,需要直接优化算法或缓存结果
GC threads相关栈大面积出现G1YoungGen、ParallelGCThreads等GC 压力高,先看堆内存配置和对象分配速率,必要时结合-e alloc做分配分析
很宽的正则 / 字符串处理java.util.regex.Pattern、String.split典型的低效文本处理,优先考虑预编译正则、避免循环 split

看到这些栈,不一定立刻知道根因,但能得到一个很明确的排查方向。比如 GC 类栈很宽时,我会切到 Spark UI 的 executor 页面看每个 executor 的 GC 时间和 GC 次数,如果确实很高,再回头优化内存和对象复用。

5.3 用 GC 指标和阶段信息交叉验证

火焰图能告诉你 CPU 时间花在哪里,但它不解释“这个热点为什么会存在”。所以我会把火焰图和外部的证据链放在一起看。

比如火焰图里 GC 线程特别宽,那就跑一下jstat -gcutil,隔一秒刷一次,看老年代使用率和 Full GC 频率;再比如火焰图里显示 shuffle 排序热点,就去 Spark UI 当前 stage 页面看 shuffle write/read 的字节数,确认是不是数据量大到溢出磁盘。火焰图是“体检报告”,这些外部指标是“既往病史”,两边对上,结论才扎实。

交叉验证还有一个作用:避免被单次采样的偶发噪声误导。一个作业如果存在周期性负载变化,单张火焰图可能只反映某个瞬间。连续采两三张,只有重复出现的热点才值得动手优化。

5.4 如果多个 executor 热点不一致,先怀疑数据倾斜

当 4.2 节里三张火焰图的热点完全对不上时,十有八九是 task 之间数据严重不均衡。有的 executor 在跑超大分区,CPU 全砸在计算和排序上;有的 executor 任务轻,大半时间在等 shuffle 数据。

这种情况下的 CPU profile 并不能直接告诉你“哪张图是对的”,但它暴露了一个重要信息:按平均值去调优是无效的。你要回到数据分布去查 key 的散列情况,或者检查读取源里的热点文件,先把倾斜问题解决,再重新采样看热点是否收敛。火焰图在这里的作用不是定位某行代码,而是帮你判断“当前阶段的瓶颈到底是不是负载不均”。

6. 最容易翻车的几个细节:容器、符号、采样时机与 off-CPU

6.1 容器和云环境:attach 失败与没有内核权限

容器化之后,很多原来好用的手段突然失效。最常见的是perf record在 Kubernetes Pod 里跑不了,报perf_event_open相关错误,因为默认容器对内核事件访问有限制。这时候用 async-profiler 的 attach 模式反而简单:它走的是 JVM 自己的 attach 机制,通常不需要特殊内核权限。

但如果遇到 attach 失败,别急着怀疑工具。先查三件事:目标 JVM 是否还是同一个用户;目标 JVM 是否已经退出;容器/tmp是否可写且可执行。还有一种少见情况是目标 JVM 开启了比较严格的 attach 限制,或者容器里的 JVM 裁剪掉了 attach 相关组件,此时可以改用 Java agent 方式:把 async-profiler 的 jar 写进spark.executor.extraJavaOptions,作为-javaagent在 executor 启动时加载,不再依赖运行时 attach。

6.2 符号缺失:Unknown Frame 和一堆裸地址

如果生成的火焰图里大量显示Unknown Frame或者0x7f1234abcd这样的地址,说明 JIT 编译后的 Java 方法没有被符号化。这个问题在白送火焰图里非常常见,原因是你没有给 JVM 打开必要的信息收集开关。

最有效的方法是在 Spark 提交命令里给 executor 加参数:

--conf spark.executor.extraJavaOptions="-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints"

DebugNonSafepoints会提供更多安全点之外的采样信息,对火焰图质量提升很明显。如果还想让采样到的 Java 栈更完整,可以加-XX:+PreserveFramePointer,但要注意它有额外开销,我一般只在 profiling 期间临时加,不放进长期配置。已经正在运行的进程是没法再通过命令行加这些参数的,只能重启应用再采样。这地方容易白忙活,建议正式采样前先花一分钟预采样,确认火焰图里能看到完整的方法名,再开始长时间采集。

6.3 采样时机不对,火焰图会误导你

Spark 作业不是匀速运行的。同一个应用,读取阶段、shuffle 阶段、聚合阶段、写出阶段,CPU 的消耗结构完全不一样。如果你随手采 30 秒,可能一半时间采在任务初始化上,另一半采在等待调度器分配任务,最后得出来的图几乎没有任何参考价值。

我的做法是先在 Spark UI 里确定正在运行的 stage,把采样窗口对准真正慢的那个阶段。如果作业已经跑完,就需要重新提交一次,在作业运行到目标 stage 时手动启动profiler.sh。宁可少采几秒,也要保证采样内容代表你真正关心的负载状态。

6.4 记住边界:CPU profile 看不到等待,需要时换 off-CPU

火焰图不是万能的。on-CPU 火焰图只统计线程真正占用 CPU 时的调用栈,线程如果在等网络响应、等磁盘 IO、等锁,CPU 火焰图上可能只有很细的一小条,甚至完全没有。所以如果你看到监控里 CPU 使用率本身不高,但作业慢,先去查等待和锁,别在 CPU 火焰图上死磕。

与之相对的排查手段是 off-CPU 火焰图或者 wall-clock 分析,它们能显示线程被阻塞时停在哪个调用点。以锁竞争为例,如果火焰图很宽地在 native 栈里看到与pthread_mutex相关的函数,说明锁竞争已经体现在 CPU 自旋上了;如果线程是Object.wait状态,CPU 图上看不见,得换 wall-clock 时间栈来看。所以先判断瓶颈是“CPU 忙”还是“人等事”,再决定用哪种 profile。

最后分享一个我自己的小习惯:每次做 Spark CPU profiling,都会把火焰图 HTML、折叠文本、对应的作业版本、采样命令和当时正在运行的 stage 一起存好。采样本身并不难,难的是证据链齐全,几天之后还能准确复现和对比。这套“先留证据、再采样子、最后看图”的顺序,比任何单次优化都更值得养成。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询