☰
SparkConf源码拆解:配置加载、优先级与序列化克隆
2026/10/26 11:30:22 网站建设 项目流程

我至今记得第一次点开SparkConf.scala的那个下午,满屏的ConcurrentHashMap、Option、clone(),加上一堆getSizeAsBytes、getTimeAsSeconds,加起来两百来行,我却看了快两个小时。当时我正在改一份别人给的工程代码,跑起来是能跑,可只要动一下参数就报错,要么是内存分配不对,要么是提交到集群后完全不按我设的来。后来才明白,问题几乎全出在配置系统这一层,而这一层的入口,就是SparkConf。

这篇是 Spark 源码阅读系列的第一篇,只啃SparkConf这一个类。我会把它的字段结构、配置加载顺序、类型转换的实现、序列化和克隆这几个最容易被忽略的点全部拆开讲,配上能直接跑的最小 Demo 和一份可抄的配置模板。适合三类人看:正在做毕业设计、需要基于 Spark 做数据分析或推荐系统,但只敢照着模板改参数的;手上有一份现成工程代码、跑得通却讲不清每个参数为什么这么写的;以及打算系统读一遍 Spark 源码、想找个软柿子先热身的。标题后半段那几个字我就不复述了,代码拿来用没问题,但答辩时被追问一句“这个spark.sql.shuffle.partitions你设成 200 的依据是什么”答不上来,那才尴尬。这篇文章就是帮你把那两百行,变成你自己能讲出来的东西。

1. 为什么源码阅读要从 SparkConf 这个“小类”开局

1.1 配置是 Spark 一切行为的起点

打开SparkContext的构造函数,第一件事就是接收一个SparkConf:

class SparkContext(config: SparkConf) extends Logging { ... private[spark] val conf = config.clone() ... }

你看,进来第一件事就是clone()。为什么?因为SparkContext不希望外部再持有同一个引用去改它的配置,这属于典型的防御性拷贝。这个细节很能说明SparkConf在整个体系里的位置——它不是个可有可无的工具类,而是整个运行时的参数总闸。你在spark-submit里写的每一个--conf,在代码里写的每一次set,在spark-defaults.conf里留的每一行,最后都要汇到这个对象里,再由它分发给调度器、内存管理器、Shuffle 服务、SQL 优化器。

我习惯把它类比成家里那台中央空调的控制面板:面板本身不制冷,但你设的每一个温度、风速、模式,最终都会决定压缩机怎么转。SparkConf就是这样一块面板,你不了解它,就只能靠“凭感觉调参数”来优化作业,调好了不知道为什么好,调坏了也找不到原因。

1.2 两百多行代码里藏着的设计取舍

SparkConf的代码量很小,但含金量不低,因为它要在几个相互冲突的目标之间做平衡。第一是轻:它必须能被序列化、能被克隆、能在 driver 端快速构造,不能依赖重量级的组件。第二是宽容:用户可能传"1g"、"1024m"、"1073741824"三种写法表达同一个内存值,它得都能认。第三是可追溯:配置被谁覆盖了、哪个 key 已经废弃了,它得能给出提示。第四是线程相对安全:driver 启动阶段可能有多个线程在往里写。

这几个目标凑在一起,就决定了它的数据结构选择、方法签名设计,以及为什么它明明叫“配置”却还要extends Serializable。读懂这些取舍,比背下来十个调优参数有用得多,因为你在别的框架里遇到类似设计时,能一眼看出对方的意图。

1.3 读之前建议补的两块前置知识

不用补太多,但有两块必须有。第一块是JVM 的系统属性,也就是System.getProperties()那套东西,因为new SparkConf()默认会从这里捞spark.开头的配置,这是很多“我明明没设,它怎么自己有了”的根源。第二块是Scala 的Option和伴生对象,SparkConf里大量使用Option表达“可能没有”,而废弃配置的映射表放在伴生对象里,不熟悉这两个语法点,读起来会很别扭。

ConcurrentHashMap稍微了解下就行,知道它比HashMap多了并发写的能力、比Hashtable性能好,且不允许 null 值——最后这点很重要,它正好解释了为什么set方法里要先判空再put,因为如果直接扔个 null 进去,ConcurrentHashMap会抛NullPointerException,而框架选择自己先抛,好给出更明确的错误信息。

2. 拆开 SparkConf 的字段与方法骨架

2.1 settings:一个 ConcurrentHashMap 撑起整个配置系统

核心字段就一行:

private val settings = new ConcurrentHashMap[String, String]()

注意,key 和 value 全是String。不管你要设的是内存大小、线程数还是布尔开关,进到这个 map 里统统变成字符串,等到读取的时候再做类型转换。这个设计乍看有点“退步”,明明可以用Map[String, Any],为什么要全存字符串?

理由有三个。其一,序列化和跨进程序列化极方便,字符串没有多态问题,从 driver 传到 executor、写进日志、塞进 JSON 都不用特殊处理。其二,配置来源本来就全是字符串——命令行参数、配置文件、系统属性,没有一个是强类型的。其三,类型转换的时机可以推迟到真正使用的地方,这样即使某个参数写错了格式,只要那个模块没被触发,作业也不会在启动阶段就崩掉。

代价当然也有:错误发现得晚。你把spark.executor.memory写成"1G "(末尾多个空格),set阶段毫无反应,直到某个 executor 启动时才炸。这个坑后面我会单独讲怎么排查。

2.2 loadDefaults 与 spark.* 系统属性的加载时机

构造函数的签名是class SparkConf(loadDefaults: Boolean),还有一个无参构造def this() = this(true)。loadDefaults为true时,构造过程中会去遍历系统属性:

if (loadDefaults) { loadFromSystemProperties(false) } private[spark] def loadFromSystemProperties(silent: Boolean): SparkConf = { for ((key, value) <- Utils.getSystemProperties if key.startsWith("spark.")) { set(key, value, silent) } this }

注意那个key.startsWith("spark.")的过滤条件,它只捞前缀为spark.的属性。这条规则解释了一个现象:为什么你用spark-submit --conf传的参数,代码里new SparkConf()之后就自动有了——提交脚本会把这些参数写进子进程的系统属性,构造时被捞了进来。

也解释了一个更隐蔽的现象:如果你在同一个 JVM 里跑了多个 Spark 应用(比如测试代码里连续创建多个SparkSession),前一个应用设置的系统属性可能会污染后一个。我遇到过测试环境里两个用例互相干扰,查了半天,最后发现是某个用例调了System.setProperty("spark.sql.shuffle.partitions", "1")没清掉。所以写测试时,要么传loadDefaults = false,要么在每个用例结束后清理属性。

2.3 set、setIfMissing、setMaster、setAppName 的区别在哪

这四个方法名字很像,行为差别不小。set(key, value)是无条件覆盖,不管之前有没有值,直接put进去。setIfMissing(key, value)是只在缺失时写入,实现上就是个先查后写:

def setIfMissing(key: String, value: String): SparkConf = { if (!settings.containsKey(key)) { set(key, value) } this }

这个方法在写库或者写公共工具类时特别有用。比如你封装了一个数据处理工具,内部需要spark.sql.shuffle.partitions有个默认值,但又不该覆盖调用方自己设的值,那就用setIfMissing。用set的话,你会在不知不觉中把别人的调优全干掉。

setMaster和setAppName本质上就是两个语法糖:

def setMaster(master: String): SparkConf = this.set("spark.master", master) def setAppName(name: String): SparkConf = this.set("spark.app.name", name)

真正值得注意的是它们在新版本里的状态。从 Spark 2.x 开始,官方更推荐用SparkSession.builder.master(...).appName(...)来设,SparkConf上的这两个方法虽然还在,但在一些场景下会被标记为不推荐。更实际的问题是:代码里硬编码 master 是个坏习惯。你在本地写setMaster("local[*]")跑得很爽,等到要提交到集群时,如果忘了删这一行,行为就可能和你预期的不一样,而且排查起来非常费劲,因为从日志上看它确实“启动成功了”,只是一直在本地跑。

我的建议是:master只在提交命令或配置文件里指定,代码里一律不写。这样同一份 jar 既能本地调试也能上集群,不会出现“在 A 机器上正常、在 B 机器上单机跑”的诡异情况。

2.4 读取侧:get、getOption 与一整套类型转换家族

读配置的方法分三档。最底层是getOption,返回Option[String],找不到就是None;中间层是get(key),找不到直接抛NoSuchElementException;最上层是一堆带默认值的版本,getInt(key, defaultValue)这种,找不到就用默认值,最温和。

def get(key: String): String = { val v = getOption(key).getOrElse(throw new NoSuchElementException(key)) ... }

类型转换的家族成员不少:

方法返回类型支持的写法示例典型参数
getIntInt"4"spark.executor.cores
getLongLong"120"spark.network.timeout的毫秒值
getDoubleDouble"0.6"spark.memory.fraction
getBooleanBoolean"true"/"false"spark.sql.adaptive.enabled
getSizeAsBytesLong"1g"、"512m"、"1024"spark.executor.memory
getSizeAsMbLong"2048m"会自动换算成 2048spark.sql.autoBroadcastJoinThreshold
getTimeAsSecondsLong"5s"、"1min"、"300"spark.executor.heartbeatInterval
getTimeAsMillisecondsLong"500ms"、"2s"各类超时参数

getSizeAsBytes和getTimeAsSeconds这两类是最容易出错的地方,因为它支持“带单位”的写法,而单位写错、大小写写错、或者漏了单位,行为完全不同。"1G"和"1g"一般都能认(内部会转小写),但"1gb"在某些版本里就不认;"1m"在内存语境下是兆字节,在时间语境下就是分钟,同一个字母两种含义,全看你调的是哪个方法。

3. 配置优先级:为什么你的参数总是“不生效”

3.1 四个来源的覆盖顺序,理清楚就少踩一半坑

Spark 的配置来源基本有四层,从“最容易被覆盖”到“最强”大致是这样:

层级来源写法特点
1代码中显式设置conf.set(...)最后执行,通常优先级最高
2提交命令参数--conf k=v、--driver-memory写入系统属性,被构造函数读取
3配置文件spark-defaults.conf提交时被读取,优先级低于命令行
4框架默认值代码里的defaultValue前面都没给时才生效

核心规则只有一条:后写的覆盖先写的。new SparkConf()先把系统属性(含提交参数和配置文件里的内容)灌进去,之后你在代码里调set,自然是你的值赢。看起来很直白,但实际出问题时往往是因为你根本不知道第二层和第三层“悄悄”生效了——比如集群管理员在spark-defaults.conf里配了spark.sql.shuffle.partitions=2000,你在代码里没设,跑出来就是 2000,而不是你以为的默认 200。

3.2 提交参数与代码参数冲突时的实测对比

我做了一组对照实验,本地模式,只改spark.sql.shuffle.partitions:

// 场景 A:代码里不设,命令行传 100 // spark-submit --conf spark.sql.shuffle.partitions=100 ... val conf = new SparkConf() println(conf.get("spark.sql.shuffle.partitions", "未设置")) // 输出:100 // 场景 B:代码里设 50,命令行传 100 val conf = new SparkConf().set("spark.sql.shuffle.partitions", "50") println(conf.get("spark.sql.shuffle.partitions", "未设置")) // 输出:50

结论很清楚:代码里的显式设置会盖掉提交参数。这带来一个反直觉的后果——如果你在代码里为了本地测试方便写死了一堆参数,上线时改命令行是改不动的。所以从工程规范上讲,业务代码里除了极少数必须的参数,其余都应该留给提交命令或配置文件,代码只负责读取。

还有一类参数属于“只认提交端”,最典型的是资源类参数,比如spark.executor.memory、spark.executor.cores、spark.driver.memory。这些参数虽然写在SparkConf里,但真正生效的时机是在 driver 向资源管理器申请容器之前。你在local[*]模式下把它们设成任何值都不会有实际效果,因为本地模式根本没有独立的 executor JVM,所有代码都在同一个进程里跑。这个认知非常重要:我见过太多人在本地模式下调spark.executor.memory=8g,然后纳闷为什么还是 OOM——因为本地模式下该调的是spark.driver.memory。

3.3 一份可直接抄的配置模板与逐项说明

下面这份是我常用的基础模板,偏向中小规模批处理场景,代码里只用setIfMissing兜底,避免覆盖外部配置:

val conf = new SparkConf() // 序列化器:默认是 Java 序列化,体积大、速度慢,Kryo 一般能快 2 到 10 倍 conf.setIfMissing("spark.serializer", "org.apache.spark.serializer.KryoSerializer") // Shuffle 分区数:默认 200。数据量小时这 200 个 task 大部分在空转 conf.setIfMissing("spark.sql.shuffle.partitions", "48") // 自适应查询执行:让优化器根据运行时统计信息自动合并小分区 conf.setIfMissing("spark.sql.adaptive.enabled", "true") conf.setIfMissing("spark.sql.adaptive.coalescePartitions.enabled", "true") // 广播 join 阈值:默认 10MB,适当调大能减少 shuffle conf.setIfMissing("spark.sql.autoBroadcastJoinThreshold", "52428800") // 内存比例:执行内存与存储内存共用的比例,默认 0.6 conf.setIfMissing("spark.memory.fraction", "0.6") // 本地模式下的 driver 内存(集群模式下不生效,由提交端决定) conf.setIfMissing("spark.driver.memory", "4g")

每一项都不是拍脑袋来的。spark.sql.shuffle.partitions改成 48,是因为在单机或小集群、数据量几十万到几百万行的场景下,200 个分区意味着每个 task 处理的数据极少,调度开销反超计算开销;经验公式是“分区数大约等于总核数的 2 到 3 倍”,4 核机器给 12 到 12 个分区其实也够,我给 48 是留了余量。广播阈值调到 50MB 是因为现代机器内存普遍宽裕,一张几万行的小维表广播出去完全没问题,省下的 shuffle 收益远大于那点内存开销。

spark.memory.fraction设 0.6 是保持默认,写出来是为了提醒你它的存在——只有当你的作业有大量缓存需求且计算不重时,才考虑降到 0.4 到 0.5;反之纯计算型作业可以提到 0.7 左右,但别更高,否则对象创建空间不够会频繁 GC。

4. 动手实现一个能打印全部配置的验证 Demo

4.1 环境与依赖准备

依赖就三行,别引太多:

libraryDependencies ++= Seq( "org.apache.spark" %% "spark-core" % "3.5.0" % "provided", "org.apache.spark" %% "spark-sql" % "3.5.0" % "provided" )

标provided是因为打包时不应该把 Spark 打进去,集群上有。如果你在 IDE 里本地跑,把provided去掉或者选compile作用域都行。Scala 版本建议 2.12 或 2.13,跟你的 Spark 版本对应,别混。

这里有个新手高频问题:本地跑的时候报NoClassDefFoundError,九成是provided作用域导致 IDE 运行时类路径里没有这个包。IDEA 里勾上 “Include dependencies with Provided scope” 就能解决。

4.2 代码实现与逐行解释

import org.apache.spark.SparkConf object ConfInspect { def main(args: Array[String]): Unit = { // 1. 构造时加载 spark.* 系统属性 val base = new SparkConf() // 2. 克隆一份,模拟下游模块继续加工,不影响 base val derived = base.clone() derived.setIfMissing("spark.sql.shuffle.partitions", "48") derived.set("spark.demo.tag", "handwritten") // 3. 类型转换的几个典型用法 val shufflePartitions = derived.getInt("spark.sql.shuffle.partitions", 200) val driverMem = derived.getSizeAsBytes("spark.driver.memory", 1024L * 1024 * 1024) val timeoutSec = derived.getTimeAsSeconds("spark.network.timeout", 120L) println(s"shuffle partitions = $shufflePartitions") println(s"driver memory = ${driverMem / 1024 / 1024} MB") println(s"network timeout = $timeoutSec s") // 4. 打印全部配置,排查问题的第一板斧 derived.getAll.sortBy(_._1).foreach { case (k, v) => println(f"$k%-60s = $v") } // 5. 判断某个 key 是否真的存在 println(s"demo tag exists? ${derived.contains("spark.demo.tag")}") } }

逐行说一下意图。第一行new SparkConf()带默认的loadDefaults = true,会去捞系统属性,这样你用spark-submit --conf传进来的东西就都能看到。第二步用clone()而不是直接用base,是为了演示“配置副本”这个模式——公共库往里加默认值时,永远不要污染调用方传进来的那份。第三步的三个get分别演示了整数、字节、时间三种转换,特意都传了默认值,这样即使 key 不存在也不会抛异常。

最后那个getAll.sortBy看起来不起眼,但它是排查配置问题最有效的手段。你想象一下:一份作业有三十多个参数,你只记得自己设了五个,剩下二十多个从哪来的完全不知道。把它们全打出来,按 key 排序,很多时候问题一眼就看见了——某个你以为默认的 key,其实被配置文件设成了别的值。

4.3 运行结果怎么读,看什么

跑完之后输出大概长这样(节选):

shuffle partitions = 48 driver memory = 1024 MB network timeout = 120 s spark.app.name = ConfInspect spark.demo.tag = handwritten spark.driver.host = 192.168.x.x spark.driver.port = 40213 spark.master = local[*] spark.network.timeout = 120s spark.serializer = org.apache.spark.serializer.JavaSerializer

有三类信息值得盯。第一类是spark.app.name、spark.master这种由提交端自动注入的,如果你没设却出现了,说明环境里有东西在帮你设,得搞清楚是谁。第二类是spark.driver.host、spark.driver.port这种运行时才确定的,看到它们说明SparkConf在启动过程中被回写过,这也是为什么它必须是可写的而不是只读的。第三类是spark.serializer,如果你看到JavaSerializer,说明你的 Kryo 设置没生效——要么 key 拼错了,要么设在了错误的时机(比如在SparkContext建好之后才设)。

更权威的验证方式是 Spark UI。作业运行起来后打开 Web 界面,进 Environment 标签页,上面会列出当前生效的全部 Spark Properties,那是运行时实际用的值,比你在代码里打印的更可信,因为作业结束后SparkConf可能还会被改动。

5. 序列化、克隆与并发:最容易被误解的三件事

5.1 NotSerializableException 的三种典型现场

SparkConf声明了extends Serializable,但你在作业里十有八九会遇到跟它相关的序列化异常。第一种现场:在map或filter里直接引用了外层的conf变量。闭包会被序列化发到 executor,而conf里带着ConcurrentHashMap,虽然类实现了Serializable,但如果链路上还有别的不可序列化对象(比如你自定义的类没实现Serializable),异常就会冒出来,错误信息往往指向SparkConf,实际元凶却是别的。

第二种现场:在算子里引用了SparkContext。SparkContext明确不是可序列化的,因为它持有调度器、监听总线等一大堆运行时资源。这时候报错信息通常很直白,但我见过有人为了“消灭红色报错”,强行给包装类加extends Serializable,结果运行期才炸,反而更难查。

第三种现场,也是我觉得最值得说的:你在 driver 端定义了一个工具类实例,里面持有conf,然后把这个实例传进map。解决思路不是让conf可序列化,而是把需要的值提前取出来:

// 反面写法:闭包捕获 conf rdd.map(x => x * conf.getInt("factor", 1)) // 正面写法:先把值取成局部变量,只有 Int 被序列化 val factor = conf.getInt("factor", 1) rdd.map(x => x * factor)

这个改法的收益不只是“能跑”,还避免了每个 task 都去 map 里查一次配置、做一次字符串转整数。配置是 driver 端的东西,算子是 executor 端的逻辑,这条边界划清楚了,一大类莫名其妙的问题就消失了。

5.2 clone 与 setIfMissing 的组合用法

clone()的实现很朴素:新建一个loadDefaults = false的实例,把当前 map 里的键值对逐个塞进去:

override def clone(): SparkConf = { val cloned = new SparkConf(false) settings.entrySet().asScala.foreach { e => cloned.set(e.getKey, e.getValue) } cloned }

注意new SparkConf(false)这个参数,它保证克隆出来的副本不会再去读一遍系统属性。如果写成new SparkConf(true),那你在克隆之后如果系统属性变了,副本里就会多出一些原本没有的配置,逻辑上就不叫“克隆”了。这个细节很能体现设计者的严谨。

这个组合在实际工程里特别有用。比如你写了一个特征处理模块和一个模型训练模块,都想设spark.sql.shuffle.partitions的默认值,但默认值不一样。做法是:SparkSession拿到的conf先clone一份给特征模块,再clone一份给训练模块,各自用setIfMissing加自己的默认值。这样两个模块互不干扰,调用方也可以通过在入口处显式设置来统一覆盖两者。这种“分层配置”的思路,在稍微复杂一点的工程项目里几乎是必需品,否则配置的相互覆盖会变成一场灾难。

5.3 关于线程安全,别想当然

settings用的是ConcurrentHashMap,所以单个put和get是线程安全的。但这不等于整个SparkConf是线程安全的。setIfMissing是“先containsKey再set”的两步操作,中间没有任何锁,两个线程同时判断同一个 key 不存在,然后都写进去,后写的赢——结果虽然不会崩,但“只设一次”的语义就破了。

实践中这个问题很少暴露,因为SparkConf的写操作几乎集中在 driver 启动阶段,那会儿基本是单线程的。但我确实遇到过一种情况:在自定义的监听器里异步往conf里补写一个统计用的 key,跟主线程的初始化撞上了,导致偶尔读到一个旧值。解决方式很简单,配置就是配置,别在运行时动态改它,要传动态值就用累加器或者广播变量。这个原则我在多个项目里反复验证过:把SparkConf当成构造期的一次性输入,而不是运行期的共享状态,能省掉大量诡异的时序问题。

6. 踩坑实录与排查清单

6.1 常见问题速查表

这张表是我这些年攒下来的,按“现象”查最快:

现象最可能的原因处理方式
参数明明设了,UI 上显示的还是旧值代码里设的时机晚于SparkContext创建把所有set挪到SparkContext构造之前
改了命令行参数没反应代码里有硬编码set覆盖了它把代码里的set改成setIfMissing
getInt抛NumberFormatException值带了单位或空格,比如"4 "用trim,或改用对应的 size/time 方法
getSizeAsBytes结果差了一千倍单位大小写或写法不被识别统一写小写g、m、k,别写gb
本地跑正常,提交后 OOMexecutor 内存是提交端决定的调--executor-memory而不是代码里的set
本地模式下调 executor 参数无效本地模式没有独立 executor改调spark.driver.memory
打印出来的配置里有一堆陌生的 key集群的spark-defaults.conf在起作用用getAll全量打印后逐条确认来源
同样的代码两次运行结果不一致前一个应用的系统属性残留测试里用loadDefaults = false或清理属性

6.2 配置不生效的五步排查法

我总结了一个固定顺序,基本能覆盖九成情况。第一步,打印。在SparkContext创建之后立刻getAll全量输出,确认你设的那个 key 到底在不在、值是多少。很多时候问题到这一步就结束了,因为你会发现 key 拼错了,比如把spark.sql.shuffle.partitions写成spark.shuffle.partitions,后者是个完全不同的、旧版本的参数。

第二步,看 UI。作业跑起来后去 Environment 页对比,确认运行时真实生效的值。这一步能区分“设进去了但没用”和“根本没设进去”两种情况,方向完全不同。

第三步,找覆盖源。搜代码里所有对这个 key 的set调用,再看提交脚本、spark-defaults.conf、环境变量。把这几处列出来,按“后写覆盖先写”的规则推一遍,答案通常就出来了。

第四步,确认生效时机。有些参数必须在SparkContext之前设,比如序列化器;有些则必须在之后,比如 SQL 的运行时参数。如果你在SparkSession建好之后再去改spark.serializer,那是绝对无效的,因为它已经被用来创建序列化管理器了。

第五步,怀疑版本。同一个参数在不同大版本之间默认值可能变,甚至可能被废弃。看日志里的 deprecation 警告,SparkConf在set的时候会检查废弃配置并打日志,只是很多人不看 WARN 级别。

有个小技巧:conf.toDebugString会把当前配置整理成一段可读文本,直接塞进日志里,出问题时把这段连同作业 ID 一起存档,事后复盘比重跑一遍快得多。

6.3 手上有一份现成代码,怎么把它变成自己的

说回开头那个话题。不管你手上那份代码是从哪来的,能跑通只是起点,真正决定你能不能讲清楚的,是你对每一行配置的理解程度。我给一个很笨但有效的办法:把工程里所有出现过spark.前缀的字符串 grep 出来,列成一张表,三列——参数名、当前值、为什么是这个值。第三列写不出来的,就去查这个参数属于哪个模块、默认值是多少、调大调小分别影响什么。

这个动作做完,你会发现大部分参数其实可以删掉,因为它们在当前数据规模下根本不影响结果;剩下的少数几个,才是真正需要调的。我见过一份毕设工程里有四十多个配置,一梳理,真正起作用的不到十个,其余都是抄来抄去、层层叠加的“祖传配置”,有的甚至互相矛盾。把配置精简到最小可用集合,本身就是一个很有说服力的工作,答辩时你能说清楚“我删掉了这三十个,因为它们在 50 万行数据规模下没有实际收益”,比堆一堆参数更能体现你确实懂。

另外提醒一句,别在代码里写setMaster("local[*]")然后忘了删。本地能跑、提交后变成单机跑、日志上看不出明显异常,这是最耗时间的坑之一。把 master 交给提交命令,是唯一稳妥的做法。

最后分享一个我自己的习惯:第一次接触一个新版本的 Spark,我会先写一个只做new SparkConf()加getAll的空作业,跑一遍把默认配置全打出来,存成文件。以后再遇到“这个参数默认是多少”的问题,直接翻自己的存档,比查文档快,而且百分之百对得上你当前的运行环境——文档写的是社区的默认值,你集群上实际生效的,可能早就被配置文件改过了。

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

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

立即咨询