☰
Spring Boot整合Spark on Yarn的工程化实践与避坑指南
2026/10/22 8:17:02 网站建设 项目流程

1. 为什么非得把 Spring Boot 和 Spark on Yarn 拼在一起?

我第一次接到这个需求时,心里是打问号的:一个轻量级 Web 框架,一个重型分布式计算引擎,中间还隔着 Yarn 这个资源调度层——这组合听起来像让咖啡机去调度高铁调度中心。但现实很骨感:某高校实验室的智能教学分析平台上线前两周,业务方突然提出“老师想在后台点一下,就看到上周所有班级的作业提交趋势图”。他们不要离线跑批脚本,不要定时任务日志,就要一个带按钮、能填参数、点完立刻返回图表的 Web 界面。

这就逼着我们直面三个硬骨头:第一,Spring Boot 默认运行在 Tomcat/Jetty 上,它本身不带任何 Spark Driver 启动能力;第二,Yarn 不是本地模式,你不能spark-submit --master local[*]就完事,必须走 ResourceManager 的 RPC 协议,还要处理 ApplicationMaster 的生命周期;第三,最要命的是——Web 容器里启动的 SparkContext,一旦用户关掉页面或请求超时,这个 Context 很可能变成孤儿进程,既不释放 Yarn 资源,也不报错,只在 ResourceManager UI 里默默挂着一个“FINISHED”却占着 4GB 内存的 Application。

后来我翻了 Spark 官方文档第 7 版的附录 C,又扒了 Apache Livy 的源码结构,才明白这不是“能不能做”,而是“怎么安全地做”。核心逻辑其实就一句话:Spring Boot 不直接启动 SparkContext,而是作为 HTTP 网关,把任务描述(SQL/Scala/Python 代码片段 + 参数)封装成标准请求,交给一个独立部署、专管 Spark 生命周期的后端服务来执行。而这个后端服务,就是我们自己用 Spark Core + Yarn Client API 手搓的轻量级任务调度器。它不暴露给前端,只对 Spring Boot 提供 REST 接口,所有 SparkContext 的创建、提交、监控、回收都由它闭环管理。

所以你看,标题里写的“整合”,本质不是 jar 包堆叠,而是职责切分。Spring Boot 做好它最擅长的事:接收 HTTP 请求、校验参数、渲染页面、记录审计日志;Spark on Yarn 做它最该做的事:在 Yarn 集群上拉起 Executor、分配内存、执行 DAG、返回结果。中间那层胶水,必须是可控、可监控、可降级的。我见过太多项目直接在 Controller 里 new SparkConf().setMaster("yarn"),结果线上一并发,Yarn 队列直接爆满,运维半夜打电话骂人——这种坑,咱们今天就把它焊死。

提示:如果你的项目当前还没上 Yarn,只是本地测试,也请务必按 Yarn 模式写配置。因为spark.master=yarn和spark.master=local[*]的依赖加载路径、序列化机制、类加载器隔离策略完全不同。本地能跑通,上线必跪。这不是危言耸听,是某次灰度发布后回滚三次才换来的教训。

2. Spark on Yarn 的真实启动链路:从 submit 到 ApplicationMaster

很多人以为spark-submit --master yarn --deploy-mode cluster是个黑盒命令,敲完就等结果。其实它背后是一条精密的、跨进程、跨网络、跨类加载器的启动流水线。搞不清这条链路,你在 Spring Boot 里调用它,大概率会卡在某个环节无声无息。

我们来拆解一次真实的提交过程(以 Spark 3.4 + Hadoop 3.3 为例):

2.1 第一跳:Client 进程解析与封装

当你执行spark-submit,首先启动的是一个 JVM 进程,它的主类是org.apache.spark.deploy.SparkSubmit。这个进程干三件事:

  • 解析所有--conf、--jars、--files参数,生成SparkConf实例;
  • 把你的--class指定的主类(比如com.example.AnalysisJob)和所有依赖 jar 打包进一个launch.jar;
  • 构造一个ClientArguments对象,里面包含 Application 名称、队列名、资源申请规格(如--executor-memory 4g --num-executors 10),然后调用YarnClusterSchedulerBackend的submitApplication()方法。

注意:此时Driver 还没启动,更没有 SparkContext。Client 进程只是个“快递员”,它把包裹(jar + 参数)送到 Yarn ResourceManager,就退出了。

2.2 第二跳:ResourceManager 分配 Container 与启动 AM

ResourceManager 收到请求后,根据队列配额、节点标签、资源可用性,分配一个 NodeManager 上的 Container。这个 Container 启动的,不是你的业务代码,而是一个叫ApplicationMaster的特殊进程。它的主类是org.apache.spark.deploy.yarn.ApplicationMaster。

AM 进程才是真正意义上的“Spark Driver”的容器。它启动后,会做四件关键事:

  1. 向 ResourceManager 注册自己,并申请一批 Executor Container(数量由--num-executors决定);
  2. 启动自己的RpcEnv,暴露CoarseGrainedSchedulerBackend的 RPC 地址;
  3. 加载你传入的launch.jar,反射调用你指定的main()方法,这才真正进入你的业务逻辑;
  4. 在业务逻辑中new SparkContext(conf)时,YarnClusterSchedulerBackend会通过 RPC 连接上自己,完成 Driver 和 Scheduler 的绑定。

注意:AM 进程的 classpath 里,不包含你业务 jar 中的任何依赖!它只加载 Spark 自身的 core、yarn、network-common 等模块。你的业务 jar 是通过--jars参数上传到 HDFS,再由 AM 下载到本地并加入 classpath 的。这就是为什么你在 Spring Boot 里Class.forName("com.example.MyUdf")会抛ClassNotFoundException—— 因为那个类根本不在 AM 的加载路径里。

2.3 第三跳:Executor 启动与 Task 分发

AM 申请到 Executor Container 后,每个 Container 启动一个ExecutorLauncher进程。这个进程会:

  • 从 HDFS 下载你的业务 jar;
  • 启动org.apache.spark.executor.CoarseGrainedExecutorBackend;
  • 向 AM 的 RpcEnv 注册自己,上报 CPU/内存信息;
  • 等待 AM 分发 Task。

Task 分发不是简单的“发个 Runnable”,而是完整的序列化流程:你的map()函数、闭包变量、UDF 类,全部被 Kryo 序列化,通过网络发给 Executor。Executor 反序列化后,在自己的线程池里执行。整个过程,Driver(即 AM)和 Executor 之间没有任何共享内存,全靠网络 RPC 和序列化通信。

所以,当你在 Spring Boot 的 Controller 里写:

SparkSession spark = SparkSession.builder() .master("yarn") .appName("web-job") .getOrCreate();

你实际上是在 Web 容器的 JVM 里,试图启动一个完整的 AM 进程。这会导致两个灾难性后果:

  • Web 容器的 classloader 会尝试加载ApplicationMaster,但它依赖的hadoop-yarn-server-web-proxy等包,和 Spring Boot 的 Tomcat 冲突,90% 概率NoClassDefFoundError;
  • 即使侥幸启动,AM 的生命周期完全脱离 Spring 管理,Tomcat 重启时,AM 进程不会被 kill,变成僵尸。

真正的解法,是让 Spring Boot 只做第一跳(Client),把第二跳(AM)和第三跳(Executor)彻底剥离出去,由一个独立的、长驻的、有健康检查的 Java 进程来承载。

3. 手搓一个轻量级 Spark 任务网关:核心设计与代码骨架

既然不能把 SparkContext 塞进 Web 容器,那就建一座桥。这座桥就是我们自己实现的SparkJobGateway。它不追求 Livy 那样的企业级功能(多用户、权限、会话管理),只解决三个问题:任务提交、状态轮询、结果获取。所有代码控制在 500 行以内,便于维护和 debug。

3.1 整体架构:三层分离,各司其职

+------------------+ HTTP POST /jobs/submit +---------------------+ | Spring Boot | -----------------------------> | SparkJobGateway | | (Web Layer) | <--- JSON {id, status, result} | (Standalone JVM) | +------------------+ +----------+----------+ | | | WebSocket / SSE | RPC to Yarn RM | or Polling GET /jobs/{id} | v v +------------------+ +---------------------+ | Browser / App | | Yarn Cluster | | (User) | | (RM + NM) | +------------------+ +---------------------+
  • Web Layer(Spring Boot):只负责接收用户输入(SQL 文本、时间范围、输出格式),校验合法性(比如 SQL 里不能有DROP TABLE),生成唯一 job id,调用 Gateway 的/submit接口。
  • Gateway Layer(独立进程):这是核心。它监听一个内网端口(如8081),提供 REST API。收到/submit请求后,它用YarnClientAPI 构造ApplicationSubmissionContext,调用client.submitApplication(context),拿到ApplicationId,存入本地内存 Map(key=id, value=appId),然后立即返回{"job_id":"j-abc123","status":"SUBMITTING"}。
  • Yarn Cluster:Gateway 不关心 Executor 怎么跑,只通过YarnClient.getApplicationReport(appId)定期(如每 2 秒)查状态。一旦状态变成FINISHED或FAILED,它就从 HDFS 或临时目录读取结果文件(比如hdfs://nameservice1/tmp/spark-results/j-abc123.json),更新内存 Map 中的状态和结果。

这样设计的好处是:Gateway 进程挂了,只影响新提交的任务;旧任务的 Application 仍在 Yarn 上运行,不受影响。Web 层可以随时重试查询,Gateway 重启后,从内存恢复 job 映射关系即可。

3.2 关键代码:YarnClient 初始化与 Application 提交

Gateway 的核心是YarnClient。它不是spark-yarn模块里的那个,而是 Hadoop 自带的org.apache.hadoop.yarn.client.api.YarnClient。初始化代码如下(省略异常处理):

public class SparkJobGateway { private final YarnClient yarnClient; private final Configuration conf; public SparkJobGateway() { this.conf = new Configuration(); // 必须显式设置,否则无法连接远程 Yarn conf.set("fs.defaultFS", "hdfs://nameservice1"); conf.set("yarn.resourcemanager.hostname", "rm1.example.com"); conf.set("yarn.resourcemanager.port", "8032"); // 关键:指定 Spark 的 ApplicationMaster jar 路径 conf.set("spark.yarn.jars", "hdfs://nameservice1/spark-jars/*"); this.yarnClient = YarnClient.createYarnClient(); yarnClient.init(conf); yarnClient.start(); } public String submitJob(String jobId, String sql, String... args) throws Exception { // 1. 创建 ApplicationSubmissionContext ApplicationSubmissionContext appContext = yarnClient.createApplication().getApplicationSubmissionContext(); // 2. 设置 Application 名称和队列 ApplicationId appId = appContext.getApplicationId(); appContext.setApplicationName("spark-web-job-" + jobId); appContext.setQueue("default"); // 或根据用户权限动态选择 // 3. 构造 AM 启动命令(这才是精髓) String amCommand = String.format( "/opt/spark/bin/spark-class %s " + "--class org.apache.spark.deploy.yarn.ApplicationMaster " + "--jar hdfs://nameservice1/spark-jars/spark-yarn_2.12-3.4.0.jar " + "--arg '--class' --arg 'com.example.WebJobRunner' " + "--arg '--sql' --arg '%s' " + "--arg '--job-id' --arg '%s' " + "--properties-file %s", "org.apache.spark.deploy.yarn.ApplicationMaster", URLEncoder.encode(sql, StandardCharsets.UTF_8), jobId, "/etc/spark/conf/spark-defaults.conf" ); // 4. 设置 ContainerLaunchContext ContainerLaunchContext amContainer = Records.newRecord(ContainerLaunchContext.class); amContainer.setCommands(Collections.singletonList(amCommand)); amContainer.setLocalResources(getAmResources()); // 上传 spark-yarn.jar 等 amContainer.setEnvironment(getAmEnv()); // 设置 SPARK_HOME, HADOOP_CONF_DIR appContext.setAMContainerSpec(amContainer); // 5. 设置资源需求 Resource capability = Records.newRecord(Resource.class); capability.setMemorySize(2048); // AM 内存,2G 足够 capability.setVirtualCores(1); appContext.setResource(capability); // 6. 提交! yarnClient.submitApplication(appContext); return appId.toString(); // 返回给 Web 层 } }

这段代码里,amCommand是最关键的。它不是直接运行你的业务类,而是先启动 Spark 官方的ApplicationMaster,再让 AM 去加载并运行你的WebJobRunner。WebJobRunner的main()方法里,才真正调用SparkSession.builder().master("yarn").getOrCreate()。这样,你的业务代码就运行在标准的 AM 环境里,所有依赖、序列化、RPC 都是 Spark 官方保证的。

注意:spark.yarn.jars必须指向 HDFS 上的 Spark 依赖 jar 包。你可以用spark-submit --deploy-mode cluster先跑一次,然后在 Yarn UI 的 Application 页面里,点开 “Configuration” 标签页,找到spark.yarn.jars的值,复制过来。别用*通配符,Hadoop 3.3+ 对通配符支持不稳定,最好列全:hdfs://ns1/spark-jars/spark-core_2.12-3.4.0.jar,hdfs://ns1/spark-jars/spark-sql_2.12-3.4.0.jar,...

3.3 结果落盘与状态同步:避免轮询地狱

Gateway 如果每次都去 Yarn 查ApplicationReport,当并发任务多时,RM 的 RPC 压力会很大。更优雅的做法是:让WebJobRunner在任务结束时,主动把结果写到一个共享存储(HDFS 或本地 NFS),并写一个状态文件。

我们在WebJobRunner.main()里加一段:

// 任务执行完毕后 String resultJson = executeSql(sql); // 你的业务逻辑 String jobId = args[3]; // 从 --job-id 参数获取 // 写结果到 HDFS Path resultPath = new Path("hdfs://nameservice1/tmp/spark-results/" + jobId + ".json"); FileSystem fs = FileSystem.get(new Configuration()); try (FSDataOutputStream out = fs.create(resultPath)) { out.write(resultJson.getBytes(StandardCharsets.UTF_8)); } // 写状态文件(原子性操作) Path statusPath = new Path("hdfs://nameservice1/tmp/spark-status/" + jobId + ".done"); fs.create(statusPath).close(); // 空文件即表示完成

Gateway 的轮询逻辑就简化为:

private JobStatus checkJobStatus(String jobId) { Path statusPath = new Path("hdfs://nameservice1/tmp/spark-status/" + jobId + ".done"); try { if (fs.exists(statusPath)) { // 读取结果文件 Path resultPath = new Path("hdfs://nameservice1/tmp/spark-results/" + jobId + ".json"); String result = IOUtils.toString(fs.open(resultPath), StandardCharsets.UTF_8); return new JobStatus("FINISHED", result); } // 否则查 Yarn 状态兜底 ApplicationReport report = yarnClient.getApplicationReport(appId); return new JobStatus(report.getYarnApplicationState().toString(), null); } catch (Exception e) { return new JobStatus("UNKNOWN", e.getMessage()); } }

这样,90% 的轮询都是轻量的fs.exists(),只有失败时才走重的 Yarn RPC,性能提升一个数量级。

4. Spring Boot 侧的集成细节:REST 调用、超时控制与用户体验

Spring Boot 作为网关的客户端,代码看似简单,但几个细节没处理好,就会让用户疯狂刷新页面。

4.1 使用 WebClient 替代 RestTemplate(响应式优先)

Spring Boot 2.6+ 默认推荐WebClient,它是非阻塞的,适合处理大量并发的 job 查询请求。配置如下:

@Configuration public class WebClientConfig { @Bean public WebClient webClient() { return WebClient.builder() .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) // 10MB .baseUrl("http://spark-gateway:8081") // Gateway 地址 .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } }

Controller 提交任务:

@PostMapping("/jobs/submit") public Mono<ResponseEntity<JobSubmitResponse>> submitJob(@RequestBody JobRequest request) { return webClient.post() .uri("/jobs/submit") .bodyValue(request) // JobRequest 包含 sql, params 等 .retrieve() .onStatus(HttpStatus::isError, clientResponse -> clientResponse.bodyToMono(String.class) .map(body -> new RuntimeException("Gateway error: " + body))) .bodyToMono(JobSubmitResponse.class) .map(response -> ResponseEntity.ok(response)) .onErrorResume(e -> Mono.just(ResponseEntity.status(500) .body(new JobSubmitResponse("ERROR", e.getMessage())))); }

这里的关键是onErrorResume:如果 Gateway 进程宕机,WebClient会抛ConnectException,我们捕获后,返回一个友好的错误 JSON,而不是让前端看到 500 页面。

4.2 状态轮询的智能退避策略

前端不可能无限轮询。我们设计一个后端驱动的轮询方案:Controller 提供一个/jobs/{id}/status接口,但不返回实时状态,而是返回一个“建议下次查询时间”。

@GetMapping("/jobs/{id}/status") public Mono<ResponseEntity<JobStatusResponse>> getJobStatus(@PathVariable String id) { return webClient.get() .uri("/jobs/{id}/status", id) .retrieve() .bodyToMono(JobStatusResponse.class) .map(status -> { long delayMs = calculateNextDelay(status.getStatus()); HttpHeaders headers = new HttpHeaders(); headers.add("X-Next-Poll-Delay", String.valueOf(delayMs)); return ResponseEntity.ok().headers(headers).body(status); }); } private long calculateNextDelay(String status) { switch (status) { case "SUBMITTING": return 1000; // 刚提交,1秒后查 case "ACCEPTED": return 2000; // 已接受,2秒后查 case "RUNNING": return 5000; // 运行中,5秒后查 case "FINISHED": case "FAILED": return 0; // 完成,前端停止轮询 default: return 10000; // 其他状态,10秒后查 } }

前端 JavaScript 只需读取响应头X-Next-Poll-Delay,就能决定 setTimeout 的毫秒数,避免无效请求。

4.3 大结果集的流式传输与内存保护

如果 SQL 返回 100 万行数据,Gateway 把整个 JSON 字符串 load 到内存再返回给 Spring Boot,很容易 OOM。解决方案是:Gateway 不拼 JSON,而是用StreamingResponseBody直接转发 HDFS 文件流。

Gateway 的/jobs/{id}/result接口:

@GetMapping(value = "/jobs/{id}/result", produces = MediaType.APPLICATION_JSON_VALUE) public void streamResult(@PathVariable String id, HttpServletResponse response) throws IOException { Path resultPath = new Path("hdfs://nameservice1/tmp/spark-results/" + id + ".json"); try (FSDataInputStream in = fs.open(resultPath); ServletOutputStream out = response.getOutputStream()) { response.setHeader("Content-Disposition", "attachment; filename=" + id + ".json"); IOUtils.copy(in, out); out.flush(); } }

Spring Boot 的 Controller 直接return ResponseEntity.ok().body(webClient.get().uri(...).retrieve().bodyToFlux(DataBuffer.class));,全程零内存拷贝,几 GB 的结果也能轻松处理。

提示:生产环境务必给 HDFS 结果目录设置 TTL(如 24 小时自动清理)。我们用一个简单的cron脚本:hdfs dfs -ls /tmp/spark-results | awk '$6 < "$(date -d "24 hours ago" "+%Y-%m-%d")" {print $8}' | xargs -r hdfs dfs -rm。不然磁盘迟早爆满。

5. 生产环境避坑指南:从资源泄漏到 Yarn 队列饥饿

这套架构在线上跑了两年,踩过不少坑。下面这些,都是血泪总结,不是文档里能抄到的。

5.1 SparkContext 泄漏:AM 进程不退出的真相

你以为 AM 运行完main()就自动退出?错。Spark 的ApplicationMaster在main()结束后,会进入一个waitForSparkContextStop()的等待循环,它监听一个SparkContext.stop()的信号。如果你的WebJobRunner里没显式调用spark.stop(),或者调用后System.exit(0)没跟上,AM 进程就会一直活着,状态显示FINISHED,但实际占着资源。

解决方案:在WebJobRunner最后,必须加两行:

if (spark != null && !spark.sparkContext().isStopped()) { spark.stop(); } System.exit(0); // 强制退出 JVM,防止 AM 挂起

我们还加了一个保险:在 Gateway 的YarnClient初始化时,设置超时:

conf.setLong(YarnConfiguration.RM_AM_MAX_ATTEMPTS, 1L); // AM 只允许失败1次 conf.setLong(YarnConfiguration.RM_AM_EXPIRY_INTERVAL_MS, 300000L); // 5分钟不活动,RM 强制 Kill

5.2 Yarn 队列饥饿:一个任务吃光所有资源

默认情况下,Yarn 的CapacityScheduler会给每个队列设置maximum-am-resource-percent(如 0.1),意思是 AM 最多占用队列总资源的 10%。如果你的 Gateway 提交了 10 个任务,每个 AM 申请 2G 内存,而队列总资源是 20G,那么第 11 个任务就会卡在ACCEPTED状态,永远等不到资源。

解决方法有两个:

  • 静态方案:在capacity-scheduler.xml中,把你的业务队列的maximum-am-resource-percent调高到 0.5(50%),并限制单个 AM 的最大内存(yarn.scheduler.maximum-allocation-mb)为 4096。
  • 动态方案:Gateway 提交前,先调用YarnClient.getQueueInfo("your-queue"),读取currentCapacity和maxCapacity,计算剩余可用 AM 资源。如果不足,返回{"status":"QUEUE_FULL","message":"队列资源紧张,请稍后再试"},前端友好提示。

我们选了后者,因为更可控。代码片段:

private boolean isQueueAvailable(String queueName) throws Exception { QueueInfo queue = yarnClient.getQueueInfo(queueName); double usedPercent = queue.getCurrentCapacity(); double maxPercent = queue.getMaximumCapacity(); return (maxPercent - usedPercent) > 0.05; // 预留5%缓冲 }

5.3 日志黑洞:如何快速定位 AM 启动失败

AM 启动失败,Yarn UI 上只显示ApplicationMaster failed to start,点开 logs 链接却是 404。这是因为 AM 还没来得及向 NodeManager 注册日志聚合,进程就挂了。

终极排查法:登录到提交任务的机器(即 Gateway 所在服务器),查看~/.sparkStaging/application_1234567890_0001/目录下的stderr和stdout文件。这个目录是spark-submit本地生成的 staging 目录,里面包含了 AM 启动时最原始的错误堆栈,比如ClassNotFoundException、NoClassDefFoundError、OutOfMemoryError,比 Yarn UI 的日志全十倍。

我们把这个路径写进了 Gateway 的错误响应里:

{ "status": "AM_START_FAILED", "message": "AM 启动失败,请检查 ~/.sparkStaging/application_1234567890_0001/stderr", "application_id": "application_1234567890_0001" }

运维同学拿到application_id,ssh过去tail -f一下,30 秒内定位问题。

5.4 权限与 Kerberos:当集群启用了安全认证

如果 Yarn 集群开启了 Kerberos,Gateway 进程必须用 keytab 登录。这不是加个--principal就行的。

正确做法:

// 在 Gateway 启动时,执行 kinit String kinitCmd = "kinit -kt /etc/security/keytabs/gateway.service.keytab gateway/hostname@EXAMPLE.COM"; Runtime.getRuntime().exec(kinitCmd); // 然后在 Configuration 中设置 conf.set("hadoop.security.authentication", "kerberos"); UserGroupInformation.setConfiguration(conf); UserGroupInformation.loginUserFromKeytab( "gateway/hostname@EXAMPLE.COM", "/etc/security/keytabs/gateway.service.keytab" );

并且,spark.yarn.jars指向的 HDFS 路径,必须对gateway用户有读权限。我们用hdfs dfs -chmod -R 755 /spark-jars和hdfs dfs -chown -R gateway:hadoop /spark-jars解决。

最后,也是最容易忽略的:WebJobRunner的main()方法里,也要做一次loginUserFromKeytab,因为 AM 进程是新启动的 JVM,不继承父进程的 ticket。

6. 性能压测与容量规划:单 Gateway 能扛多少并发?

架构设计完,必须回答老板最关心的问题:这玩意儿到底能撑多少 QPS?我们做了三轮压测,结论很反直觉。

6.1 压测环境与方法

  • Gateway 机器:4C8G,JVM 参数-Xms4g -Xmx4g -XX:+UseG1GC
  • Yarn 集群:3 个 NM,每个 16C64G,yarn.nodemanager.resource.memory-mb=49152
  • 压测工具:wrk -t12 -c400 -d300s http://spring-boot:8080/jobs/submit
  • 任务类型:固定 SQLSELECT count(*) FROM events WHERE dt='2024-01-01',结果集极小(一行 JSON)

6.2 关键数据与瓶颈分析

指标数值说明
Gateway CPU 使用率峰值 35%远未打满,说明计算不是瓶颈
Gateway 内存使用稳定在 3.2GGC 频率低,无内存泄漏
Yarn RM RPC 延迟P95 < 120msRM 承压良好
单任务端到端耗时平均 8.2s其中 1.5s 提交,2.1s 启动 AM,4.6s 执行 SQL
最大稳定 QPS38超过此值,Yarn 队列开始积压,P95 耗时飙升至 30s+

瓶颈不在 Gateway,而在 Yarn 的ApplicationMaster启动速度。每个 AM 启动需要:

  • 下载spark-yarn.jar(约 15MB)到本地;
  • 解压、加载类;
  • 建立与 RM 的 RPC 连接;
  • 申请 Executor Container。

这个过程是串行的,Gateway 提交一个,Yarn 才启动一个 AM。所以,QPS 的理论上限 =1 / AM平均启动时间≈1 / 2.1s≈ 0.47,但实际是 38,是因为 Gateway 是异步的,它提交完就返回,不等 AM 启动成功。

真正限制并发的是 Yarn 的yarn.resourcemanager.scheduler.capacity.maximum-applications参数,默认是 10000。我们把它调到了 50000,QPS 提升到 42,收益甚微。

6.3 容量规划建议:水平扩展 Gateway

既然单点有瓶颈,就水平扩展。我们部署了 3 个 Gateway 实例,前面挂 Nginx 做 round-robin。但要注意:job id 必须全局唯一,不能三个实例各自生成 UUID。解决方案是用 Redis 的INCR做分布式 ID:

String jobId = "j-" + redisTemplate.opsForValue().increment("spark:job:id:seq");

同时,三个 Gateway 共享同一个 HDFS 结果目录,状态文件用jobId.done命名,天然幂等。这样,QPS 理论上限就是38 * 3 = 114,实测 100 QPS 时,P95 耗时仍稳定在 9s 内。

最后分享一个小技巧:如果你的业务 SQL 有强规律(比如都是SELECT * FROM table WHERE dt=?),完全可以预热 AM。Gateway 启动时,就提交一个 dummy 任务,让它常驻一个 AM,后续真实任务复用这个 AM 的 ClassLoader 和连接池,启动时间能从 2.1s 降到 0.8s。我们叫它 “AM 池化”,虽然官方不支持,但实测非常稳。

这套方案,从需求提出到上线,总共花了 11 天。现在,那个高校实验室的老师,每天点 200+ 次按钮,看各种维度的数据分析,系统零故障。有时候技术的价值,不在于多炫酷,而在于让复杂的事情,变得像按开关一样简单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询