Spark Executor CPU 火焰图分析:从采样到性能调优的完整实践
1. 为什么要给 Spark Executor 做 CPU 分析很多 Spark 开发者都有过这样的经历任务跑得慢看 Spark UI 发现某个 Stage 的耗时特别长点进 Executor 页面一看CPU 使用率要么长时间跑满要么一个核在忙、其他核闲着。这时候如果你只去调并行度、调内存参数往往治标不治本。真正的问题可能藏在某个不合理的算子、一个低效的 UDF、或者一次糟糕的数据倾斜处理里。火焰图Flame Graph是这类问题最好的诊断工具之一。它能把 CPU 时间按调用栈的维度聚合起来让你一眼看出 CPU 时间到底消耗在哪些函数上。对 Spark 来说executor 是真正干活的进程所有的 task 都在 executor 的 JVM 里跑。对 executor 做 CPU profile本质上就是对 JVM 进程做采样分析然后把采样结果渲染成火焰图。这篇文章我会讲清楚完整的实操链路从环境准备、工具选型到对 executor 进程做采样、生成火焰图、解读火焰图、定位问题最后附上我在实践中踩过的坑和排查经验。整个过程不需要改 Spark 代码不需要重启集群完全可以在生产环境操作。适用人群用 Spark 做数据处理、遇到了 CPU 瓶颈但不知道瓶颈在哪、想学会系统化性能分析方法的开发者。如果你只是想知道“哪个 Stage 慢”Spark UI 就够用了但如果你想知道“CPU 到底在忙什么”火焰图是绕不开的工具。2. 火焰图的工作原理与工具选型2.1 火焰图到底是怎么画出来的火焰图的核心思路不复杂周期性对目标进程做采样比如每 1 毫秒记录一次当前线程的调用栈连续采样几十秒甚至几分钟然后把所有采样点按调用栈聚合画成一张横轴是时间占比、纵轴是调用深度的图。横轴不是时间轴是采样点占比。一个函数在横轴上占的宽度越宽说明它被采样到的次数越多也就是 CPU 时间消耗越多。纵轴是调用栈的深度最下面是根越往上越接近当前正在执行的函数。一个“火焰”的形状底部宽、顶部尖代表大量采样点都落在了相近的调用路径上通常意味着热点非常集中。这里有个关键点火焰图采样的是“正在 CPU 上执行的代码”也就是 on-CPU 时间。它不直接统计等待 IO、锁等待、网络等待这些 off-CPU 时间。如果你的瓶颈是 shuffle 拉数据太慢、或者 GC 停顿火焰图单独看是看不全的得结合其他工具。但对 CPU 密集型任务来说火焰图就是最直观的工具。2.2 主流采样工具怎么选对 Spark executor 做采样主要候选工具有这几个工具原理优点缺点jstack 脚本周期抓线程栈零安装采样频率低、聚合麻烦JFRJava Flight RecorderJVM 内置事件开销低、功能全需要 JDK 11命令行稍复杂async-profiler基于 perf_event 和 JVMTI采样开销低、支持火焰图直接输出需要装 native 库perf 翻译脚本Linux 内核采样能采到 native 代码对 JVM 调用栈翻译麻烦我的建议是直接用 async-profiler。它是目前对 JVM 进程做 CPU 分析最成熟的工具底层用 Linux 的 perf_event_open 做采样对 JVM 做了专门的调用栈解析能把 Java 方法、JIT 编译后的代码、native 代码统一还原成 Java 层面的调用栈。它还能直接输出火焰图需要的折叠格式配合 Brendan Gregg 的 FlameGraph 脚本就能出图。如果你用的是 JDK 11 以上的版本JFR 也是一个很好的选择它内置在 JVM 里不需要额外装 native 库。但 JFR 默认的采样配置偏向于低开销对短时间、高精度的 CPU 热点分析没有 async-profiler 灵活而且生成火焰图还需要额外转格式。考虑到 Spark 集群的 JDK 版本往往不太统一async-profiler 的兼容性更好。2.3 离线采样还是在线采样对生产环境的 Spark executor 做 profile有两种方式第一种是离线采样在 executor 进程外面用jstack或 async-profiler 的-d参数指定采样时长采完生成结果文件再下载到本地分析。这种方式对目标进程几乎无侵入风险最低。第二种是在线采样通过async-profiler的-o参数持续输出到文件或者用 JMCJava Mission Control远程连接 JVM 实时观察。这种方式可以看到实时的调用栈变化但对生产环境来说远程连接 JVM 需要开 JMX 端口有安全风险我一般不太推荐。下面所有步骤都以离线采样为主线这也是我实际用得最多的方式。3. 实操前准备定位 Executor 进程与安装工具3.1 找到 Executor 的 PID对 executor 做 profile 的第一步是找到 executor 对应的 JVM 进程 PID。这一步看着简单但生产环境经常因为找错进程而白忙一场。如果你在 YARN 集群上跑 Sparkexecutor 进程是 YARN NodeManager 拉起的容器里的 Java 进程。最简单的方式是在 executor 所在节点上执行ps aux | grep -i executor | grep -v grep输出里会看到类似这样的行yarn 12345 12.3 8.1 5242880 5242880 ? Sl 10:23 1:45 /usr/lib/jvm/java-8-openjdk-amd64/bin/java -server -Xmx4096m -Djava.io.tmpdir/data/tmp ...其中第二列的 12345 就是 PID。注意看命令行里有没有org.apache.spark.executor.CoarseGrainedExecutorBackend这是 Spark executor 进程的主类也是确认进程身份最可靠的标志。因为同一节点上可能还有 NodeManager、ApplicationMaster 这些 Java 进程光看 Java 进程是不够的。如果在 K8s 上跑 Spark情况类似executor 也是容器里的 Java 进程需要先通过kubectl get pods找到 executor 的 pod再kubectl exec进容器里执行ps查找 PID。容器内不一定有ps命令这时候可以用jpsjps -l输出里带CoarseGrainedExecutorBackend的就是 executor。jps是 JDK 自带的工具比ps更精准前提是节点上装了 JDK。3.2 确认 JDK 版本和权限在动手之前先确认两件事第一executor 的 JVM 版本。async-profiler 对不同 JDK 版本的支持有差异JDK 8 和 JDK 11 用起来基本没问题JDK 17 以上也支持但要确认你下载的 async-profiler 版本。最好在节点上跑一下java -version确认版本。第二采样的权限。async-profiler 底层用 perf_event_open 系统调用有些内核配置了perf_event_paranoid限制非 root 用户使用。检查一下cat /proc/sys/kernel/perf_event_paranoid如果输出是 1 或 2非 root 用户可能没法直接采样。两个解决办法一是用 root 用户执行 async-profiler二是临时调低 paranoid 值但生产环境不建议随便改内核参数首选还是用 root。3.3 准备 async-profiler 和 FlameGraph 脚本async-profiler 的安装很简单下载对应平台的压缩包解压就行。解压后目录里会有build/async-profiler.jar和profiler.sh两个主要文件。profiler.sh是启动脚本async-profiler.jar是真正干活的部分。FlameGraph 脚本是 Brendan Gregg 开源的 Perl 脚本集主要用到两个脚本stackcollapse-*.pl负责把采样结果里的原始调用栈折叠成聚合格式flamegraph.pl负责把折叠格式渲染成 SVG 图片。获取方式git clone https://github.com/brendangregg/FlameGraph.git这个仓库很小clone 下来就行。如果你所在的网络环境访问 GitHub 不方便可以先在本地下载好再传到集群节点上。生产集群一般没有外网权限提前把工具放到共享目录或者直接放到节点上是常态。提示async-profiler 版本之间差异不小遇到奇怪的报错先考虑升级版本。我用过旧版本在 JDK 11 上采样时出现线程栈乱掉的情况升级到新版后问题消失。4. 核心操作对 Executor 进行 CPU 采样4.1 采样时长的选择这是第一个关键决策点。采样时间太长会产生特别大的文件而且会引入太多无关的上下文切换太短又可能采不到热点。我一般按任务的 Stage 时长来决定。如果某个 Stage 跑 10 分钟采样 1 分钟足够了因为热点函数通常会持续存在。如果 Stage 只有 30 秒那采样时长设 15-20 秒尽量覆盖任务的主要部分。还要注意采样时机最好在任务稳定运行的时间段采样避开任务刚启动时的 JVM 类加载、代码 JIT 编译阶段也别在任务快结束时采样那时候很多线程在收尾参考价值不高。4.2 基础采样命令在 executor 节点上执行./profiler.sh -d 60 -o collapsed -f /tmp/executor_spark.collapsed PID解释一下参数-d 60采样 60 秒-o collapsed输出折叠格式后面用 FlameGraph 脚本生成 SVG-f输出文件路径最后一个参数是目标进程 PID60 秒后会在/tmp下生成一个.collapsed文件。这个文件每一行代表一个采样到的调用栈格式是“调用栈帧1;调用栈帧2;... 次数”。这就是火焰图的数据源。这里有个细节-o collapsed输出的是纯文本折叠格式文件可能很大。比如采样 60 秒、每秒 1000 次采样就有 6 万个采样点每个采样点一行文件大小可能到几十 MB。这是正常的别慌。4.3 生成火焰图 SVG在本地或者节点上用 FlameGraph 脚本把 collapsed 文件渲染成 SVG./flamegraph.pl /tmp/executor_spark.collapsed /tmp/executor_spark.svg然后就可以用浏览器打开 SVG 文件查看。如果你是在本地生成的直接拖到浏览器里如果在服务器上生成的可以scp到本地再打开。这一步有个经验我强烈建议多生成几个视角的图。上面生成的是默认的火焰图从底部根调用往上聚合。你还可以生成差分火焰图、以及只看某个线程的火焰图后面在排查章节会详细讲。4.4 采样时的额外注意事项采样过程中有几点必须注意否则很容易采到“假数据”第一executor 进程可能有多个线程在跑但 Spark 的 task 主要是通过Executor.TaskRunner线程执行的。flamegraph 默认会聚合所有线程。如果你只关心 task 执行的 CPU 时间有两种做法一是采样时加-t参数指定线程名比如-t Executor TaskRunner但这种写法对线程名匹配要求严格二是采样后用脚本过滤线程。我倾向于第一种更干净。第二JIT 编译对采样的干扰。JVM 的 JIT 编译器在运行早期会把热代码编译成 native code火焰图上会表现为大量时间花在Compile相关的调用上。这不是真正的问题点只是正常的 JIT 行为。如果火焰图顶部出现大块的Compile可以等任务运行热了再采样或者忽略这部分。第三GC 线程。火焰图上如果出现大块的 GC 相关调用比如G1CollectedHeap::...说明 CPU 时间大量花在垃圾回收上。这其实是很有价值的发现——说明内存压力大或者对象分配太频繁但处理方式和 CPU 热点不同后面会单独讲。5. 火焰图解读从图形到结论的思维路径5.1 看懂火焰图的三个层次拿到一张火焰图不要急着找“最宽的块”要按层次去看。第一层看整体形状。如果火焰图形状又高又尖说明调用栈很深可能存在多层嵌套调用如果比较扁平说明调用栈浅CPU 时间分散在许多平行路径上。高而尖的图通常意味着反射、代理、或者框架的层层包装这时候要警惕框架开销过大。第二层看顶部颜色块。火焰图的每一个颜色块代表一个函数颜色本身没有特殊含义只用于区分不同的函数。顶部宽大的块是“叶子函数”也就是 CPU 真正在执行的函数。如果顶部大块集中在某个业务方法比如你的 UDF 里的map函数说明 CPU 花在业务逻辑上如果集中在一堆 JDK 库函数上比如java.lang.String相关操作说明可能在频繁做字符串处理。第三层看父调用链。点击任意一个色块会高亮它的所有父调用。通过看父调用链你能知道这个热点是从哪条路径进来的。很多新手只看顶部忽略了“这个热点是怎么被调起来的”这个关键信息。5.2 一个典型的 Spark CPU 热点解读示例举个例子我曾经遇到过一张火焰图顶部是一个很宽的compress相关函数块。顺着调用链往下看发现是从ParquetWriteSupport一路调用下来的。这个 Stage 是在写 Parquet 文件CPU 时间大量耗在压缩上。进一步分析后发现这个任务的数据本身压缩率很低但还是选用了 snappy 压缩而且数据分布导致每个 block 都很小压缩的固定开销占比偏高。这就是火焰图的典型用法你以为瓶颈在 Shuffle实际在图里看到的是序列化和压缩。这种结论光靠看 Spark UI 是得不出来的UI 只能告诉你“这个 stage 慢”不能告诉你“慢在压缩”。所以说火焰图的本质是让你从“知其然”到“知其所以然”。5.3 热点分类与处理优先级把火焰图上看到的热点按类型分类处理思路是完全不同的热点类型图上的特征处理方向CPU 密集计算叶子函数是业务代码或算法库优化算法、减少计算量序列化/反序列化出现 Kryo/Java 序列化相关函数换序列化方式、减少数据传输量GC 频繁出现垃圾回收器相关函数块调整内存、减少对象分配锁竞争出现LockSupport.park、synchronized相关优化锁粒度、消除竞争网络/IO出现Socket/FileChannel等但火焰图不统计 off-CPU结合其他工具分析这个表不是严格的分类标准是我自己实践中整理的分析框架。看到一个热点块之后先归个大类再看处理方向比对着图发呆有效率得多。6. 进阶实操差分火焰图与线程级分析6.1 用差分火焰图对比优化前后的变化很多时候你做了优化之后需要验证到底有没有效果。只靠对比两张火焰图的“宽宽度”不够精确我推荐用差分火焰图。实现方式很简单。假设你优化前采样得到before.collapsed优化后得到after.collapsed用 FlameGraph 仓库里的difffolded.pl脚本生成差分图./difffolded.pl before.collapsed after.collapsed | ./flamegraph.pl diff.svg差分图里红色表示优化后变多的部分蓝色表示变少的部分。红色越宽说明该函数的 CPU 占比在优化后反而增加了需要警惕蓝色越宽说明优化有效热点降下去了。我在实际工作中养成了一个习惯每一个优化动作前后都留一次采样结果攒多了就能总结出很多经验。比如某次我把一个广播变量从collectAsMap改成broadcast之后差分图上序列化相关的蓝色块明显变宽说明优化确实减少了序列化开销。6.2 只看某个线程的火焰图Spark executor 里除了执行 task 的线程还有一堆后台线程比如 shuffle 相关的ShuffleBlockFetcherIterator线程、代码生成线程、JIT 编译线程、GC 线程等等。默认的火焰图把所有这些线程的时间都聚合在一起可能导致某个线程的热点被淹没。如果你怀疑热点集中在某个特定线程可以这么做。先用 async-profiler 采样时不加-t然后用脚本把 collapsed 文件按线程过滤。不过 collapsed 格式本身已经丢失了线程信息所以更好的办法是采样时直接输出带线程信息的格式./profiler.sh -d 60 -o collapsed -t -f /tmp/executor_tasks.collapsed PID-t参数会让输出按线程分组生成单独的折叠文件文件名里会带上线程名。然后针对感兴趣的线程文件分别生成火焰图。这个方法在排查数据倾斜时特别有用。如果某个 task 线程的火焰图明显比其他的宽说明这个 task 的 CPU 时间远高于其他 task数据倾斜就实锤了。配合 Spark UI 看到的 task 耗时分布能很准确地定位到是哪个 task、卡在哪个算子。6.3 JFR 采样作为交叉验证async-profiler 的结果虽然可靠但我偶尔还会用 JFR 交叉验证一下。特别是当你怀疑 async-profiler 因为底层 perf_event 的限制漏采了某些事件时。JDK 11 的 JFR 用起来很简单jcmd PID JFR.start duration60s filename/tmp/executor.jfr jcmd PID JFR.dump filename/tmp/executor.jfr然后把 jfr 文件下载到本地用 JMC 或者也可以用 async-profiler 转换./profiler.sh -f /tmp/executor.jfr -o collapsedJFR 的事件模型和 perf 采样不同它能采到线程阻塞、锁等待这些 async-profiler 看不到的东西。如果你的火焰图上 CPU 占用不高但任务还是慢大概率瓶颈在 off-CPU 的部分这时候 JFR 的价值就体现出来了。7. 生产环境实战案例排查一个 Executor CPU 跑满的问题7.1 问题现象与初步判断有一次我遇到一个 Spark SQL 任务某个 Stage 有 200 个 task每个 task 处理的数据量差不多但 Spark UI 上看 CPU 时间曲线executor 的 CPU 使用率一直维持在 90% 以上任务整体跑了快 50 分钟明显比预期慢。初步排查思路是CPU 跑满说明计算密集但不知道是业务计算还是 GC 在跑。先用火焰图看看。7.2 采样的具体操作我在其中一台 executor 节点上执行./profiler.sh -d 120 -o collapsed -f /tmp/spark_executor.collapsed PID采样 120 秒覆盖了任务的中段稳定运行期。这个过程对任务本身基本没有影响executor 的 CPU 占用只是增加了极少量采样开销。采样结束后迅速生成火焰图./flamegraph.pl /tmp/spark_executor.collapsed /tmp/spark_executor.svg打开 SVG 后首先注意到整个火焰图形状很高有大量嵌套调用。顶部出现了一个很宽的色块函数名类似ExpressionEvaluator.evaluate顺着父调用链一路往下经过GeneratedIterator、WholeStageCodegenExec最后回到了TaskRunner。7.3 定位根因看到WholeStageCodegenExec和GeneratedIterator其实是个重要线索。这说明任务走的是 Spark SQL 的 Codegen 路径也就是生成的 Java 代码在跑。问题在于ExpressionEvaluator这个调用占的时间太长了说明可能生成了非常低效的表达式计算代码。进一步看子调用发现里面大量时间花在字符串拼接连字符上。于是我去查了 SQL 语句果然发现一个字符串拼接的 UDF 在循环中对每一行做了String.format和多个字符串加号拼接且这个 UDF 没有被下推成 SQL 表达式而是作为一个黑盒 Python UDF 被调用的。这里有个关键经验如果你的 UDF 是 Python UDF火焰图上会看到很宽的 PySpark 相关调用比如PythonRDD。但这次是 Java UDF 包装在 SQL 表达式里。无论是哪种火焰图都能帮你确认 CPU 到底卡在哪个环节而不用靠猜。7.4 针对性优化与结果确认根因后优化思路很清晰把字符串拼接改成预先分配好大小的StringBuilder避免频繁创建中间字符串同时把可以下推的表达式直接改成 SQL 原生函数去掉 UDF 的包装开销。优化后重新跑同一个任务Stage 耗时从 50 分钟降到 17 分钟。再用差分火焰图对比优化前后可以看到ExpressionEvaluator对应的色块从红色变成了大片的蓝色节省掉的 CPU 时间一目了然。这个案例其实很典型任务慢CPU 跑满Spark UI 能看到的只是“CPU 很忙”但忙在哪里完全看不到。火焰图把这层窗户纸捅破了。8. 常见问题与排查技巧实录8.1 采样时报“Unable to open perf event”错误这个错误通常有两个原因一是权限不够二是perf_event_paranoid限制。解决办法按优先级排列确认你在用 root 用户执行 async-profiler检查/proc/sys/kernel/perf_event_paranoid的值如果为 2尝试用sysctl -w kernel.perf_event_paranoid1临时调整如果还不行改用 JFR 方式采样JFR 不需要 perf_event 权限注意第 2 点调整的是节点级别的内核参数生产环境改完记得改回来或者只在测试节点上操作。8.2 采样结果里全是底层 native 函数看不到 Java 栈这种情况通常发生在旧版 async-profiler 或者没有正确配置 JVMTI 的情况下。确保你用的是官网最新版同时确认执行profiler.sh时用的是和 executor 相同的 JVM 用户权限。还有一个坑有些发行版在编译 async-profiler 时没有开启对特定 JDK 的支持需要下载对应平台的 build。如果实在解决不了用 JFR 做备选方案JFR 还原 Java 调用栈的能力更强。8.3 火焰图出来了但全都是 GC 相关函数这本身就是一个重要的结论。如果火焰图上大面积是G1YoungCollector、ParallelGCThreads这些说明 CPU 时间大量消耗在垃圾回收上你的任务不是“计算慢”而是“内存分配太频繁”。这时候不要再去优化业务代码了先看这几个方面每个 task 处理的数据量是不是过大导致内存压力大是不是有 map 阶段产生了过多的中间对象比如频繁的字符串拼接、装箱拆箱executor 的spark.memory.fraction是不是设置得不合理导致 JVM heap 里留给 GC 的空间太少处理 GC 问题很多时候比优化业务代码见效更快。我之前遇到过一个任务火焰图上 GC 相关占了 40%把 executor 内存从 4G 调到 8G 之后GC 占比降到了 8%任务时间直接少了三分之一。8.4 多个 executor 节点该对哪个采样生产环境一般有几十上百个 executor 节点不可能每个都采。我的做法是先去 Spark UI 看每个 executor 的 CPU 时间分布挑 CPU 时间最高、且任务完成时间最长的那个节点采样。因为瓶颈节点通常是最有代表性的其他节点慢多半是负载不均导致的。如果多个 executor 的 CPU 时间都差不多高那就随机采两个对比。对比发现如果两个节点的火焰图热点一致说明是全局性问题比如 SQL 本身有低效算子不是某个节点独有。8.5 采样时任务刚好跑完了怎么办这是最尴尬的情况采样 120 秒任务在第 100 秒就结束了后面的采样全是空转。解决办法是缩短采样时长的同时提前观察任务状态。如果你在 YARN 上跑可以通过yarn application -status或者 Spark UI 实时看 Stage 进度估算剩余时间再开采样。我更推荐的做法是如果任务每个 Stage 时间比较稳定可以对这个 Stage 做一次预采样确认热点稳定后再做正式采样避免浪费一次采样窗口。9. 把 CPU Profile 变成日常习惯到这里完整的实操链路已经清楚了找 PID、装工具、采样、生成火焰图、解读、针对性优化。看起来步骤不少但熟练之后整个流程可以在 10 分钟内完成。我只强调三件事。第一采样时长宁短勿长覆盖任务稳定运行期就够了太长只会增加干扰信息。第二火焰图不是只看一张就完事结合差分图和线程级分析才能拿到更深的结论。第三火焰图只解决“CPU 在忙什么”这个问题如果你的瓶颈是 IO、网络或者内存请配合 JFR、Spark UI 的其他指标一起看。我个人的体会是在 Spark 性能调优这条路上火焰图不是万能的但没有它你就像一个在黑暗里摸钥匙的人知道钥匙在房间里却不知道往哪个方向找。每当我看到一个慢任务第一步永远是先看一眼它到底忙在哪儿。很多时候火焰图给我的第一个结论就足够让我节省一整天的排查时间。最后分享一个小技巧我会在集群的每台节点上提前装好 async-profiler 和 FlameGraph 脚本放到固定的路径下比如/opt/profiler/bin。这样每次遇到问题不用现场装工具直接就能开采样。这个习惯在线上问题排查时省下的时间远比装工具那点成本值钱。