Spark Executor CPU飙高?用火焰图与async-profiler精准定位热点

📅 发布时间:2026/10/15 22:14:41
Spark Executor CPU飙高?用火焰图与async-profiler精准定位热点
直接讲结论Spark 任务的 Executor CPU 飙高但看不出业务代码在干什么这种情况十有八九不是业务逻辑有多复杂而是序列化、GC、等待锁、甚至 JIT 编译在偷偷吃 CPU。而火焰图Flame Graph就是把 CPU 时间一片片摊开给你看的最直接手段。这篇文章我会把从定位 Executor JVM 进程、设计采样参数、采集 profile 数据、到生成火焰图并且读懂它这一整套流程完整走一遍附上我在实际排障中踩过的坑和最终觉得好使的操作姿势。先交代一下适用对象你在跑 Spark不管 Standalone、YARN 还是 K8s 模式遇到 Executor CPU 占用异常、任务卡在某一个 Stage、或者单纯想知道一段代码到底慢在哪。这篇文章不要求你懂底层性能分析原理只要会看 JVM 进程、能敲 shell 命令就行。采集器和可视化脚本都是开源工具按步骤装好就能用。1. 火焰图对 Spark Executor 做 CPU Profile到底在解决什么问题先说清楚为什么要专门给 Executor 做 CPU profile而不是直接在 Web UI 上看 CPU 曲线就完事。Spark Web UI 能看到的是每个 Executor 的 CPU 使用率、GC 时间、Shuffle 读写量这些聚合指标它能告诉你“哪个环节有异常”但没法告诉你“这 100% CPU 里到底哪行代码贡献了 30%”。1.1 Executor CPU 使用率异常为什么常规手段找不出原因我们平时排查 Executor 高 CPU第一反应是上 jstack 看线程栈。jstack 确实能看到 Java 线程当前栈但它是瞬时快照只能拿到“这一毫秒线程在干嘛”。如果热点代码是循环里的一行普通计算jstack 抓多次内容也差不多能看出来大概但如果 CPU 消耗分布在大量短方法调用、native 调用或者 JVM 内部线程上jstack 就非常乏力。举个例子我之前排查过一个 Spark 任务每个 Executor CPU 稳定在 95% 以上jstack 出来全是 RUNNABLE 状态栈顶在java.util.zip.Inflater.inflateBytes这其实是读 Parquet 解压。但问题是任务读的数据量并不大为什么解压 CPU 这么高后来发现是压缩算法选的 ZSTD并且每个 Task 开了太多小文件解压开销被文件数放大。这种事 jstack 能指向一个方向但火焰图能把“解压、序列化、GC、任务调度”这些开销按 CPU 时间占比排出来一眼看到底。还有一个容易被忽略的点Spark Executor 的 CPU 使用率包括 JVM 线程、GC 线程、JIT 编译线程C1/C2 Compiler以及 native 代码。其中 JIT 编译线程如果持续高是典型的“代码过热”信号跟业务逻辑没关系。jstack 抓不到编译线程的细节火焰图却可以把编译线程的栈也画出来直接量出内核态和 native 栈。1.2 火焰图能告诉你的三件事火焰图本质是 CPU 采样得到的调用栈聚合。它对 JVM 进程按固定频率取线程栈比如每秒 99 次或 1000 次时间越长、样本越多最终每个方法在图中占的横向宽度就越接近它的真实 CPU 消耗占比。从一张火焰图里至少能读出三件事宽度越大的函数CPU 上的时间越多。这通常是首要排查点。调用链关系。从下往上看栈底是入口比如Executor::run栈顶是此刻正在执行的叶子方法。一段宽大的调用链表示 CPU 被一个固定路径反复消耗。不同颜色区域代表不同类型代码。常见配色里 Java 方法是一种颜色native 方法包括内核态是另一种颜色JIT 编译、GC、VM 内部线程还会有独立颜色。颜色一深一浅能快速看出 CPU 到底花在业务代码还是 JVM 内部。你把这三件事和 Stage 持续时间、输入数据量、GC 日志放在一起对照基本能把“高 CPU”分解成“业务计算高”“序列化高”“GC 高”“Shuffle 高”“JIT 高”这五类后续优化就是分而治之的事。2. 工具选型为什么我推荐 async-profiler 而不是 jstack做 JVM CPU profile可选工具其实不少内网环境常用perfFlameGraph脚本Java 自身有 JFRJava Flight Recorder商业 profiling 工具也有很多。我给 Spark Executor 做 CPU 火焰图最常用的是async-profiler原因后面说。2.1 先解决“CPU 时间都花在哪”这个基本问题首先要理解采样型 profiler 和 jstack 的本质区别。jstack 是“拍照”async-profiler 是“录像”。采样型 profiler 以固定频率典型 100Hz 到 1000Hz读取每个线程的调用栈把所有线程栈按栈路径聚合最后形成每个栈路径的时间占比。采样的逻辑很简单每 10ms 触发一次信号JVM 收到信号后记录当前线程的 Java 栈、native 栈和内核栈。这里的关键技术点是“怎么触发采样”。JFR 使用的是 safepoint 采样只在安全点记录会漏掉大量正在运行纯 Java 代码的线程栈导致数据偏向 GC 和锁相关。async-profiler用perf_eventsLinux 上的性能计数器子系统和 AsyncGetCallTrace 机制能够绕过 safepoint 限制在任意时刻抓到 Java 字节码执行栈数据准得多。这就是为什么同一个 Spark 任务JFR 和 async-profiler 画出来的火焰图“结论完全不同”不是工具坏了而是 JFR 采样的系统偏差。给 Spark Executor 做 CPU profile我强烈建议用 async-profiler因为它能同时拿到 Java 方法、native 方法、内核栈正好覆盖 Executor 里数据处理 网络 IO 文件读写这条链路。2.2 采集器怎么挑Perf 事件、JFR 与 async-profiler 的取舍如果你的环境只能用 JFR比如某些容器里的受限环境也不是不行。JFR 的优势是 JDK 11 自带不用额外安装采集开销低。但它的 CPU profiling 是 safepoint-based会导致代码热点里“正在执行的 Java 方法”被低估而把时间算到 safepoint 等待或者 GC 上去。做粗略分析可以定位精确热点容易误导。perf是 Linux 内核级工具能抓 native 栈和内核栈但对 Java 方法栈只能看到像是[JIT]之类的地址还需要用perf-map-agent把 JIT 编译的方法地址映射成 Java 方法名。这套组合在容器里经常遇到权限问题要开perf_event_paranoid还要在 JVM 启动时加一堆 agent 参数落地成本高。不过它有一个不可替代的优点能看全内核栈和系统调用如果怀疑 CPU 花在零拷贝、网络、磁盘 IO 上perf比 async-profiler 更深。async-profiler 正好居中。它用 Linux perf 事件的采样能力又能通过 JVMTI 拿到 Java 栈和 native 栈生成结果直接是可读的方法名。它还集成了火焰图生成功能一条命令就能产出 SVG。对“Spark Executor CPU 到底花在哪”这种问题它是性价比最高的选择。2.3 安装与前置准备安装 async-profiler 很简单把对应平台的包下载解压即可不需要安装到系统目录。目录下有两个关键东西.so格式的 agent 库和profiler.sh脚本。profiler.sh负责向指定 Java 进程 attach或者把 agent 附加到 JVM 启动参数里。前置检查三件事JDK 版本要匹配。JDK 8 到 JDK 21 的主流版本都能用但不同版本对 attach 机制有差异遇到 attach 失败要先确认版本兼容。Linux 内核和权限。容器内跑 async-profiler 需要在宿主机允许 perf 事件perf_event_paranoid小于等于 1部分托管环境默认禁掉。如果拿不到权限需要换用-e cpu之外的事件或者改用 JFR 折中。Spark Executor 的启动参数。强烈建议给 Executor JVM 加-XX:UnlockDiagnosticVMOptions -XX:DebugNonSafepoints这样采样得到的 Java 栈信息更完整能还原字节码对应的源码行。不加也能采但结果会稍微粗糙。注意在 Spark 里给 Executor 加参数不要直接改spark-env.sh的全局配置生产环境建议用spark.executor.extraJavaOptions按任务维度指定避免影响别的任务。3. 定位 Spark Executor 进程并在线上采集 CPU 火焰图工具准备好了最难的一步其实是“找到对的那个进程”。Spark Executor 常常有多个每个 JVM 进程号都不一样采集的时候采样到 Driver 或者别的 Executor 上就全白干了。3.1 第一步找到真正的 Executor JVM PID判断标准不是“进程名里带 Executor”而是 JVM 启动参数里包含spark.executor。方法如下# 在 Executor 所在节点执行列出所有 java 进程及完整命令行 ps -ef | grep java | grep -v grep如果有多个 java 进程用jps -lv更直观jps -lv | grep -i executor输出里会有一行类似12345 /usr/lib/jvm/java-17/bin/java ... -Dspark.executor.id5 -Xmx4g ...记录下第一列 PID比如 12345。-Dspark.executor.id5说明这是第 5 个执行器容器不要采集成-Dspark.driver的那个进程。如果你用的是 K8s 模式Executor 以独立 Pod 跑通常是每个 Pod 一个 Java 进程进到 Pod 里执行jps也能看到。还要注意一个常见陷阱YARN 模式下同一个节点可能有多个任务混合部署光看进程数不够一定要用jps -lv看完整参数不然你可能采到一个“恰好也是 Java 但根本不是 Spark Executor”的进程。3.2 第二步采样前的参数设计采集参数直接影响火焰图质量。以 async-profiler 为例我常用的命令是这样./profiler.sh -d 120 -i 10ms -o flamegraph -e cpu -t 12345 /tmp/executor_5_cpu.svg参数含义-d 120采样持续 120 秒。120 秒对大多数 Spark 任务够用能看到稳定热点如果任务阶段本身只有几十秒就按阶段时长来建议至少覆盖一整个 Stage。-i 10ms采样间隔 10ms对应约 100Hz。实际生产经验是 10ms 到 20ms 之间比较稳。间隔太小会让 profiler 自身开销变大干扰结果间隔太大则热点分辨率不足短暂高 CPU 的函数可能被漏掉。-e cpu按 CPU 时间采样。还有-e itimer和-e wall先说结论排查“CPU 使用率高”就用cpu排查“任务慢但 CPU 不高”用wall更有价值。cpu模式只统计线程在 CPU 上的时间wall统计墙钟时间包括等待 IO、锁、睡眠。两者结合能区分“CPU 忙”和“纯等待”。-t采集所有线程。不加这个参数默认只采集主线程实际上 async-profiler 默认采集所有线程但显式加上更保险。-o flamegraph直接输出火焰图 SVG。如果还想要更细致的分析可以输出jfr格式导入工具里交互看。这里再提一个进阶参数--all-user或--user之类的过滤不常用但-e cpu时加上--cstack可以决定采集 native 栈的深度默认够用。想要看系统调用层栈加上--cstack fp试试不过采样负载会提高不长期开。3.3 第三步三种实际可用的采集方式采集方式取决于你对任务的控制力三种我都用过分别适用不同场景。第一种: attach 模式推荐大多数场景。直接对运行中的 Executor 执行profiler.sh启动采集到期自动生成 SVG不需要重启 JVM。这种模式适合线上已经在跑的 Spark 任务发现问题后临时挂载采样。./profiler.sh -d 120 -o flamegraph -e cpu -t 12345 /tmp/executor5.svg第二种: 启动即在采集用于复现稳定问题。如果问题是稳定复现的可以把 agent 挂到 Executor 启动参数里让 JVM 一启动就开始采样spark.executor.extraJavaOptions-agentpath:/path/to/libasyncProfiler.sostart,eventcpu,file/tmp/executor_%p.svg,interval10ms,duration120s这里的%p展开成 Executor PID多 Executor 各写各的文件不用抢。但这种模式要重启任务适用于测试环境或者能接受重启的线上小任务。第三种: 在 Spark 代码里主动触发。如果是想针对某一阶段精确采样可以在代码里包一层调用AsyncProfiler.getInstance().execute(start)和控制结束。适合分析某个特别慢的 partition缺点是代码要改动临时加测完再移除。我自己最常用的是 attach 模式因为它最不会影响线上任务而且还支持随时停止按 CtrlC 或者kill采集进程只影响采集器不影响 Executor。3.4 第四步把采集结果导出为火焰图其实 async-profiler 内置的 flamegraph 输出就能直接用但有时候遇到输出是 HTML 版本或者你用的是 JFR 文件就需要手工通过 FlameGraph 脚本二次转换。这套脚本是 Perl 写的核心步骤是# 从 JFR 或轻量采样文件转成折叠栈格式 # 如果 async-profiler 输出是 collapsed 格式直接跳过这步 ./jfr2flame all /path/to/result.jfr /tmp/folded.txt # 生成 SVG 火焰图 ./flamegraph.pl /tmp/folded.txt /tmp/executor5_cpu.svg不过更常见的做法是直接用 profiler.sh 输出的 SVG。它的优点是自带搜索框可以在浏览器里按方法名搜索也可以输出 html 格式交互更强./profiler.sh -d 120 -o html -e cpu -t 12345 /tmp/executor5_cpu.html用浏览器打开 HTML点击任意方法都能重定位火焰图把某个子栈放大研究。线上我一般先输出 HTML方便点击查看再导出 SVG 放进文档留档。4. 火焰图怎么读区分热点、噪声与真相拿到火焰图先别急着看顶上最宽的函数。火焰图的信息量很大读图顺序不对很容易被 JIT 编译线程或者采样噪声带偏。4.1 纵向调用栈与横向时间占比先理解坐标轴横向是 CPU 时间占比纵向是调用深度。底部最宽的那一层是所有线程的入口聚合比如Executor::run或者Thread::run。往上一层就是它调用的方法再往上是子方法。宽度代表的是“这一栈路径占所有采样的比例”。判断热点从“顶部宽块”开始没有错但是要看它是“叶节点宽”还是“路径宽”。如果最顶部某个叶子方法很宽说明 CPU 真真切切被这个函数占着。如果顶部是很多窄条但底部中间某一层特别宽说明 CPU 被同一个父方法下的不同子路径消耗重点要看父方法而不是看某个窄叶子。举例我见过一张火焰图顶部 30% 都是一大片java.util.HashMap.putVal很多人会直接冲着 HashMap 优化。但放大了看这些 put 全都来自KryoSerializer.deserialize的某一层真正应该改的是序列化对象结构而不是 HashMap 本身。宽块要顺着栈往下追 3~5 层找到业务入口。4.2 不同颜色的含义Java 方法 vs native vs C2async-profiler 默认配色大致是黄色系Java 方法绿色/蓝色系native 方法包括 JVM 内部和系统库红色/橙色kernel 栈内核态 CPU 开销这只是一个大概具体版本配色会变。重点是看颜色分布能快速定性。如果大块黄色且集中在业务代码是纯 Java 计算瓶颈比如正则、字符串操作、复杂数据结构。如果中间混了大量绿色 native 方法多半是系统函数、压缩解压、sun.misc.Unsafe之类。 如果底层出现大块红色CPU 时间花在内核态常见是文件读写、网络收发、内存分配时的 page fault 处理。这种时候往往伴随 IO 高或者系统调用频繁优化要往批量化、减少 syscall 上走而不是调业务逻辑。还有一个容易被忽略的C2Compiler图标。JIT 编译本身也要 CPU采样结果里会出现C2Compiler::compile_method正常情况下占比很低1%~3%。如果它长期占 10% 以上说明代码被反复 JIT 编译一般发生在热循环被频繁触发或者方法太巨大的场景。配合-XX:PrintCompilation能看到编译事件但我更建议先优化代码结构减少巨大方法。4.3 典型模式GC、序列化、Shuffle 三大热点怎么一眼认出结合 Spark 的特点火焰图里最常出现的三种固定模式我可以直接给你特征。GC 热点火焰图顶部或中部出现大片G1YoungGenCollector::...、ParallelScavengeHeap::...、VMThread。VMThread 不算业务但 GC 线程是独立一根得看它占的总宽度。GC 占用超过 20% 基本说明内存压力大。这时候该看的是 Executor 内存配置、对象分配速率不是火焰图里别的业务函数。序列化热点出现大片com.esotericsoftware.kryo.io.*或者java.io.ObjectOutputStream、Encoder/Decoder。Kryo 序列化 CPU 高的情况先看是不是默认用了 Java 序列化还是 Kryo 里注册的 class 太多导致每个对象都写类名。Shuffle 热点出现大块org.apache.spark.shuffle.*、netty相关调用栈比如NettyBlockTransferService、FileShuffleBlockResolver。说明大量时间在写 shuffle 临时文件和传输数据配合 Web UI 里 Shuffle Write 指标一起判断是真实数据量过大还是小文件太多导致开文件开销。技巧火焰图里看到大片com.esotericsoftware.kryo.Kryo.writeObject别急着认定是序列化库慢先切到“按调用栈看数据量”——如果是Array[Long]写基本类型数组Kryo 不会慢要看到writeClassAndObject或者Struct包装类才说明对象图复杂。5. 常见问题与排查技巧实录采集火焰图本身不算难难的是采集过程中各种环境问题。以下是我实际踩过的坑整理成速查表形式。5.1 jstack 打出来的线程跟火焰图对不上这不是火焰图的问题而是采样模式不同。jstack 抓的是瞬时线程 DUMP火焰图是高频采样的聚合统计。两者出现差异很正常尤其是在低峰期抓 jstack 可能抓到线程在Thread.sleep或park而火焰图把 CPU 时间集中统计只体现活跃线程。如果非要对照方法是在采样结束后一分钟内连续抓 3~5 次 jstack找到始终 RUNNABLE 的线程再回火焰图里搜对应栈顶方法。只抓一次就下结论容易误判。5.2 火焰图顶上全是 C2Compiler / VMThread顶上一大片 C2Compiler通常有两个原因计算任务路径上有短循环小方法被 C2 反复编译。采样事件选的是cpu而cpu事件按 CPU 时间统计编译线程确实占用 CPU。如果编译线程只占 5% 以下可以忽略。超过 15%试试给 Executor 加-XX:CICompilerCount2限制编译线程数或调整-XX:CompileThreshold提高编译阈值。但注意编译线程限制会影响峰值吞吐别一刀切禁用编译。VMThread 占比高则要小心它可能是 safepoint 等待导致。检查-XX:PrintSafepointStatistics看停顿时间或者直接加-XX:-UseBiasedLockingJDK 15 以后默认禁用试试。5.3 GC 线程占比异常GC 线程占 CPU 高火焰图上会看到G1垃圾回收线程栈或GC_task_thread。这里要区分“全 GC”和“年轻代 GC”。大块G1YoungGen说明对象分配速率高重点找谁在疯狂 new 对象。大块G1FullGC则是老年代爆了重点看内存配置和内存泄漏。GC 线程占 CPU 超过 25%任何业务代码优化都不如先调 Executor 内存和spark.memory.offHeap.enabled来得立竿见影。另外我踩过一个坑开了 off-heap 以后火焰图里出现大量sun.misc.Unsafe::allocateMemory和malloc相关 native 栈CPU 占比高但 GC 反而降下来了。这其实是堆外内存分配频繁解决方向是加大 off-heap 复用或者用 MemoryPool 预分配。5.4 attach 失败常见原因profiler.sh往一个运行中的 JVM attach最常见的错误是ERROR: failed to attach。原因清单当前用户和 Executor 进程用户不一致。用sudo -u切到同一个用户再跑。容器里 attach 不了宿主机 JVM需要在同一个 pid namespace 内执行一般是进入 Pod 再跑。JDK 版本过低或过高导致 agent 不兼容换对应版本的 async-profiler 包。JVM 启动时加了-XX:DisableAttachMechanism那就只能走启动参数挂 agent 的方式。调试技巧加-v参数跑profiler.sh -v -d ...能打印 attach 过程的详细日志定位是哪一步失败。5.5 采样数据太慢/文件过大短时间采样120 秒生成的 SVG 通常几 MB 以内但如果你拿-i 1ms高精度采样或者持续采样半小时文件可能几百 MB浏览器完全打不开。解法两个方向降低频率间隔改成-i 50ms文件骤减热点识别影响有限。输出为 collapsed 文本格式然后用命令行过滤只看目标包名。比如只关心业务包可以grep -E com.example|org.apache.spark collapsed.txt再生成 SVG聚焦速度会快很多。还有一点不要直接在生产节点上开着 profiler 跑半小时以上async-profiler 自身开销很低大约 1%~2%但采样导致的 CPU 缓存压力和文件写入压力在重负载节点上不可忽略。建议采样时长控制在任务一个 Stage 的 1.5~2 倍最长不超过 5 分钟。6. 实战案例一次 Executor CPU 100% 的定位过程前面理论讲了不少用一个简化过的真实案例把整套流程串起来。案例中的任务可以理解为“模拟项目 X”从 Hive 表读取一批明细数据做清洗、聚合、再写回表。现象是运行到第三个 Stage 时所有 Executor CPU 使用率拉满任务跑不完。6.1 现象与初步假设Web UI 上看到 Executor 各分片 GC 时间只有 2%~3%说明不是 GC 问题。Shuffle 读写量正常但 CPU 100% 持续了 40 分钟明显不正常。我当时的初步假设是业务代码里有某段循环正则或者字符串处理太慢又或者是读取的源数据带有大量嵌套结构导致序列化开销大。先找到对应节点jps -lv锁定 PID。然后执行./profiler.sh -d 90 -i 10ms -o html -e cpu -t 12345 /tmp/spark_stage3.html90 秒覆盖一个完整 Task 执行周期看 CPU 采样结果。6.2 火焰图解读打开 HTML 火焰图以后第一眼整张图底部是两层下面一层是java.lang.Thread.run与org.apache.spark.executor.Executor$TaskRunner.run往上比较宽的是org.apache.spark.sql.execution.WholeStageCodegenExec。再往上出现一个非常宽的黄色块org.apache.spark.sql.catalyst.expressions.GeneratedClass$SpecificUnsafeProjection.apply宽到几乎占了整图三分之一。这时候注意直接看这个函数名很容易得出“Spark SQL 投影计算瓶颈”的结论但内行都知道这只是代码生成后的入口真正消耗要看它的叶子节点。进一步展开后我发现叶子方法是org.apache.spark.unsafe.types.UTF8String.compareTo且栈下方跟SortAggregate相关。这不就是我之前提到的“路径宽”而不是“叶子宽”。真正热点是排序聚合时的字符串比较。因为聚合 key 是若干个 varchar 字段拼接后比较数据量一大UTF8 字符串比较就成了 CPU 杀手。同时火焰图底部还有一小片绿色 native 栈libc.so.6里的__memmove_avx_unaligned_erms这是字符串比较时内存拷贝导致的 native 开销。这进一步证实了数据在 key 比较上花了很多时间。6.3 修复与验证根据火焰图的结论没有去优化投影表达式而是直接改了两点聚合 key 里去掉一个低区分度的超长字符串字段改用自增代理键join/聚合的字符串比较量大幅下降。对剩下需要字符串比较的字段启用 Spark 的二进制比较优化本质上是避免反复构造 UTF8String同时在写入表时把该字段排序放到前面减少比较开销。改完后重新跑同样的数据CPU 从 100% 降到 35% 左右Stage 耗时从 40 分钟缩到 11 分钟。整个过程里头火焰图的最大价值不是告诉我哪个函数热而是告诉我“热点是路径型的”——CPU 耗在聚合 key 的字符串比较链路上这直接决定优化方向。如果只看 Web UI 和 jstack我大概率会去调 Executor 内存或者加并行度解决不了根本问题。7. 写在最后关于 CPU 采样的几点个人体会采样的次数多了以后我的一个总印象是给 Spark Executor 做火焰图最耗时间的环节从来不是“生成图”而是“把进程认准、把采样时长设计对、把热点的路径看全”。很多人图拿到了看一眼最宽的叶子方法就去改代码往往改完没有效果因为他们没看这叶子方法是从哪条业务路径进来的。另一个体会是CPU profile 要跟 GC 日志、Stage 指标、Shuffle 统计一起用不能只看一张图下结论。火焰图回答的是“CPU 时间分配”GC 日志回答的是“内存为什么紧张”Stage 指标回答的是“慢在哪一步”。三份数据对齐后才能既不冤枉业务代码也不放过 JVM 内部问题。最后分享一个小技巧环境允许的前提下cpu和wall两种事件都采一次对比着看。同一个任务如果cpu火焰图和wall火焰图差异巨大说明任务其实不是 CPU 瓶颈而是在等待如果两者热点基本一致才是真正的计算密集。很多“CPU 高”的问题可能实际是“等价转换成了 CPU 忙”的等待问题这两张图一对比现象就清楚了。