资讯详情

资讯详情

用火焰图定位 Spark Executor 的 CPU 热点与性能瓶颈

如果你的一个 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 等待上回头一看全是epollWaitCPU 热点反而被淹没了。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-CPU6.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:DebugNonSafepointsDebugNonSafepoints会提供更多安全点之外的采样信息对火焰图质量提升很明显。如果还想让采样到的 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 一起存好。采样本身并不难难的是证据链齐全几天之后还能准确复现和对比。这套“先留证据、再采样子、最后看图”的顺序比任何单次优化都更值得养成。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →