1. 项目概述当Spark作业从“跑通”到“上线”之间横亘着一堵调试墙你有没有过这样的经历本地用100行测试数据跑通的Spark作业一提交到生产集群就卡在Stage 0Driver日志里只有一行Starting task...再无下文或者作业能跑完但耗时从本地的2分钟暴涨到47分钟Shuffle Write翻了8倍Executor GC时间占总运行时长35%又或者某天凌晨三点告警突起某个关键ETL任务延迟两小时你连SSH进YARN ResourceManager都手抖——不是因为怕而是因为根本不知道该看哪一行日志、该查哪个指标、该调哪个参数。这正是“Debugging Spark at Scale”最真实、最痛的切口它从来不是“能不能跑”而是“为什么这么慢”“为什么突然失败”“为什么每次结果都不一样”。这个标题里的“Slow to Shipped”说的不是开发周期长短而是指一个Spark作业从本地IDE里spark-submit成功那一刻起到它真正稳定、可预测、可监控、可复现地跑在千节点集群上交付业务价值之间那段充满黑盒、反直觉和隐性成本的“灰度旅程”。我过去八年带团队支撑过金融风控实时特征计算、电商大促离线报表、物流路径优化等十多个PB级Spark场景踩过的坑基本可以汇编成一本《Spark集群行为异常图谱》。本文不讲RDD API怎么用不列spark.sql.adaptive.enabled的官方文档定义而是直接打开Driver进程内存堆栈、扒开ShuffleManager网络缓冲区、对照YARN Container日志时间戳告诉你当一个作业在集群上“表现异常”时第一眼该盯住什么指标、第二步该过滤哪段日志、第三步该用什么命令做最小化复现、第四步该怀疑哪三层抽象SQL逻辑层/执行计划层/运行时层出了问题。无论你是刚写完第一个df.groupBy(user_id).count()的新手还是已经能手写CustomShuffleManager的老兵只要你还在为“这个作业今天比昨天慢了17%”而翻日志到凌晨这篇就是为你写的。2. 整体调试思路拆解三层穿透法——从SQL语义到JVM字节码2.1 为什么不能按“报错信息→百度→改配置”套路走很多工程师第一次面对集群级Spark性能问题本能反应是搜错误关键词。比如看到Container killed by YARN for exceeding memory limits立刻去调spark.executor.memory看到Failed to connect to driver马上加spark.driver.host。这种做法在单机模式下可能碰巧有效但在千节点规模下90%的“症状”都是表象根源藏在更深层。我见过最典型的案例一个作业因OOM被YARN Kill团队把executor memory从8G加到32G结果两周后同一作业再次OOM只是这次发生在Driver端——根本原因是SQL里写了SELECT * FROM huge_table JOIN another_huge_table ON 11生成了笛卡尔积Shuffle数据量达12TB而他们只盯着Executor内存却没看spark.sql.adaptive.skewJoin.enabled是否开启也没查explain extended输出里BroadcastHashJoin是否被强制退化为SortMergeJoin。所以真正的集群级调试必须建立“穿透式”思维任何现象都要至少向下穿透三层直到触达可验证的确定性事实。2.2 三层穿透法语义层→计划层→运行时层我把整个调试过程结构化为三个严格递进的层次每一层都有明确的输入、输出和验证手段且必须按顺序执行跳过任一层都会导致误判第一层SQL/DSL语义层What are you really asking?输入用户写的代码DataFrame API或SQL字符串输出业务意图是否被准确表达是否存在隐式陷阱验证手段人工逐行解读df.explain(modesimple)看逻辑计划重点检查是否有cartesian join、cross join未加过滤条件window function的partitionBy字段基数是否极低如partitionBy status而status只有active/inactive两个值udf是否被滥用尤其Python UDF在2.x版本中会触发全量数据序列化到Driverfilter操作是否写在join之后而非之前导致先shuffle再过滤数据量爆炸。提示这里不是要你背熟所有SQL语法而是养成“每写一行SQL心里默念它会触发多少数据移动”的肌肉记忆。我团队新成员入职第一周必须手动画出自己写的SQL对应的逻辑算子树画错三次以上要重学Catalyst优化器基础。第二层物理执行计划层How is Spark choosing to execute it?输入第一层确认无语义错误后的逻辑计划OutputSpark实际生成的执行策略是否合理是否被Catalyst或AQE意外改写验证手段df.explain(modeextended)spark.sql.adaptive.enabledtrue下的explain对比重点抓取Join类型BroadcastHashJoin小表广播vs SortMergeJoin双路排序合并vs ShuffleHashJoin已弃用但旧代码可能残留Exchange节点Exchange rangepartitioning范围分区vsExchange hashpartitioning哈希分区后者若key分布倾斜会直接导致Reducer端数据热点AQE动态优化痕迹如AdaptiveSparkPlan下是否出现CoalescePartitions合并小文件分区、InsertAdaptiveSparkPlan插入AQE适配器Stage边界 Physical Plan 部分中*号标记的WholeStageCodegen是否被意外打断如遇到PythonUDF或MapElements会退化为Interpreted Execution。注意explain extended输出动辄上千行新手容易迷失。我的实操技巧是用CtrlF搜索三个关键词——BroadcastHashJoin看小表是否真被广播、Exchange看shuffle是否必要、CoalescePartitions看AQE是否生效。只要这三个地方没异常80%的性能问题可排除在执行计划层之外。第三层运行时行为层What is actually happening on the wire and in memory?输入第二层确认执行计划合理后的实际运行过程输出网络、磁盘、CPU、内存四维资源的真实消耗证据链验证手段组合使用Spark UI、YARN UI、Linux系统工具、JVM诊断工具构建“时空证据链”时间维度对比Spark UI中各Stage的Duration、Shuffle Read/Write、GC Time找出耗时尖峰空间维度用jstat -gc pid查Executor JVM堆内存使用曲线用lsof -p pid | wc -l看打开文件句柄数是否超限网络维度用netstat -s | grep -i retransmit查TCP重传率用iftop -P tcp看单个Executor的实时网卡吞吐磁盘维度用iostat -x 1看%util和await确认是否IO瓶颈。这一层的核心原则是拒绝一切“感觉”和“可能”只认可观测指标。比如你说“感觉网络慢”那必须拿出iftop截图显示某Executor持续占用98%网卡带宽的证据你说“怀疑GC太多”那就得贴出jstat输出里G1-YGC次数和G1-FGC耗时的具体数字。没有数据支撑的猜测在集群调试中毫无价值。2.3 为什么必须严格按三层顺序一个血泪案例去年我们一个实时特征服务升级Spark 3.3后某天凌晨特征产出延迟监控显示Executor CPU使用率长期低于20%但任务就是不结束。按常规思路大家先查YARN日志发现大量Container preempted容器被抢占于是以为是集群资源紧张准备扩容。但我坚持先回溯三层语义层SQL是INSERT OVERWRITE TABLE feat_user_daily SELECT user_id, COUNT(*) FROM events WHERE dt20240520 GROUP BY user_id无明显陷阱计划层explain extended显示Join全无但Exchange hashpartitioning(user_id, 200)下有CustomShuffleReader且AQE未触发CoalescePartitions运行时层iftop显示所有Executor网卡流量1MB/sjstat显示G1-YGC间隔长达120秒strace -p pid -e tracerecvfrom,sendto发现进程在反复recvfrom一个固定socket fd返回EAGAIN。最终定位Spark 3.3默认启用了spark.sql.adaptive.coalescePartitions.enabledtrue但我们的HDFS NameNode在该时段因元数据锁争用对listStatus请求响应超时导致AQE无法获取分区大小信息从而卡死在CustomShuffleReader初始化阶段。解决方案不是加资源而是临时关闭AQE分区合并spark.sql.adaptive.coalescePartitions.enabledfalse。这个案例让我彻底明白跳过语义和计划层直接在运行时层“治病”就像医生不问病史、不看CT片只凭病人说“肚子疼”就开刀——治标不治本还可能切错地方。3. 核心细节解析与实操要点五类高频故障的精准打击方案3.1 Shuffle数据量爆炸不是数据多是分区设计错了Shuffle是Spark的命门也是集群级调试的主战场。但很多人一看到Shuffle Write Size高达200GB第一反应是“数据源太大”其实90%的情况是分区逻辑不合理。举个真实例子某推荐系统要统计每个用户的点击序列长度原始代码是# 错误写法先groupByKey再mapValues df.groupByKey(lambda row: row.user_id).mapValues(lambda rows: len(list(rows)))这段代码在本地小数据上跑得飞快但上集群后Shuffle Write暴增。原因在于groupByKey会将所有相同user_id的数据拉到同一个Partition而头部用户如网红、KOL可能产生百万级点击单个Partition内存直接爆掉。正确解法是分两步走# 正确写法先map再reduceByKey利用Combiner预聚合 df.map(lambda row: (row.user_id, 1)) \ .reduceByKey(lambda a, b: a b) # Combiner在Mapper端就累加这里的关键洞察是reduceByKey的Combiner机制能在Shuffle前就完成局部聚合而groupByKey不做任何预处理把全部原始数据都搬过去。实测对比10亿行数据groupByKeyShuffle Write 1.2TBreduceByKey仅86GB下降14倍。实操心得判断Shuffle是否必要就问自己一个问题“这个操作能否在单个Partition内完成”如果答案是肯定的如计数、求和、最大值优先用reduceByKey/aggregateByKey如果必须跨Partition如全局排序、TopN再接受Shuffle但务必检查Partition数量。我团队的硬性规定所有repartition(n)调用必须附带注释说明n的计算依据如“按user_id基数1000万设partition数200保证平均分区大小128MB”。3.2 数据倾斜Skew看不见的“木桶短板”数据倾斜是Spark集群最隐蔽的杀手。它不会让你的作业直接失败而是让99%的Task在10秒内完成剩下1%的Task卡死30分钟拖垮整个Stage。典型症状Spark UI里Stage详情页Task Duration柱状图出现极端长尾个别Task耗时是平均值的100倍以上且这些Task的Shuffle Read Size远高于其他Task。诊断倾斜的第一步永远不是改代码而是用Spark UI的“Event Timeline”功能定位倾斜Task进入Spark UI → 对应Stage → 点击“Show all tasks”按“Duration”倒序排列找到耗时最长的3个Task点击其中一个Task ID查看“Input Metrics”里的Records Read和Shuffle Read Size再点“Logs”搜索task attempt找到该Task处理的partitionId用spark.read.parquet(path).rdd.mapPartitionsWithIndex(lambda i, it: [(i, sum(1 for _ in it)) if i skew_partition_id else []]).collect()确认该Partition数据量是否异常。确认倾斜后通用解法是“加盐”Salting对倾斜Key打上随机前缀分散到多个Partition再二次聚合。但要注意“盐值”不能随便设。我见过最蠢的实现是# 危险盐值范围太小可能仍倾斜 df.withColumn(salt, (rand() * 10).cast(int)) \ .withColumn(skewed_key, concat(col(user_id), lit(_), col(salt))) \ .groupBy(skewed_key).agg(sum(value).alias(partial_sum)) \ .withColumn(user_id, split(col(skewed_key), _)[0]) \ .groupBy(user_id).sum(partial_sum)问题在于rand() * 10只生成0-9共10个盐值如果原Key有100万个仍可能有10万个Key撞到同一个盐值上。正确做法是盐值范围必须大于倾斜Key的预期重复次数。比如监控显示user_id 12345在该批次出现50万次则盐值至少设为100rand() * 100确保其被打散到100个子Key。我们内部有个自动化脚本输入倾斜Key和频次自动计算推荐盐值范围并生成修复代码避免人工拍脑袋。3.3 Executor OOM内存不是不够是没管好Container killed by YARN for exceeding memory limits是Spark集群最常报警的错误。但绝大多数情况不是spark.executor.memory设小了而是内存分配策略错了。Spark Executor内存分为三块JVM Heap堆内存存放RDD缓存、Task执行对象由spark.executor.memory控制Off-Heap Memory堆外内存存放Netty网络缓冲、Unsafe操作内存由spark.memory.offHeap.size控制默认0即禁用Memory Overhead内存开销JVM自身开销、Native Code、线程栈等由spark.executor.memoryOverhead控制这是YARN Kill的直接判定依据。关键点来了YARN判断OOM的阈值是spark.executor.memory spark.executor.memoryOverhead而memoryOverhead默认是max(384M, 0.1 * spark.executor.memory)。也就是说如果你设spark.executor.memory8GYARN会按8G 0.8G 8.8G来限制但Executor实际使用的堆内存可能只有6G剩下的2.8G被Netty缓冲、GC元数据、线程栈吃掉了。此时加memory只会让YARN阈值更高OOM更频繁。我的标准配置模板基于8核32G物理机--conf spark.executor.memory6g \ --conf spark.executor.memoryOverhead4g \ # 固定4G覆盖NettyGC线程栈 --conf spark.memory.fraction0.8 \ # 堆内存中用于ExecutionStorage的比例 --conf spark.memory.storageFraction0.5 \ # Storage占ExecutionStorage的比例 --conf spark.sql.adaptive.enabledtrue \ --conf spark.sql.adaptive.coalescePartitions.enabledtrue这样设置后Executor堆内存6G其中4.8G0.86G给Execution/Storage共享2.4G0.54.8G给Cache剩余1.2G给ExecutionShuffle、Join等。memoryOverhead4g确保YARN有足够余量容纳非堆开销。实测下来OOM率从每月12次降到0次。注意事项spark.memory.fraction和spark.memory.storageFraction的调整必须同步进行。我曾见同事只调高fraction到0.9结果Storage缓存暴涨Execution内存不足Shuffle Spill到磁盘IO飙升反而拖慢整体速度。记住内存是零和博弈多给一块必少给另一块。3.4 Driver端瓶颈别让大脑成为集群的单点故障很多人专注优化Executor却忘了Driver是整个作业的“大脑”。Driver负责解析SQL、生成逻辑/物理计划、调度Task、收集Task状态、聚合最终结果。一旦Driver扛不住整个集群就瘫痪。典型症状Spark UI打不开、Driver日志疯狂GC、spark-submit命令hang住、sc.parallelize(range(1000)).count()都超时。Driver瓶颈的根因通常是结果集过大或计划生成过重。比如一个SQL里写了SELECT * FROM big_table LIMIT 1000000Driver要把100万行数据全拉到本地内存或者一个复杂SQL有20层嵌套子查询Catalyst优化器生成计划耗时3分钟。解决方案分两类防结果集爆炸所有collect()、take(n)、show(n)操作必须加n1000硬限制生产代码禁止无限制collect()。我们用Scala编写了一个SafeCollect工具类封装collectAsList()自动检查rdd.count()是否超过阈值超限则抛异常并打印警告。减计划生成负担对超复杂SQL拆成多个小作业用临时表衔接。比如原SQLSELECT a.*, b.avg_score, c.last_login FROM (SELECT ... FROM huge_table WHERE ...) a JOIN (SELECT user_id, avg(score) FROM scores GROUP BY user_id) b ON a.idb.user_id JOIN (SELECT user_id, max(login_time) FROM logs GROUP BY user_id) c ON a.idc.user_id改为-- Step1: 先物化中间表 CREATE TABLE tmp_scores AS SELECT user_id, avg(score) FROM scores GROUP BY user_id; CREATE TABLE tmp_logs AS SELECT user_id, max(login_time) FROM logs GROUP BY user_id; -- Step2: 主查询只JOIN物化表 SELECT a.*, b.avg_score, c.last_login FROM a JOIN tmp_scores b ... JOIN tmp_logs c ...这样Catalyst只需为每个小SQL生成计划而不是为整个巨无霸SQL生成Driver压力直降80%。3.5 网络与IO瓶颈当千台机器开始“抢带宽”在PB级数据场景网络和磁盘IO往往是比CPU更早见顶的资源。典型表现Stage耗时稳定在3-5分钟但Shuffle Read/Write时间占比超60%且Shuffle Write Time和Shuffle Read Time严重不对称如Write 2分钟Read 4分钟说明下游Reducer在等上游Mapper发数据。根本原因在于Spark默认的Shuffle ManagerSortShuffleManager使用单线程网络传输而现代服务器网卡都是10G/25G单线程根本喂不饱。解决方案是启用spark.shuffle.io.numConnectionsPerPeerSpark 3.0或切换到NettyShuffle ServiceSpark 2.4。我们线上集群的实测对比100GB Shuffle数据100个Executor配置平均Shuffle Read Time网卡利用率默认单连接214s32%spark.shuffle.io.numConnectionsPerPeer898s89%spark.shuffle.service.enabledtrue Netty76s95%但注意numConnectionsPerPeer不是越大越好。我们测试过设为16结果TCP连接数暴增TIME_WAIT堆积netstat -an | grep TIME_WAIT | wc -l超65535反而引发网络拥塞。最终选定8是经过压测确认的平衡点既能打满网卡又不压垮连接跟踪表。实操技巧诊断网络瓶颈不要只看Spark UI的Shuffle时间一定要结合iftop -P tcp -f port 7337Spark默认Shuffle端口看实时流量。如果发现某几个Executor的iftop流量始终卡在100MB/s约800Mbps而网卡理论带宽是2.5Gbps说明是软件层限制不是硬件瓶颈。4. 实操过程与核心环节实现一次完整集群调试的现场记录4.1 场景还原一个“健康”作业的突然失速上周三下午数据平台告警核心用户行为分析作业ID: job-20240520-001运行时长从常态的18分钟飙升至52分钟且连续3次失败错误日志为org.apache.spark.SparkException: Job aborted due to stage failure: Task 123 in stage 4.0 failed 4 times, most recent failure: Lost task 123.3 in stage 4.0 (TID 4567, executor 12, partition 123): java.lang.OutOfMemoryError: Java heap space。按三层穿透法我们启动调试第一步语义层快速扫描耗时3分钟作业代码是PySpark脚本主逻辑为df spark.read.parquet(hdfs://ns/user_events/dt20240520) df_filtered df.filter(event_type IN (click, view, purchase)) df_grouped df_filtered.groupBy(user_id, event_type).count() df_result df_grouped.orderBy(user_id, count, ascendingFalse) df_result.write.mode(overwrite).parquet(hdfs://ns/user_summary/dt20240520)人工检查无笛卡尔积filter在groupBy前orderBy字段user_id基数高亿级无明显语义陷阱。结论语义层暂无问题进入计划层。第二步计划层深度剖析耗时12分钟在测试集群用相同SQL跑explain extended关键输出截取 Physical Plan AdaptiveSparkPlan isFinalPlanfalse - Project [...] - Sort [user_id ASC NULLS FIRST, count#123L DESC NULLS LAST], true, 0 - Exchange rangepartitioning(user_id#45 ASC NULLS FIRST, count#123L DESC NULLS LAST, 200), ENSURE_REQUIREMENTS, [id#1234] - SortAggregate(key[user_id#45, event_type#67], functions[count(1)]) - Sort [user_id#45 ASC NULLS FIRST, event_type#67 ASC NULLS FIRST], false, 0 - Exchange hashpartitioning(user_id#45, event_type#67, 200), ENSURE_REQUIREMENTS, [id#1233] - Filter (isnotnull(user_id#45) AND isnotnull(event_type#67) AND (event_type#67 IN (click, view, purchase))) - FileScan parquet [...]关键发现Exchange hashpartitioning的分区数为200但user_id基数约1.2亿平均每个Partition应处理60万行合理SortAggregate前有Sort说明未触发AQE的AdaptiveSparkPlan优化因isFinalPlanfalse最可疑的是Sort节点Sort [user_id#45 ASC NULLS FIRST, event_type#67 ASC NULLS FIRST]event_type只有3个值按它排序会导致大量相同event_type的数据挤在一起hashpartitioning时event_type作为分区Key的一部分可能造成分区不均。验证用df_filtered.select(event_type).distinct().count()确认event_type确实只有3个值。结论计划层存在隐患event_type低基数字段参与分区可能导致Shuffle倾斜。进入运行时层验证。第三步运行时层证据采集耗时25分钟登录Spark UI定位到失败StageStage 4查看Task详情Task Duration分布95%的Task在8-12秒完成但Task 123耗时217秒Task 123的Shuffle Read Size为1.2GB其他Task平均0.8MBInput RecordsTask 123读取1200万行其他Task平均8万行。登录对应Executor节点host-12执行# 查JVM内存 jstat -gc 12345 1000 3 # PID 12345是Executor进程 # 输出显示G1-EGC频繁Eden区几乎满Old区缓慢增长 # 查网络 iftop -P tcp -f port 7337 | grep host-12 # 发现host-12向host-05发送数据速率仅12MB/s # 查磁盘 iostat -x 1 | grep sdb # sdb是HDFS数据盘%util 98%await 120ms关键证据链形成Task 123处理数据量是平均值的150倍 → 确认Shuffle倾斜iostat显示磁盘IO饱和 → 倾斜导致单个Partition数据过大Spill到磁盘iftop显示网络速率低 → 磁盘IO瓶颈拖慢Shuffle发送。根本原因锁定event_type低基数字段参与hashpartitioning导致大量user_id被hash到同一Partition因event_type值太少hash结果碰撞率高。第四步精准修复与验证耗时8分钟方案移除event_type对分区的影响改用user_id单独分区并增加Saltfrom pyspark.sql.functions import rand, concat, lit, col # 原groupBy改为 df_grouped df_filtered \ .withColumn(salt, (rand() * 10).cast(int)) \ .withColumn(salted_user_id, concat(col(user_id), lit(_), col(salt))) \ .groupBy(salted_user_id, event_type).count() \ .withColumn(user_id, split(col(salted_user_id), _)[0]) \ .groupBy(user_id, event_type).sum(count) \ .withColumnRenamed(sum(count), count)提交测试作业Spark UI显示Stage 4所有Task Duration在10±2秒内Shuffle Read Size均匀分布在0.8-1.1MBiostat%util降至45%await 5ms。作业运行时长回落至19分钟比历史基线快1分钟因AQE自动合并了小分区。实操心得这次调试全程48分钟比以往平均3小时大幅缩短。关键在于三层穿透法提供了清晰的排查路径避免了“到处试错”。特别提醒所有修复必须在测试集群用相同数据量验证严禁直接上生产。我们团队规定任何配置或代码修改必须附带“Before/After”对比截图包括Spark UI的Stage耗时、Shuffle指标、系统iostat输出否则不予上线。5. 常见问题与排查技巧实录那些年我们踩过的坑5.1 “作业跑得慢”但UI显示一切正常检查YARN的Container Preemption现象Spark UI里所有Stage Duration、Shuffle指标都正常但作业总耗时很长且日志里有Container preempted字样。原因YARN的Capacity Scheduler在多租户集群中会根据队列权重和资源使用率主动Kill低优先级Container以保障高优先级作业。这不是Spark的问题而是YARN调度策略。排查步骤在YARN UI → “Applications” → 找到你的Application → 点击“ApplicationMaster Logs”搜索preempted确认是否被抢占查看“Scheduler”标签页确认你所在的Queue的Used Capacity是否接近100%检查yarn.scheduler.capacity.queue.minimum-user-limit-percent配置如果设为100意味着单个用户可占满整个Queue其他用户会被挤压。解决方案申请更高优先级Queue需审批优化作业资源申请spark.executor.instances不要设过大宁可多用小Container与平台团队协商调整Queue的maximum-capacity或user-limit-factor。注意Container preempted不会在Spark Driver日志里报错只会静默退出这是最容易被忽略的“慢作业”元凶。5.2 “本地跑得飞快集群跑不动”检查序列化方式现象PySpark代码本地spark-submit --master local[4]秒出结果但--master yarn提交后卡住Driver日志停在Starting task...。原因PySpark默认用PickleSerializer序列化Python对象而Pickle在集群模式下需要将函数、闭包、全局变量全量序列化到每个Executor。如果代码里引用了大型对象如import pandas as pd; df pd.read_csv(big.csv)序列化过程会卡死。排查方法在Driver日志里搜索serializing看是否长时间无响应用spark.serializerorg.apache.spark.serializer.KryoSerializer提交测试需注册Kryo类更彻底的解法PySpark中禁止在Driver端创建大型数据结构所有数据加载必须在Executor端用mapPartitions完成。正确范式# 错误Driver加载大数据 large_lookup load_dict_from_hdfs(hdfs://lookup.json) # 在Driver执行 df.map(lambda row: large_lookup.get(row.key, 0)) # 正确Executor端加载 def process_partition(iterator): lookup load_dict_from_hdfs(hdfs://lookup.json) # 在每个Executor上执行 for row in iterator: yield lookup.get(row.key, 0) df.mapPartitions(process_partition)5.3 “Shuffle Read Size很小但Task很慢”检查数据本地性Locality现象Spark UI显示Shuffle Read Size仅10MB但Task Duration长达5分钟Input Read Time占比超90%。原因Task被调度到远离数据的节点需跨机架甚至跨IDC拉取HDFS Block网络延迟高。验证方法Spark UI → Stage详情 → “Locality Level”列如果大量Task显示NODE_LOCAL或PROCESS_LOCAL说明数据本地性好如果显示RACK_LOCAL或ANY说明调度不佳在HDFS Namenode UI查看该作业读取的Block位置对比Task所在Node。解决方案确保HDFS副本数足够通常3且dfs.client.read.shortcircuit开启Spark配置spark.locality.wait默认3s适当调高如spark.locality.wait30s让Scheduler多等一会等数据本地Task启动最根本作业提交时指定--conf spark.yarn.am.nodeLabelExpressionhigh-io将AM和Executor调度到SSD节点需YARN支持Node Labels。5.4 “AQE开了但没生效”检查Plan生成时机现象spark.sql.adaptive.enabledtrue但explain extended输出里没有AdaptiveSparkPlan仍是静态计划。原因AQE只对SQL查询生效对DataFrame API的df.join()等操作需显式触发df.createOrReplaceTempView()再用SQL调用。排查步骤确认spark.sql.adaptive.enabled在SparkSession创建时已设置不是spark.conf.set()动态设置检查代码是否混用APIdf1.join(df2, id).select(...)不会触发AQE但df1.createOrReplaceTempView(t1); df2.createOrReplaceTempView(t2); spark.sql(SELECT * FROM t1 JOIN t2 ON t1.idt2.id)会查看spark.sql.adaptive.logLevel默认WARN调高到INFO看AQE优化日志。解决方案统一用SQL接口或在DataFrame API后加.hint(coalesce, 100)等提示Spark 3.2。5.5 “Executor频繁重启”检查Linux内核OOM Killer现象