1. 项目概述:为什么Spring Boot要和Spark on Yarn“搭伙”?
在某公司做实时推荐系统升级时,我接手了一个典型的数据服务场景:前端用户行为日志通过Kafka持续流入,后端需要每5分钟拉取最新窗口数据,跑一次协同过滤模型,把生成的Top20推荐结果写入Redis供API调用。最初用纯Spring Boot + JDBC轮询MySQL做离线计算,单次耗时38秒,延迟高、资源独占、无法横向扩展——当日活从2万涨到8万时,任务开始频繁超时,DB连接池直接打满。
这时候,“Spring Boot整合Spark,基于Yarn运行提交Spark任务”就不是一句技术口号,而是必须落地的生产解法。它本质是把业务服务层(Spring Boot)和大数据计算层(Spark on Yarn)解耦又贯通:Spring Boot不再自己扛计算,只负责触发、传参、查状态、取结果;真正的数据清洗、特征工程、模型训练全部交给Yarn集群上的Spark Executor并行执行。我实测过,同样数据量下,Spark on Yarn任务平均耗时压到4.2秒,资源利用率提升3.7倍,且能随业务增长动态扩缩Worker节点——这才是企业级数据服务该有的弹性底座。
这个组合特别适合三类人:一是Java后端工程师想快速接入大数据能力,不想从头学Scala或PySpark;二是数据平台团队需要为业务方提供“点按钮即运行”的分析接口;三是中小团队没有专职大数据运维,但又得支撑准实时计算需求。它不追求替代专业数仓或流式引擎,而是用最小学习成本,在Spring生态里稳稳接住Spark这颗“重型计算弹药”。关键词“spring boot整合spark”“spark on yarn”背后,其实是Java系开发者对“可控、可维护、可监控”的工程化诉求,而不是炫技式的大数据堆砌。
2. 整体架构设计与方案选型逻辑
2.1 为什么放弃Local模式和Standalone模式,死磕Yarn?
刚接触时我也试过Spark Local模式:在Spring Boot里new SparkSession.builder().master("local[*]"),代码极简,本地调试飞快。但一上测试环境就崩——某次处理10GB用户画像数据,JVM直接OOM,日志里全是GC overhead limit exceeded。根本原因在于Local模式把Driver和Executor全塞进一个JVM进程,计算资源被Spring Boot应用本身吃掉大半,留给Spark的内存捉襟见肘。
接着切到Standalone模式:自己搭Spark Master/Worker集群。问题转向运维黑洞——Worker节点挂了没人告警,Executor日志分散在各节点磁盘上,排查一次Shuffle失败得连SSH翻四台机器。更致命的是,它和公司已有的Yarn资源调度器冲突,HDFS权限体系也得单独配一套,等于重复造轮子。
最终选定Yarn模式,核心逻辑就三点:
第一,资源复用不打架。Yarn作为统一资源调度器,能把Spark、Flink、MapReduce甚至自定义服务都纳管进来。我们集群里Spark占60%内存配额,Flink占30%,剩下10%留给临时ETL脚本,所有任务按需申请Container,彻底告别“抢资源”内耗。
第二,日志和监控有根可循。Yarn Web UI(默认端口8088)里能直接看到每个Spark Application的ApplicationMaster状态、Container列表、Stderr日志链接;配合ELK收集NodeManager日志,任何Executor OOM都能秒定位到具体Container ID和堆栈。
第三,安全合规有兜底。Yarn天然支持Kerberos认证和HDFS ACL,我们只需在Spring Boot配置里加几行principal和keytab路径,Spark任务就能以指定用户身份读写HDFS,审计日志自动记录操作者、时间、路径——这对金融类业务是硬性要求。
提示:别信“Yarn配置太重”的说法。我们线上集群用CDH 6.3.2,Yarn相关配置文件就yarn-site.xml和core-site.xml两份,加起来不到200行。真正要花时间的是理解Yarn的Resource Model(vcores/memory per container),这决定了你的Spark任务能分到多少算力。
2.2 Spring Boot整合Spark的三种姿势,为什么选“客户端模式”?
整合方式无非三种:
- 嵌入式模式:Spring Boot启动时初始化SparkContext,整个应用生命周期内复用。看似省事,实则埋雷——SparkContext是重量级对象,Shutdown不干净会导致线程泄漏;多线程并发提交任务时,Driver端容易出现“Task not serializable”异常(因闭包捕获了Spring Bean)。
- 服务化模式:把Spark封装成独立微服务(如用Flask暴露REST API),Spring Boot通过HTTP调用。解耦彻底,但引入网络开销和额外运维点,5分钟级任务的延迟从毫秒升到秒级,不符合我们SLA要求。
- 客户端模式(Client Mode):Spring Boot作为纯粹的“任务发起者”,每次提交时动态构建spark-submit命令,由Yarn分配ApplicationMaster,Spark Driver运行在提交节点(即Spring Boot服务器),Executor在Yarn Worker上启动。这是我们最终选择,理由很实在:
- 调试友好:Driver日志直接输出到Spring Boot控制台,断点调试Spark代码毫无障碍;
- 资源隔离:每个任务Driver内存独立配置(--driver-memory),不会互相影响;
- 故障收敛快:某个任务Driver崩溃,只影响本次提交,不影响Spring Boot主进程和其他任务。
注意:Client模式要求Spring Boot服务器必须安装Spark客户端(spark-submit脚本)、Hadoop客户端(hadoop、hdfs命令)、以及完整的Hadoop配置文件(core-site.xml, hdfs-site.xml, yarn-site.xml)。别指望只拷几个jar包就能跑通——Yarn通信、HDFS认证、Kerberos票据续期全靠这些二进制和配置驱动。
2.3 为什么不用Spark Thrift Server或Livy?直连Yarn更可靠
社区常提的Livy(Spark REST Server)和Thrift Server(JDBC接口)看似更“标准”,但我们实测后主动弃用。Livy的问题在于:它本质是个代理层,Spring Boot发HTTP请求给Livy,Livy再调spark-submit。中间多一层,故障点翻倍——Livy进程挂了、Livy和Yarn网络不通、Livy配置的spark-home路径错误,都会导致任务提交失败,而错误日志全在Livy端,Spring Boot只能收到个500错误,排查链路拉长3倍。
Thrift Server更麻烦:它长期运行一个SparkSession,所有SQL查询共享同一个Driver。我们曾遇到一个慢查询占满Driver线程池,后续所有请求排队等待,监控看CPU很低但QPS暴跌。而且Thrift Server不支持Spark 3.x的动态资源分配(Dynamic Allocation),Worker节点无法根据负载自动伸缩。
最终坚持“Spring Boot → spark-submit → Yarn”最短路径。虽然要手写命令拼接逻辑,但好处是:
- 每次提交都是全新Application,资源干净;
- 错误信息直达源头(Yarn RM日志或ApplicationMaster日志);
- 参数可精细控制(比如对不同任务设置不同--executor-cores,避免小任务浪费资源)。
3. 核心细节解析与实操要点
3.1 环境准备:Spring Boot服务器不是“裸机”,必须预装四大件
很多教程跳过这步直接写代码,结果90%的人卡在环境配置。Spring Boot服务器(我们叫submit-node)必须满足四个硬性条件:
第一,Hadoop客户端完整安装。不能只放hadoop-common.jar,必须下载对应Hadoop版本的二进制包(如hadoop-3.2.1.tar.gz),解压后配置HADOOP_HOME,并把$HADOOP_HOME/bin加入PATH。验证命令:hadoop version输出版本号,hdfs dfs -ls /能列出HDFS根目录。缺这步,spark-submit会报“Failed to locate the winutils binary”(Windows)或“No FileSystem for scheme: hdfs”(Linux)。
第二,Spark客户端精准匹配。Spark版本必须和Yarn集群的Spark版本严格一致。我们集群用Spark 3.2.1,submit-node就必须装同版本。重点检查$SPARK_HOME/conf/spark-defaults.conf里的spark.yarn.jars配置——它指向Yarn上Spark依赖jar包的HDFS路径(如hdfs://nameservice1/spark-jars/*)。如果路径错,Yarn会提示“ClassNotFound in Spark assembly”。
第三,Kerberos认证凭证到位。开启Kerberos后,submit-node需有合法principal(如spark-submit/submit-node@EXAMPLE.COM)和对应keytab文件。关键操作:
# 生成票据缓存 kinit -kt /etc/security/keytabs/spark-submit.keytab spark-submit/submit-node@EXAMPLE.COM # 验证票据是否有效(有效期24小时) klistSpring Boot代码里通过System.setProperty("java.security.krb5.conf", "/etc/krb5.conf")指定Kerberos配置。
第四,网络策略白名单。submit-node必须能直连Yarn ResourceManager(8032端口)、NodeManager(8042端口)、HDFS NameNode(8020端口)。我们曾因安全组没放开8032,spark-submit卡在“Connecting to ResourceManager”长达5分钟才超时。
实操心得:我写了个check-env.sh脚本,每次部署新submit-node都先跑一遍:检测hadoop/spark命令、klist票据、telnet端口、hdfs ls权限。10行shell解决80%环境问题,比看日志猜错因快10倍。
3.2 Spark任务打包:Jar包里到底该放什么,不该放什么?
这是最容易踩坑的环节。Spark任务本质是Java/Scala程序,但打包规则和普通Spring Boot Jar截然不同。
必须包含的内容:
- 业务逻辑代码(如UserRecJob.class);
- 所有非provided范围的依赖jar(如commons-lang3、fastjson);
- Spark SQL的DataFrame操作代码(注意:不要打包spark-sql_2.12,Yarn集群已提供);
- Hadoop配置文件(core-site.xml等),确保任务能正确访问HDFS。
必须排除的内容:
- Spark核心jar:spark-core_2.12、spark-sql_2.12等。Yarn集群的Spark版本已预装,重复打包会导致Classloader冲突,常见报错:“java.lang.NoSuchMethodError: org.apache.spark.sql.SparkSession.builder()”。
- Hadoop核心jar:hadoop-client、hadoop-common等。同理,集群Hadoop版本已固定,本地打包会覆盖Yarn的Hadoop类。
- Log4j桥接器:slf4j-log4j12、log4j-over-slf4j。Spark自带log4j,桥接器会引发日志框架死锁,任务卡在“Setting up executor log4j”不动。
Maven打包插件配置实录(pom.xml关键段):
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.2.4</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <!-- 排除Spark和Hadoop核心jar --> <filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> </excludes> </filter> </filters> <artifactSet> <excludes> <exclude>org.apache.spark:spark-core_2.12</exclude> <exclude>org.apache.spark:spark-sql_2.12</exclude> <exclude>org.apache.hadoop:hadoop-client</exclude> <exclude>org.apache.hadoop:hadoop-common</exclude> <exclude>org.slf4j:slf4j-log4j12</exclude> <exclude>log4j:log4j</exclude> </excludes> </artifactSet> <!-- 主类指定 --> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.job.UserRecJob</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin>注意:shade插件生成的fat jar命名要带版本号(如rec-job-1.0.0.jar),方便Yarn UI识别。我们约定所有任务jar上传到HDFS固定路径:/spark-jobs/prod/rec-job-1.0.0.jar,Spring Boot提交时直接引用此HDFS路径。
3.3 Spring Boot任务提交模块:不是调API,而是拼命令
Spring Boot不提供“开箱即用”的Spark提交SDK,本质是用Java ProcessBuilder执行spark-submit命令。很多人用RestTemplate调Livy,反而绕远路。
核心代码结构(UserRecService.java):
public class UserRecService { // spark-submit绝对路径(避免PATH环境变量问题) private static final String SPARK_SUBMIT_PATH = "/opt/spark/bin/spark-submit"; // Yarn集群配置 private static final String HADOOP_CONF_DIR = "/etc/hadoop/conf"; private static final String SPARK_CONF_DIR = "/opt/spark/conf"; public String submitRecJob(String userId, int topN) throws IOException, InterruptedException { // 构建spark-submit命令参数列表 List<String> command = new ArrayList<>(); command.add(SPARK_SUBMIT_PATH); // 基础配置 command.add("--master"); command.add("yarn"); command.add("--deploy-mode"); command.add("client"); command.add("--conf"); command.add("spark.yarn.appMasterEnv.PYSPARK_PYTHON=python3"); command.add("--conf"); command.add("spark.yarn.appMasterEnv.PYSPARK_DRIVER_PYTHON=python3"); // 资源参数(根据任务类型动态调整) command.add("--driver-memory"); command.add("2g"); command.add("--executor-memory"); command.add("4g"); command.add("--executor-cores"); command.add("2"); command.add("--num-executors"); command.add("4"); // 依赖jar(HDFS路径) command.add("--jars"); command.add("hdfs://nameservice1/spark-jobs/lib/mysql-connector-java-8.0.26.jar"); // 主jar和参数 command.add("--class"); command.add("com.example.job.UserRecJob"); command.add("hdfs://nameservice1/spark-jobs/prod/rec-job-1.0.0.jar"); command.add(userId); // 传递给main方法的第一个参数 command.add(String.valueOf(topN)); // 第二个参数 // 启动进程 ProcessBuilder pb = new ProcessBuilder(command); pb.environment().put("HADOOP_CONF_DIR", HADOOP_CONF_DIR); pb.environment().put("SPARK_CONF_DIR", SPARK_CONF_DIR); pb.redirectErrorStream(true); // 合并stdout/stderr便于日志采集 Process process = pb.start(); BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream())); // 实时读取日志(用于前端WebSocket推送) String line; while ((line = reader.readLine()) != null) { log.info("[SparkLog] {}", line); // 推送日志到前端 websocketService.sendLog(line); } int exitCode = process.waitFor(); if (exitCode == 0) { return "SUCCESS"; } else { throw new RuntimeException("Spark job failed with exit code: " + exitCode); } } }关键细节说明:
- 环境变量注入:
pb.environment().put("HADOOP_CONF_DIR", ...)必须显式设置,否则spark-submit找不到HDFS配置; - 参数顺序敏感:
--jars必须在--class之前,否则Yarn会忽略依赖; - HDFS路径必须完整:
hdfs://nameservice1/...不能简写为/spark-jobs/...,否则报“File does not exist”; - 日志实时捕获:
redirectErrorStream(true)让stderr合并到stdout,避免日志丢失,这对前端展示任务进度至关重要。
实操心得:我给每个任务加了唯一traceId,拼在spark-submit命令最后(如
--conf spark.app.traceId=abc123),这样Yarn UI里一眼就能关联到Spring Boot的请求日志,排查问题时不用跨系统对时间戳。
4. 实操过程与核心环节实现
4.1 从零搭建Yarn集群验证环境(CDH 6.3.2精简版)
生产环境用CDH,但开发调试用伪分布式Yarn足够。我在一台16G内存的CentOS 7虚拟机上搭建,全程30分钟:
步骤1:安装JDK 1.8和Hadoop 3.2.1
# 下载hadoop-3.2.1.tar.gz,解压到/opt/hadoop tar -zxvf hadoop-3.2.1.tar.gz -C /opt/ # 配置环境变量(/etc/profile) export HADOOP_HOME=/opt/hadoop export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin # 格式化NameNode hdfs namenode -format # 启动HDFS start-dfs.sh # 验证:hdfs dfs -ls / 应返回Found 1 items步骤2:配置Yarn(关键四文件)
etc/hadoop/core-site.xml:指定HDFS地址<property> <name>fs.defaultFS</name> <value>hdfs://localhost:9000</value> </property>etc/hadoop/hdfs-site.xml:关闭权限检查(开发用)<property> <name>dfs.permissions.enabled</name> <value>false</value> </property>etc/hadoop/yarn-site.xml:启用Yarn<property> <name>yarn.resourcemanager.hostname</name> <value>localhost</value> </property> <property> <name>yarn.nodemanager.aux-services</name> <value>mapreduce_shuffle</value> </property>etc/hadoop/mapred-site.xml:指定MapReduce框架<property> <name>mapreduce.framework.name</name> <value>yarn</value> </property>
步骤3:启动Yarn并验证
# 启动Yarn start-yarn.sh # 检查进程:jps应看到ResourceManager、NodeManager # 访问Yarn Web UI:http://localhost:8088,能看到Nodes页显示1个Active Node # 运行官方示例验证 hadoop jar $HADOOP_HOME/share/hadoop/mapreduce/hadoop-mapreduce-examples-3.2.1.jar wordcount /input /output注意:伪分布式模式下,Yarn的ApplicationMaster和Executor都在本机启动,内存要预留充足。我给虚拟机分配12G内存,Yarn配置中
yarn.nodemanager.resource.memory-mb设为8192,避免Container被Kill。
4.2 Spark任务开发:一个真实可用的协同过滤推荐Job
以UserCF(基于用户的协同过滤)为例,代码必须满足三个生产要求:可参数化、可中断、可监控。
Job入口(UserRecJob.scala):
object UserRecJob extends App { // 从命令行获取参数(userId, topN) if (args.length < 2) { println("Usage: UserRecJob <userId> <topN>") System.exit(1) } val userId = args(0) val topN = args(1).toInt // 初始化SparkSession(Yarn模式自动适配) val spark = SparkSession.builder() .appName(s"UserRecJob-$userId") .getOrCreate() import spark.implicits._ try { // 1. 读取用户行为日志(Parquet格式,分区表) val actionDF = spark.read .parquet("hdfs://localhost:9000/data/actions/") .filter($"userId" =!= userId) // 排除目标用户自身 .filter($"actionType" === "click" || $"actionType" === "buy") // 2. 计算用户相似度(余弦相似度简化版) val userSimDF = actionDF .groupBy("userId") .agg(collect_list("itemId").as("items")) .withColumn("itemSet", array_distinct($"items")) .select($"userId", size($"itemSet").as("itemCount"), $"itemSet") .filter($"itemCount" > 5) // 过滤活跃用户 .crossJoin( // 自连接求交集 actionDF.groupBy("userId").agg(collect_list("itemId").as("items")) .withColumn("itemSet", array_distinct($"items")) .select($"userId".as("otherUserId"), $"itemSet".as("otherItemSet")) ) .withColumn("commonItems", size(array_intersect($"itemSet", $"otherItemSet"))) .withColumn("similarity", when($"commonItems" > 0, $"commonItems" / (sqrt($"itemCount") * sqrt(size($"otherItemSet")))) .otherwise(0.0)) .filter($"similarity" > 0.1) // 相似度阈值 .orderBy(desc("similarity")) .limit(topN) // 3. 获取相似用户喜欢的物品(去重+排序) val recItems = userSimDF .join(actionDF, $"otherUserId" === $"userId", "inner") .filter($"actionType" === "buy") .select($"itemId", $"similarity") .groupBy($"itemId") .agg(sum($"similarity").as("score")) .orderBy(desc("score")) .limit(topN) .rdd.map(row => row.getString(0)).collect() // 4. 写入Redis(通过Jedis) val jedis = new Jedis("redis-host", 6379) jedis.setex(s"rec:$userId", 3600, recItems.mkString(",")) jedis.close() println(s"User $userId recommendation completed: ${recItems.length} items") } catch { case e: Exception => println(s"Job failed: ${e.getMessage}") throw e } finally { spark.stop() } }关键生产特性说明:
- 参数化:
args(0)和args(1)接收Spring Boot传入的userId和topN,无需改代码; - 可中断:
try/catch/finally确保spark.stop()必执行,避免Driver残留; - 可监控:
println日志会被spark-submit捕获,Spring Boot实时推送; - 防错设计:
filter($"itemCount" > 5)过滤低活跃用户,避免相似度计算失真;similarity > 0.1设阈值,防止噪声用户干扰。
实操心得:我给所有Spark Job加了
--conf spark.sql.adaptive.enabled=true(Spark 3.0+),开启自适应查询优化。实测对复杂join场景,执行时间平均缩短22%,且无需人工调优shuffle partitions。
4.3 Spring Boot集成全流程:从HTTP触发到结果回调
Spring Boot端提供REST API,前端调用POST /api/rec/submit即可提交任务:
Controller层(RecController.java):
@RestController @RequestMapping("/api/rec") public class RecController { @Autowired private UserRecService userRecService; @PostMapping("/submit") public ResponseEntity<SubmitResponse> submitJob(@RequestBody SubmitRequest request) { // 1. 参数校验 if (StringUtils.isBlank(request.getUserId()) || request.getTopN() <= 0) { return ResponseEntity.badRequest() .body(new SubmitResponse("FAILED", "Invalid parameters")); } // 2. 生成唯一traceId String traceId = UUID.randomUUID().toString().replace("-", ""); // 3. 异步提交(避免HTTP请求阻塞) CompletableFuture.supplyAsync(() -> { try { String result = userRecService.submitRecJob( request.getUserId(), request.getTopN() ); return new SubmitResponse("SUCCESS", traceId); } catch (Exception e) { log.error("Submit job failed, traceId: {}", traceId, e); return new SubmitResponse("FAILED", traceId); } }).thenAccept(response -> { // 4. 结果写入数据库(供查询状态) recResultMapper.insert(RecResult.builder() .traceId(traceId) .userId(request.getUserId()) .topN(request.getTopN()) .status(response.getStatus()) .createTime(new Date()) .build()); }); return ResponseEntity.ok(new SubmitResponse("SUBMITTED", traceId)); } @GetMapping("/status/{traceId}") public ResponseEntity<StatusResponse> getJobStatus(@PathVariable String traceId) { RecResult result = recResultMapper.selectByTraceId(traceId); if (result == null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok(new StatusResponse(result.getStatus(), result.getCreateTime())); } }配套DTO和Mapper:
SubmitRequest:包含userId、topN字段;SubmitResponse:返回status(SUBMITTED/SUCCESS/FAILED)和traceId;StatusResponse:返回当前状态和创建时间;RecResultMapper:MyBatis操作MySQL,记录每次提交的traceId、状态、时间。
前端调用示例(curl):
# 提交任务 curl -X POST http://localhost:8080/api/rec/submit \ -H "Content-Type: application/json" \ -d '{"userId":"U1001","topN":10}' # 响应:{"status":"SUBMITTED","traceId":"a1b2c3d4e5f6"} # 查询状态(轮询) curl http://localhost:8080/api/rec/status/a1b2c3d4e5f6 # 响应:{"status":"SUCCESS","createTime":"2023-10-01T10:20:30"}注意:异步提交用
CompletableFuture.supplyAsync()而非@Async,避免Spring事务上下文传播问题。我们数据库用MySQL 5.7,RecResult表加了traceId唯一索引,防止重复提交。
4.4 生产级监控与日志追踪:让Spark任务“看得见、管得住”
Yarn原生监控不够细,我们加了三层增强:
第一层:Yarn UI基础监控
- 访问
http://yarn-rm-host:8088,看Application列表; - 点击Application ID,查看:
- Running Containers:实时Container数量,突降说明Executor异常退出;
- Logs:点击某个Container的“logs”链接,直接看Executor stdout;
- Diagnostics:任务失败时的根因(如“Container exited with a non-zero exit code 143”表示被Yarn Kill)。
第二层:Spring Boot日志聚合
- 所有spark-submit日志通过Logback输出到
/var/log/spark-submit.log; - 配置Logback滚动策略(按天+大小双限制):
<appender name="SPARK" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>/var/log/spark-submit.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>/var/log/spark-submit.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>100MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> </rollingPolicy> </appender>
第三层:自定义指标埋点
在UserRecService中添加Micrometer指标:
@Component public class SparkMetrics { private final Timer sparkSubmitTimer; private final Counter sparkSuccessCounter; private final Counter sparkFailCounter; public SparkMetrics(MeterRegistry registry) { this.sparkSubmitTimer = Timer.builder("spark.submit.duration") .description("Time taken to submit and complete Spark job") .register(registry); this.sparkSuccessCounter = Counter.builder("spark.job.success") .description("Count of successful Spark jobs") .register(registry); this.sparkFailCounter = Counter.builder("spark.job.fail") .description("Count of failed Spark jobs") .register(registry); } public void recordSuccess(long durationMs) { sparkSubmitTimer.record(durationMs, TimeUnit.MILLISECONDS); sparkSuccessCounter.increment(); } public void recordFailure() { sparkFailCounter.increment(); } }Prometheus抓取/actuator/metrics,Grafana看板展示:
- “Spark Submit Success Rate”(成功率);
- “Spark Avg Duration”(平均耗时);
- “Spark Running Jobs”(并发数)。
实操心得:我们发现一个隐藏问题——Yarn默认
yarn.nodemanager.vmem-pmem-ratio=2.1,即虚拟内存是物理内存的2.1倍。当Spark Executor申请4G内存时,Yarn会按比例分配8.4G虚拟内存。但某些云主机虚拟内存不足,导致Container被Kill。解决方案:在spark-submit中加--conf yarn.nodemanager.vmem-pmem-ratio=1.0,强制虚拟内存不超额。
5. 常见问题与排查技巧实录
5.1 典型问题速查表:从报错日志反推根因
| 报错日志片段 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
ClassNotFoundException: org.apache.spark.sql.SparkSession | Spark核心jar重复打包 | 1.jar -tf rec-job-1.0.0.jar | grep spark-core2. 检查pom.xml中spark依赖scope是否为provided | 在pom.xml中将spark依赖scope设为<scope>provided</scope> |
No FileSystem for scheme: hdfs | HADOOP_CONF_DIR未设置或配置文件缺失 | 1.echo $HADOOP_CONF_DIR2. ls $HADOOP_CONF_DIR/core-site.xml3. hdfs dfs -ls / | 在ProcessBuilder中显式设置pb.environment().put("HADOOP_CONF_DIR", "/etc/hadoop/conf") |
Container exited with a non-zero exit code 143 | Yarn Kill了超内存Container | 1. 查Yarn UI中Container的Diagnostics 2. yarn logs -applicationId application_12345看AM日志 | 降低--executor-memory,或增加--conf spark.yarn.executor.memoryOverhead=2048 |
java.lang.IllegalArgumentException: java.net.UnknownHostException: nameservice1 | HDFS HA配置中nameservice1未解析 | 1.cat /etc/hadoop/conf/core-site.xml确认fs.defaultFS值2. ping nameservice1或nslookup nameservice1 | 在submit-node的/etc/hosts中添加192.168.1.100 nameservice1 |
org.apache.hadoop.security.AccessControlException: Permission denied: user=spark, access=WRITE, inode="/spark-jobs" | HDFS权限不足 | 1.hdfs dfs -ls /看/spark-jobs目录属主2. hdfs dfs -chmod -R 777 /spark-jobs(开发环境) | 生产环境用hdfs dfs -chown spark:spark /spark-jobs,并配置HDFS ACL |
5.2 我踩过的五个深坑及独家避坑技巧
坑1:Kerberos票据过期导致任务随机失败
现象:任务白天正常,凌晨2点后大量失败,Yarn日志报“GSSException: No valid credentials provided”。
根因:Kerberos票据默认有效期10小时,凌晨2点恰好过期。
避坑技巧:在submit-node上配置自动续期脚本,每5小时刷新一次:
# /opt/spark/bin/kinit-auto.sh #!/bin/bash kinit -kt /etc/security/keytabs/spark-submit.keytab spark-submit/submit-node@EXAMPLE.COM # 加入crontab:0 */5 * * * /opt/spark/bin/kinit-auto.sh坑2:Spark UI端口被占用,多个任务冲突
现象:第二个任务提交后,Yarn UI显示ApplicationMaster Running,但点进去报“Connection refused”。
根因:Spark Driver默认用4040端口,第二个任务启动时端口被占,降级到4041,但Yarn UI仍指向4040。
避坑技巧:强制指定随机端口,在spark-submit中加:
--conf spark.ui.port=$(shuf -i 4040-4099 -n 1)坑3:HDFS小文件过多,任务启动巨慢
现象:spark-submit执行后,卡在“Loading Spark configuration from Hadoop configuration”长达2分钟。
根因:HDFS上/spark-jobs/lib/目录有2000+个小jar,listStatus操作遍历耗时。
避坑技巧:用Hadoop Archive(HAR)打包依赖:
hadoop archive -archiveName libs.har -p /spark-jobs/lib /spark-jobs/archives/ # 提交时用--archives hdfs://nameservice1/spark-jobs/archives/libs.har#libs坑4:MySQL连接池耗尽,Spring Boot假死
现象:连续提交10个任务后,Spring Boot HTTP接口全部超时,但进程仍在。
根因:每个spark-submit进程启动时,Spring Boot的Hik