1. 为什么要在 Spring Boot 里塞一个 Spark
很多做后端的朋友第一次听到"Spring Boot 整合 Spark"这个组合,第一反应是:这俩东西画风不太一样啊。Spring Boot 是典型的 Web 应用框架,讲究的是快速启动、轻量、请求响应;Spark 是分布式计算引擎,讲究的是把一个大任务拆成无数小任务丢到集群上跑。把它们放一起,本质上是要解决一个很实际的问题:让业务系统能够通过一个 HTTP 接口,触发一次大数据量的计算任务,并且拿到结果或者至少拿到任务状态。
这个需求在真实场景里非常常见。比如某个运营后台需要生成一份全量用户的行为分析报表,数据量在千万级别,用普通的 SQL 查询数据库会直接把库拖垮,但如果用 Spark 跑一个批处理任务,几分钟就能出结果。问题在于,运营人员不可能去命令行敲spark-submit,他们需要一个按钮,点一下,任务就跑起来,跑完了页面上能看到结果。这时候 Spring Boot 就派上用场了——它负责接收 HTTP 请求、管理任务的生命周期、把 Spark 的计算能力包装成一个对前端友好的接口。
所以这个整合的核心价值可以概括成三句话:用 Spring Boot 做任务的门面和调度器,用 Spark 做真正的计算引擎,用 HTTP 接口做两者之间的桥梁。适合谁来参考呢?如果你是一个 Java 后端开发者,手头有 Spring Boot 的项目经验,现在需要接入一些大数据处理能力,但又不想单独维护一套 Spark 的提交脚本和调度系统,那这套方案就是为你准备的。它不需要你成为 Spark 专家,但需要你理解 Spark 的基本运行模型,否则后面配置和调优的部分会看得很痛苦。
我先把整体架构用文字描述清楚,方便你建立全局观。整个系统分三层:最上层是 Controller 层,暴露 REST 接口,接收任务参数;中间是 Service 层,负责构建 Spark 的配置对象、创建 SparkSession、提交任务、管理任务状态;最下层是 Spark 的运行环境,可以是本地的 local 模式,也可以是 YARN 或者 Standalone 集群模式。这三层之间的边界要划清楚,尤其是 SparkSession 的创建和销毁,如果管理不当,会出现内存泄漏或者任务互相干扰的问题,这个后面会详细讲。
还有一个容易被忽略的点:Spark 的依赖包非常大。如果你用 Maven 引入spark-core和spark-sql,打包出来的 fat jar 动辄两三百兆。这在开发阶段没什么感觉,但到了部署阶段,每次发版都要传这么大的包,运维同学会想打人。所以后面我会专门讲依赖的 scope 怎么设置,以及集群模式下怎么把依赖剥离出去。
2. 依赖引入与项目骨架搭建
2.1 Maven 依赖的取舍逻辑
先看依赖。Spring Boot 的依赖不用多说,用你熟悉的spring-boot-starter-web就行。关键是 Spark 相关的依赖怎么引。这里有一个非常重要的原则:Spark 的依赖在开发阶段用compile,在打包部署时尽量用provided。
为什么?因为集群环境上通常已经自带了 Spark 的运行时库。如果你把 Spark 的所有 jar 都打进自己的应用包里,一来包体积巨大,二来可能和集群上的 Spark 版本冲突,导致各种诡异的ClassNotFoundException或者NoSuchMethodError。所以正确的做法是:编译时需要这些类来写代码,但运行时让集群提供。
<dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-core_2.12</artifactId> <version>3.4.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_2.12</artifactId> <version>3.4.1</version> <scope>provided</scope> </dependency>注意版本号里的_2.12是 Scala 的版本,不是 Spark 的版本。Spark 3.x 默认用 Scala 2.12,如果你用的 Spark 版本对应的是 2.13,这里要改成_2.13。这个细节很多人第一次会搞错,导致依赖拉不下来。
但是,如果你要在 local 模式下运行,provided就会导致运行时找不到类。所以我的建议是:开发环境用compile,生产打包时通过 Maven Profile 切换成provided。这样本地调试方便,部署时又不会把包撑大。
2.2 项目目录结构的设计
目录结构上,我建议单独建一个spark包,把所有和 Spark 相关的代码放在里面,不要和普通的业务 Service 混在一起。原因很简单:Spark 的代码有它自己的生命周期管理逻辑,和普通的 CRUD 逻辑混在一起会让代码变得很难维护。
com.example.demo ├── controller │ └── SparkJobController.java ├── service │ └── SparkJobService.java ├── spark │ ├── SparkSessionManager.java │ ├── JobConfig.java │ └── JobResult.java └── DemoApplication.javaSparkSessionManager是整个整合的核心类,负责创建和管理 SparkSession。JobConfig是一个配置类,用来承载任务的参数。JobResult是任务执行结果的封装。Controller 只负责接收请求和返回响应,真正的逻辑在 Service 里。
2.3 一个容易踩的坑:Spring Boot 的类加载器
这里要提前说一个坑。Spring Boot 用的是自己的类加载器(LaunchedURLClassLoader),而 Spark 在提交任务时也会涉及类加载。如果你在 local 模式下运行,有时候会遇到ClassNotFoundException,明明依赖已经引入了,但就是找不到类。这通常是因为 Spark 的类加载器和 Spring Boot 的类加载器之间的委托关系出了问题。
解决办法是在创建 SparkSession 的时候,显式设置spark.driver.userClassPathFirst为true,让 Spark 优先使用应用自己的类路径。这个参数在集群模式下也很有用,可以避免集群上的旧版本 jar 覆盖你应用里的新版本。
.config("spark.driver.userClassPathFirst", "true")不过这个参数不是万能的,有时候反而会引发新的冲突。我的经验是:先不加,遇到类加载问题再加,加了之后如果问题更严重就换其他方案,比如把冲突的依赖 shade 掉。
3. SparkSession 的创建与生命周期管理
3.1 为什么不能每次请求都创建一个 SparkSession
这是新手最容易犯的错误。很多人写代码的时候,习惯性地在每次请求进来的时候SparkSession.builder().getOrCreate(),觉得getOrCreate会帮你复用。但实际上,在 Spring Boot 的 Web 环境里,每次请求可能由不同的线程处理,而 SparkSession 本身是线程安全的,但它的创建过程是有开销的。
更重要的是,SparkSession 持有一个 SparkContext,而 SparkContext 一旦启动,就会占用一堆资源:driver 的内存、和集群的通信连接、executor 的分配等等。如果你每次请求都创建一个新的,很快就会发现内存爆了,或者集群上的 executor 被占满了。
所以正确的做法是:整个应用生命周期内只创建一个 SparkSession,所有任务共享它。用 Spring 的@Bean和单例模式来管理。
@Configuration public class SparkSessionManager { private SparkSession sparkSession; @PostConstruct public void init() { sparkSession = SparkSession.builder() .appName("spring-boot-spark-demo") .master("local[*]") .config("spark.sql.shuffle.partitions", "10") .config("spark.driver.memory", "2g") .getOrCreate(); } public SparkSession getSession() { return sparkSession; } @PreDestroy public void destroy() { if (sparkSession != null) { sparkSession.stop(); } } }注意@PreDestroy里的stop()调用。这个非常重要,如果不调用,应用关闭的时候 SparkContext 可能不会正常释放,导致端口被占用或者资源泄漏。我在本地调试的时候就遇到过,改完代码重启应用,结果报端口冲突,就是因为上一次的 SparkContext 没关干净。
3.2 local 模式和集群模式的配置差异
master这个参数决定了 Spark 跑在哪里。local[*]表示用本地所有 CPU 核心跑,local[2]表示用两个核心。这种模式适合开发和测试,不需要任何集群环境。
如果要跑在集群上,master要改成对应的集群地址。比如 YARN 模式是yarn,Standalone 模式是spark://host:7077。但这里有个关键点:在集群模式下,你不能在代码里硬编码 master 地址,因为不同环境的集群地址不一样。正确的做法是通过配置文件或者环境变量注入。
@Value("${spark.master:local[*]}") private String sparkMaster;这样在本地开发时用默认的local[*],部署到集群时通过配置覆盖成yarn。这个设计看起来简单,但实际项目中很多人会忽略,导致代码在本地跑得好好的,一上集群就挂。
还有一个参数spark.submit.deployMode,在 YARN 模式下可以设置为client或者cluster。client模式下 driver 跑在提交任务的机器上,cluster模式下 driver 跑在集群的某个节点上。对于 Spring Boot 应用来说,通常用client模式,因为 driver 需要和 Web 应用在一起,才能把结果返回给 HTTP 请求。
3.3 资源参数的设置与计算
Spark 的资源参数直接决定了任务能跑多快、能处理多大数据量。最核心的三个参数是spark.driver.memory、spark.executor.memory和spark.executor.cores。
spark.driver.memory控制 driver 的内存。driver 负责协调任务、收集结果,如果你的任务需要把大量数据 collect 回 driver,这个值要设大一点。一般 2g 到 4g 够用,但如果你的任务会返回几十万行数据,就要慎重了,因为 collect 操作会把所有数据拉到 driver 内存里,很容易 OOM。
spark.executor.memory控制每个 executor 的内存。这个值的计算有个经验公式:每个 executor 的内存 = 总内存 / executor 数量。比如集群有 100g 内存,你想开 10 个 executor,那每个就是 10g。但要注意,executor 内存里有一部分是给 Spark 自己用的(比如 shuffle 的缓冲区),实际能用来缓存数据的只有一部分。通常建议每个 executor 的内存不低于 4g,否则稍微大一点的数据集就会 spill 到磁盘。
spark.executor.cores控制每个 executor 的 CPU 核心数。这个值一般设 2 到 5 之间。设太小,executor 的并行度不够;设太大,一个 executor 上跑的 task 太多,GC 压力会很大。我个人的经验是设 4 比较均衡。
.config("spark.executor.memory", "4g") .config("spark.executor.cores", "4") .config("spark.executor.instances", "10")spark.executor.instances是 executor 的数量。这个值不是越大越好,因为每个 executor 都要和 driver 通信,数量太多会让 driver 成为瓶颈。一般来说,几十个 executor 已经能处理很大的数据量了。
4. HTTP 接口触发 Spark 任务的完整实现
4.1 Controller 层的设计
Controller 层的设计要遵循一个原则:接口要简单,参数要清晰,返回要明确。不要让前端传一堆 Spark 的配置参数过来,那样既不安全也不好维护。正确的做法是定义几个业务层面的参数,比如任务类型、数据范围、输出格式,然后在 Service 层把这些业务参数翻译成 Spark 的配置。
@RestController @RequestMapping("/api/spark") public class SparkJobController { @Autowired private SparkJobService sparkJobService; @PostMapping("/submit") public ResponseEntity<JobResult> submitJob(@RequestBody JobRequest request) { JobResult result = sparkJobService.executeJob(request); return ResponseEntity.ok(result); } @GetMapping("/status/{jobId}") public ResponseEntity<JobStatus> getStatus(@PathVariable String jobId) { return ResponseEntity.ok(sparkJobService.getJobStatus(jobId)); } }这里我设计了两个接口:一个提交任务,一个查询状态。为什么要分开?因为 Spark 任务通常是异步的,提交之后不会立刻返回结果。如果做成同步的,HTTP 请求会一直挂着,直到任务跑完,这期间如果任务跑了十分钟,前端就超时了。所以更好的做法是提交任务后立刻返回一个 jobId,前端拿着 jobId 轮询状态。
4.2 异步执行与任务状态管理
异步执行的核心是把任务丢到一个线程池里跑,然后立刻返回。这里要注意,Spark 的任务提交本身是异步的,但如果你在提交后调用collect()或者count()这种 action 操作,就会阻塞当前线程。所以要把整个任务执行逻辑放到一个独立的线程里。
@Service public class SparkJobService { private final ExecutorService executorService = Executors.newFixedThreadPool(5); private final Map<String, JobStatus> jobStatusMap = new ConcurrentHashMap<>(); public JobResult executeJob(JobRequest request) { String jobId = UUID.randomUUID().toString(); jobStatusMap.put(jobId, JobStatus.running()); executorService.submit(() -> { try { // 真正的 Spark 任务逻辑 Dataset<Row> result = runSparkJob(request); long count = result.count(); jobStatusMap.put(jobId, JobStatus.success(count)); } catch (Exception e) { jobStatusMap.put(jobId, JobStatus.failed(e.getMessage())); } }); return new JobResult(jobId, "submitted"); } }线程池的大小要根据你的集群资源来定。如果集群只能同时跑 5 个任务,线程池设成 10 也没用,多出来的任务只会排队等待。而且要注意,Spark 任务之间是会互相抢资源的,如果同时提交太多任务,每个任务分到的 executor 就少了,反而都跑得慢。
4.3 任务结果的返回方式
任务结果的返回有三种常见方式,各有适用场景。
第一种是直接返回小结果集。如果任务的结果只有几百行,可以直接collect()回来,转成 JSON 返回给前端。这种方式最简单,但只适合小数据量。
第二种是返回统计值。如果前端只需要知道总数、最大值、平均值这些,可以在 Spark 里算好,只返回几个数字。这种方式对内存最友好。
第三种是写入外部存储后返回路径。如果结果有几十万行,不适合直接返回,可以把结果写成 Parquet 或者 CSV 文件,存到 HDFS 或者对象存储上,然后返回一个下载链接。这种方式最适合大数据量的场景。
// 方式一:直接返回 List<Row> rows = result.collectAsList(); // 方式二:返回统计值 long total = result.count(); double avg = result.select(avg("amount")).first().getDouble(0); // 方式三:写入外部存储 result.write().mode("overwrite").parquet("hdfs://path/to/output");我个人的建议是:默认用方式三,除非你确定结果很小。因为方式一和方式二都有 driver OOM 的风险,而方式三把压力分散到了 executor 上,更安全。
5. Spark 配置优化的实战经验
5.1 序列化器的选择
Spark 默认用的是 Java 序列化器,性能一般。换成 Kryo 序列化器可以显著提升性能,尤其是在 shuffle 数据量大的时候。Kryo 的序列化速度比 Java 快好几倍,序列化后的数据也更小。
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.kryo.registrationRequired", "false")但 Kryo 有个坑:它需要你注册自定义的类,否则性能会打折扣。如果你不注册,Kryo 会把每个类的全名都写进序列化数据里,反而更占空间。所以如果你的任务里用了自定义的 POJO,最好注册一下。
.config("spark.kryo.classesToRegister", "com.example.demo.UserBehavior,com.example.demo.OrderInfo")5.2 shuffle 分区数的调整
spark.sql.shuffle.partitions这个参数默认是 200。这个默认值对于小数据量来说太大了,会导致大量的小任务,每个任务处理的数据量很少,调度开销反而成了瓶颈。对于中等数据量(几 GB),设成 50 到 100 比较合适。对于小数据量(几百 MB),设成 10 到 20 就够了。
.config("spark.sql.shuffle.partitions", "50")怎么判断设多少合适?一个经验法则是:每个分区的数据量在 100MB 到 200MB 之间比较理想。如果你知道总数据量是 5GB,那设 50 个分区,每个分区大约 100MB,就比较合适。
5.3 内存管理的调优
Spark 的内存管理分两块:execution 内存和 storage 内存。execution 内存用于 shuffle、join、sort 等操作,storage 内存用于缓存数据。默认情况下,两者各占 50%,但在某些场景下需要调整。
如果你的任务有很多 shuffle 操作,可以调大 execution 内存的比例:
.config("spark.memory.fraction", "0.8") .config("spark.memory.storageFraction", "0.3")spark.memory.fraction控制的是 Spark 内存占总内存的比例,默认 0.6。调大到 0.8 意味着给 Spark 更多内存,留给用户对象的内存就少了。spark.memory.storageFraction控制的是 storage 内存占 Spark 内存的比例,默认 0.5。调小到 0.3 意味着更多内存给 execution 用。
这两个参数需要根据实际任务的特点来调。如果任务经常 OOM,先调大spark.memory.fraction;如果任务经常 spill 到磁盘,调小spark.memory.storageFraction。
5.4 数据倾斜的处理
数据倾斜是 Spark 任务最常见的性能杀手。表现是:大部分 task 很快就跑完了,但有几个 task 跑得特别慢,甚至卡住不动。原因通常是某个 key 的数据量特别大,导致处理这个 key 的 task 成了瓶颈。
处理数据倾斜有几种常见方法。第一种是加盐打散,给倾斜的 key 加上随机前缀,让它分散到多个 task 上,最后再去掉前缀聚合。第二种是广播小表,如果 join 的一边是小表,可以用 broadcast join 避免 shuffle。第三种是拆分大 key,把倾斜的 key 单独拿出来处理,剩下的正常处理。
// 广播小表 Dataset<Row> result = largeTable.join( broadcast(smallTable), "join_key" );广播 join 的原理是把小表复制到每个 executor 上,这样就不需要 shuffle 大表了。但小表不能太大,一般建议不超过几百 MB,否则 driver 和 executor 的内存压力会很大。
6. 集群模式下的部署与排错
6.1 打包与提交的注意事项
集群模式下,你的 Spring Boot 应用需要打包成一个可执行的 jar,然后提交到集群上运行。但这里有个问题:Spark 的依赖不能打进包里,否则会和集群上的 Spark 冲突。所以打包的时候要用providedscope,或者用 shade 插件把 Spark 的类排除掉。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <configuration> <artifactSet> <excludes> <exclude>org.apache.spark:*</exclude> <exclude>org.scala-lang:*</exclude> </excludes> </artifactSet> </configuration> </plugin>提交任务的时候,用spark-submit命令,指定你的 jar 包和主类。但注意,如果你的应用是一个 Web 服务,它需要一直运行,而不是跑完就退出。这时候spark-submit的用法就和普通的批处理任务不一样了。
一种做法是用spark-submit提交一个长期运行的服务,但这种方式不太优雅,因为 Spark 的提交机制本来是为批处理设计的。另一种做法是把 Spring Boot 应用独立部署,然后在代码里通过SparkSession.builder().master("yarn")连接到集群。这种方式更灵活,但需要你的应用能访问到集群的配置。
6.2 常见的类冲突与排查方法
类冲突是集群模式下最常见的问题。表现是:本地跑得好好的,一上集群就报NoSuchMethodError或者ClassNotFoundException。原因通常是集群上的某个 jar 版本和你应用里的版本不一致。
排查方法分三步。第一步,看异常堆栈,找到是哪个类出的问题。第二步,用mvn dependency:tree看你的应用里这个类来自哪个 jar。第三步,去集群上确认这个 jar 的版本。如果版本不一致,要么升级你的依赖,要么把冲突的类 shade 掉。
mvn dependency:tree | grep spark还有一个工具叫jar tf,可以看 jar 包里有哪些类。如果你怀疑某个类被重复打包了,可以用这个命令确认。
6.3 日志与监控的接入
集群模式下,日志不会直接输出到你的控制台,而是分散在各个 executor 的节点上。所以你需要一套日志收集方案。最简单的做法是在 Spark 配置里设置日志级别,把日志写到 HDFS 上。
.config("spark.eventLog.enabled", "true") .config("spark.eventLog.dir", "hdfs://path/to/spark-logs")然后通过 Spark 的 Web UI 查看任务执行情况。Web UI 默认在 4040 端口,可以看到每个 stage 的耗时、每个 task 的数据量、shuffle 的读写量等信息。这些信息对于调优非常有用。
如果你想把日志接入到现有的监控系统,可以在应用里用 Logback 或者 Log4j2 配置日志输出,把 Spark 的日志和应用的日志统一收集。但要注意,Spark 的日志量很大,如果全部收集,存储成本会很高。建议只收集 WARN 和 ERROR 级别的日志。
7. 几个我踩过的坑和对应的解法
7.1 SparkSession 重复创建导致端口冲突
前面提到过,SparkSession 要单例。但即使你用了单例,如果在测试的时候频繁重启应用,可能会遇到 4040 端口被占用的问题。这是因为上一次的 SparkContext 没有完全关闭,端口还没释放。
解决办法是在@PreDestroy里调用sparkSession.stop(),并且确保stop()被调用。如果还是不行,可以在启动时检查端口是否被占用,如果被占用就换一个端口。
.config("spark.ui.port", "4041")7.2 collect 操作导致 driver OOM
这个坑我踩过好几次。任务在 executor 上跑得好好的,一到 collect 就 OOM。原因是 collect 会把所有数据拉到 driver 的内存里,如果数据量大,driver 的内存根本不够。
解决办法是:尽量不要用 collect。如果一定要用,先估算数据量。比如你知道结果有 10 万行,每行 1KB,那就是 100MB,driver 内存设 2g 是够的。但如果结果有 1000 万行,那就别 collect 了,直接写文件。
7.3 任务卡在某个 stage 不动
这种情况通常是数据倾斜或者资源不足。先看 Spark UI,找到卡住的 stage,看它的 task 分布。如果大部分 task 都完成了,只有几个没完成,那就是数据倾斜。如果所有 task 都很慢,那就是资源不足,需要加 executor 或者加内存。
还有一种可能是 shuffle 的文件太多,导致磁盘 IO 成了瓶颈。这时候可以调大spark.sql.shuffle.partitions,减少每个分区的文件数。
7.4 序列化异常
Task not serializable是 Spark 里最常见的异常之一。原因是你在闭包里引用了一个不可序列化的对象,比如数据库连接、Spring 的 Bean 等。
解决办法是:在闭包里只引用可序列化的对象。如果必须引用一个不可序列化的对象,可以把它声明为transient,然后在 executor 端重新初始化。
public class MyJob implements Serializable { private transient DatabaseConnection conn; public void run() { // 在 executor 端重新初始化 if (conn == null) { conn = createConnection(); } } }这个坑的本质是:Spark 会把闭包序列化后发送到 executor 上执行,如果闭包里引用了 driver 端的对象,那个对象必须能被序列化。Spring 的 Bean 通常不能被序列化,所以不要在 Spark 的闭包里直接引用@Autowired的 Bean。
8. 一些实用的配置模板和参数速查
8.1 local 模式的最小配置
SparkSession.builder() .appName("local-job") .master("local[*]") .config("spark.sql.shuffle.partitions", "10") .config("spark.driver.memory", "2g") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .getOrCreate();这套配置适合本地开发和测试,启动快,资源占用少。
8.2 集群模式的推荐配置
SparkSession.builder() .appName("cluster-job") .master("yarn") .config("spark.submit.deployMode", "client") .config("spark.executor.memory", "4g") .config("spark.executor.cores", "4") .config("spark.executor.instances", "10") .config("spark.sql.shuffle.partitions", "100") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.driver.userClassPathFirst", "true") .getOrCreate();这套配置适合中等规模的数据处理任务,可以根据实际情况调整 executor 的数量和内存。
8.3 关键参数速查表
| 参数 | 默认值 | 建议值 | 作用 |
|---|---|---|---|
| spark.sql.shuffle.partitions | 200 | 10-100 | shuffle 后的分区数 |
| spark.executor.memory | 1g | 4g-8g | 每个 executor 的内存 |
| spark.executor.cores | 1 | 2-5 | 每个 executor 的 CPU 核心数 |
| spark.executor.instances | 2 | 根据集群定 | executor 的数量 |
| spark.serializer | Java | Kryo | 序列化器 |
| spark.memory.fraction | 0.6 | 0.6-0.8 | Spark 内存占总内存比例 |
| spark.memory.storageFraction | 0.5 | 0.3-0.5 | storage 内存占 Spark 内存比例 |
这张表可以贴在显示器旁边,调参的时候随时参考。但记住,没有一套参数是万能的,每个任务的数据量、计算逻辑、集群环境都不一样,最好的参数是测出来的,不是抄出来的。
8.4 一个完整的任务执行示例
最后给一个完整的示例,把前面的内容串起来。这个示例接收一个 HTTP 请求,读取一个 Parquet 文件,做一个 group by 聚合,然后把结果写回 Parquet。
@Service public class SparkJobService { @Autowired private SparkSessionManager sessionManager; public JobResult executeJob(JobRequest request) { String jobId = UUID.randomUUID().toString(); executorService.submit(() -> { try { SparkSession spark = sessionManager.getSession(); Dataset<Row> input = spark.read() .parquet(request.getInputPath()); Dataset<Row> result = input .groupBy("category") .agg(sum("amount").as("total_amount")); result.write() .mode("overwrite") .parquet(request.getOutputPath()); jobStatusMap.put(jobId, JobStatus.success()); } catch (Exception e) { jobStatusMap.put(jobId, JobStatus.failed(e.getMessage())); } }); return new JobResult(jobId, "submitted"); } }这个示例虽然简单,但涵盖了整合的核心流程:接收请求、获取 SparkSession、执行计算、写入结果、更新状态。你可以在这个基础上根据实际需求扩展,比如增加参数校验、增加重试逻辑、增加任务超时处理等。
我在实际项目里用这套方案跑了半年多,处理了上千个任务,整体稳定性还不错。最大的体会是:Spark 的配置调优是一个持续的过程,不要指望一次调好就一劳永逸。随着数据量的增长和业务逻辑的变化,之前合适的参数可能就不合适了,需要定期 review 和调整。另外,日志和监控一定要做好,否则出了问题只能靠猜,效率会非常低。