做数据开发这几年,Spark Scala的语法几乎每天都在写。业务方提需求时给的表结构、临时规则、跑批任务,最后落到代码里逃不过读数据、清洗、聚合、写回这几件事。这篇文章是我平日写Spark作业时最常用Scala语法与工作习惯的整理——从SparkSession怎么初始化,到DataFrame常见的列操作、聚合和join,再到UDF、广播变量、累加器和分区调整这些高频写法。适合刚开始用Scala写Spark的读者,也适合用惯SQL想换更灵活写法、以及被PySpark序列化问题折腾过的人。
动手之前先分享一个观点:Scala版本的Spark API虽然和Python版几乎一一对应,但真正拉开差距的是类型安全、函数式写法和IDE的自动补全。列名拼错在编译期就能发现,不用等作业跑到一半才报错。这也是我日常项目里坚持用Scala写Spark的主要原因。
1. 先搭出能跑的Spark Scala骨架:sbt与依赖坑
1.1 scalaVersion与sparkVersion怎么搭配
我自己的经验是:设置Spark版本的时候优先看集群上部署的Spark是什么,本地开发环境尽量与集群保持一致。Spark 3.x默认支持的是Scala 2.12,所以新工程的scalaVersion直接写2.12.18;将来升级到Spark 4.x,大概率要配合Scala 2.13。版本不对就会在编译阶段报一堆隐式转换找不到、方法签名对不上,这类错误看起来像是代码写错了,其实只要单独编译一个小demo就能确认是版本不匹配。
build.sbt里最常见的配置是这样:
ThisBuild / scalaVersion := "2.12.18" ThisBuild / sparkVersion := "3.5.1" libraryDependencies ++= Seq( "org.apache.spark" %% "spark-sql" % sparkVersion % "provided", "org.apache.spark" %% "spark-core" % sparkVersion % "provided" )依赖其实不用堆太多。spark-sql包会把spark-core、spark-catalyst这些核心模块带进来,所以只保留spark-sql就够了。加"provided"的意思很明确:编译时需要这些jar,但运行时使用集群上的Spark环境,不要把Spark本身打包进任务Jar。否则打出来的fat jar动不动就上百MB,提交时还会和集群上各种依赖冲突。
1.2 SparkSession初始化的标准姿势
Spark 2.0以后,写Spark Scala基本都从SparkSession开始,初始化代码比较固定:
val spark = SparkSession .builder() .appName("user_etl_job") .config("spark.sql.shuffle.partitions", "200") .enableHiveSupport() .getOrCreate()本地调试的时候建议加一行.master("local[*]"),并去掉enableHiveSupport,因为本机往往没有配置Hive元数据服务,开了反而可能报错。这里有个容易忽略的细节:代码里config设置的参数是惰性读取的,真正生效在提交任务、启动executor的时候。所以生产环境的参数最好放到spark-submit脚本里,代码里的那部分更多服务于本地开发和单机测试。
import spark.implicits._这行我也是每个作业必加。它提供字符串到Column的隐式转换,后面经常用到的$"colName"写法,以及rdd.toDF(),都依赖这个隐式导入。忘记加的时候,编译器会提示"value toDF is not a member of ...",看懂了就是没import。
1.3 打包的三种流派怎么选
实际开发中,我见过三种打包方式。第一种是sbt assembly,把所有依赖打进一个fat jar,适合任务Jar里需要带第三方库的场景。第二种是只编译项目代码,Spark依赖用provided,适合跑在已装好Spark的集群上。第三种是纯本地开发模式,直接在IDE里跑main函数,master设为local,靠本机安装的Spark运行。
这三种方式没有谁绝对好,取决于你有没有依赖冲突的烦恼。如果第三方库经常跟Spark内部依赖冲突,我建议走fat jar路线,并在assembly时把META-INF下的杂七杂八文件统一丢弃。简化版合并策略如下:
assembly / assemblyMergeStrategy := { case PathList("META-INF", _*) => MergeStrategy.discard case _ => MergeStrategy.first }跑通一次之后,我觉得整个环境准备里最花时间的不是搭工程,而是搞清楚provided和fat jar的边界。提供一个小判断标准:如果不打包Spark,就用provided;如果想让提交过程完全不依赖集群的Spark部署情况,就老老实实打fat jar,但要用spark-submit的--conf指定driver内存和executor内存,避免用默认值踩到集群资源限制。
2. 数据读写是日常大头:格式、分区与谓词下推
2.1 CSV与parquet:inferSchema能不用就别用
读CSV大概是刚上手时用得最多的场景,很多数据团队拿到的原始文件就是CSV。直接这么写也能跑:
val raw = spark.read .option("header", "true") .option("inferSchema", "true") .csv("/data/input/2025/01/01.csv")但inferSchema需要扫描一遍数据才能推断所有列的类型,文件越大,这一遍的开销越明显。更麻烦的是生产环境每天读同一个表,如果某天某列因为空值太多被推断成string,后面所有cast逻辑都会跟着变。所以我的习惯是:schema已知的表,提前定义好StructType读入,既快又稳。
schema定义示例:
val schema = StructType(Seq( StructField("uid", LongType, nullable = false), StructField("name", StringType, nullable = true), StructField("amount", DoubleType, nullable = true) )) val df = spark.read .schema(schema) .option("header", "true") .csv("/data/input/...")CSV另一个常踩的坑是转义和引号。字段里面带逗号、换行的时候,要确认option里的escape和quote有没有设置对。常用组合是.option("escape", """).option("quote", """).option("multiLine", "true"),但multiLine会显著降低解析效率,能用单行结构就尽量保持单行。
parquet读起来简单很多,spark.read.parquet("/data/warehouse/xxx")就完事;写parquet时最常用的是partitionBy:
df.write .mode(SaveMode.Overwrite) .partitionBy("dt", "hour") .parquet("/data/warehouse/xxx")partitionBy指定的列不会出现在数据文件里,而是作为目录层级出现。重读的时候直接过滤dt和hour,走的就是分区裁剪,能省掉大量IO。这是数仓表最常见的物理组织形式,也直接影响到下一层读数据的性能。
2.2 JDBC读写的关键option
从关系型数据库同步数据也是常见需求,Spark读写JDBC的性能受写法影响很大,核心写法如下:
val jdbcDF = spark.read .format("jdbc") .option("url", jdbcUrl) .option("dbtable", "(select * from ods_order where create_time >= '2025-01-01') t") .option("user", userName) .option("password", password) .option("fetchsize", "2000") .option("partitionColumn", "id") .option("lowerBound", 0) .option("upperBound", 100000000) .option("numPartitions", 8) .load()这里面最关键的往往是partitionColumn。如果不指定分区列,Spark会把整个查询丢到一个task里执行,十几万行可能无所谓,上千万行就慢得没法看。设置了partitionColumn、lowerBound和upperBound之后,Spark会把id字段按范围切成numPartitions段,同时派发多个连接并行拉取数据。
注意partitionColumn对应的列必须是可以比较数值大小的类型,字符串列不好切。dbtable里直接写子查询还可以把行级过滤提前到数据库端完成,减少网络传输数据量。写库的模式也不要乱用,能配合业务主键做Append就尽量别用Overwrite,防止把整表冲掉。
2.3 写回时的文件数量控制
很多人写完df.write.parquet("...")就以为结束,结果下一个任务读相同目录时发现几千个小文件,性能直接崩。规律很简单:写出的文件数量基本等于上游DataFrame的分区数。一个200分区的DataFrame写出去,就是200个文件。
如果希望下游负载小一点,写之前可以coalesce到合适的分区数:
df.coalesce(80) .write .mode(SaveMode.Overwrite) .parquet("/data/warehouse/xxx")如果目标是加速后续shuffle,就别coalesce太狠,否则并行度被压住,后面groupBy会非常吃力。这个拿捏取决于表被读取的频率。我个人的习惯是:离结果越近的表,写出的文件数量控制在50到100个;中间结果表反而不太管,因为反正会被再处理一次。
3. DataFrame里最高频的十几种Scala写法
3.1 select、withColumn、selectExpr:列操作的三种打开方式
select最简单的用法是df.select("uid", "name"),返回一个只包含这两个列的DataFrame。它适合从宽表里取出关心的几列,是裁剪数据宽度的第一步,也常用在join前只保留必要key。
withColumn用于新增列或替换列,不会丢掉原有列。比如:
df .withColumn("amount_round2", round(col("amount"), 2)) .withColumn("event_date", to_date(col("create_time"), "yyyy-MM-dd HH:mm:ss"))一个初学者常犯的问题是:想新增两列就写成一条很长的withColumn链,把依赖另一列的逻辑也塞进同一个withColumn里。其实只要记住withColumn是一列一列处理的,每次新增独立列,逻辑就清晰了。要实现基于条件的分支,用withColumn配合when更直观。
selectExpr是字符串形式的表达式,相当于把SQL的SELECT片段直接写进来:
df.selectExpr( "uid", "upper(name) as name_upper", "if(amount > 100, 'high', 'low') as amount_level", "date_format(create_time, 'yyyyMMdd') as day" )selectExpr最大优势是易读、好维护,业务同学review起来压力小;缺点是列名错误要等到运行时才暴露。我的习惯是DSL写核心逻辑,selectExpr写简单换算和一目了然的字段变换。
3.2 filter、when、groupBy:常写SQL的人怎么切换
从SQL思维切到DataFrame DSL,最顺手的就是filter。df.filter(col("status") === "SUCCESS")对应where status = 'SUCCESS';df.filter(col("status").isin("A", "B"))对应in。这里提醒一个细节:Scala里值为条件的相等判断写法是===,不是==。因为Column是一个表达式对象,用==会走Scala对象引用的比较逻辑,过滤结果会变得很奇怪,而且编译器通常不报错。
条件分支使用when/otherwise:
import org.apache.spark.sql.functions._ df.withColumn( "level", when(col("amount") >= 1000, "high") .when(col("amount") >= 100, "mid") .otherwise("low") )这和SQL的case when语义一样,但列名错误能在编译期暴露。聚合是DSL优势最明显的地方:
df.groupBy("dt", "channel") .agg( sum("amount").as("total_amount"), countDistinct("uid").as("uv"), collect_list("order_id").as("order_ids") )collect_list和sort_array这对组合经常用来避免二次join。场景很典型:同组数据想保留多个ID列表,后面展开或按顺序取首尾都可以。第一次接触时可能会担心collect_list把数据弄大,但只要组内ID数量有限,性能远好过反复join。
3.3 join的联接类型与广播hint
DataFrame的join写法有两类常见形态。等值key用Seq指定最省事:
val result = left.join(right, Seq("uid"), "left")这种写法不会产生重复的uid列,结果里只保留一列uid。另一种是用Column表达式,适合非等值连接或多条件连接:
left.join(right, left.col("uid") === right.col("uid"), "left_semi")join类型除了inner、left、right、full outer,日常很常用left_semi和left_anti。left_semi表示左表保留能与右表匹配上的行,left_anti表示左表保留匹配不上的行,这两个都只保留左表列。很多去重、找差异需求用它们就能干净解决,不需要先join再filter再drop。
广播hint也值得养成习惯。当小表只有几MB时,手写join可以给右边小表包一个broadcast:
import org.apache.spark.sql.functions.broadcast val result = left.join(broadcast(right), Seq("uid"), "inner")广播的本质是把小表完整发到每个executor,避免大表每个task都通过网络反复拉取小表数据。我见过不少任务是没做广播导致中间shuffle数据量翻几倍,手动加一个broadcast就立竿见影。
3.4 用SQL字符串还是用DSL:取决于你要什么
写长逻辑时经常纠结是直接DSL还是spark.sql("select ...")。我的真实体验是:两者不是互斥关系。
spark.sql的优势非常直观:SQL大家都熟,写复杂关联、子查询比DSL少敲很多括号;而且可以先用临时SQL工具验证结果,再原样搬进代码,沟通和排错成本低。
但SQL字符串在Scala代码里有明显劣势:没有编译期检查,列名函数名拼错在运行时才炸;字符串拼接变量时还容易引入语法错误;一旦逻辑嵌套太深,字符串会变得很难读。
我通常这样分配:ETL清洗逻辑用DSL,因为要复用、要单元测试、要防列名错;临时分析、多段嵌套查询用spark.sql加临时视图,因为快、直观:
df.createOrReplaceTempView("ods_tb") spark.sql(""" select dt, channel, sum(amount) as total from ods_tb group by dt, channel """)createOrReplaceTempView是会话内可见的,跨应用就不能指望它;多session场景要用createGlobalTempView,或者干脆物理落一张表。
4. UDF、广播变量与累加器:别让语法变成绊脚石
4.1 UDF的类型映射与序列化规则
Scala里写UDF比Python舒服的一点是类型是编译期的,定义一个UDF就是把普通函数转成Column函数:
val parseLevel = udf { (amount: Double) => if (amount >= 1000) "high" else "low" } df.withColumn("level", parseLevel(col("amount")))也可以注册成SQL函数,这样SQL字符串里也能用:
spark.udf.register("parse_level", (amount: Double) => if (amount >= 1000) "high" else "low") spark.sql("select parse_level(amount) from t")这里要重点提醒序列化问题:闭包里的内容会被序列化后分发给executor。最常见的坑是UDF里引用了class内部定义的def或val,这个class如果没有实现Serializable,任务直接报Task not serializable。更稳妥的办法是把UDF逻辑放到object里,或者把依赖的数据抽成无状态函数的入参。函数写得越纯,输入输出越明确,越不容易出现这类诡异错误。
4.2 广播变量为什么要先collect再broadcast
广播变量的典型用途是把一份小字典分发给所有executor,避免每个task重复拉取同一份数据。正确写法如下:
val dict = spark.read.parquet("/dict/region_map") .select("code", "region_name") .collect() .map(row => row.getString(0) -> row.getString(1)) .toMap val bc = spark.sparkContext.broadcast(dict)为什么要先collect?因为broadcast的是driver端内存里的对象。如果直接把DataFrame广播出去,实际上广播的是DataFrame的逻辑执行计划相关对象,内存占用和序列化成本都会失控。先collect成小型Map,广播的才是纯净的字典。
executor端使用时直接读bc.value,配合getOrElse处理缺失key:
df.map(row => row.getString(0) -> bc.value.getOrElse(row.getString(0), "unknown")) .toDF("code", "region")还有一点容易忽略:broadcast变量创建后不要中途改它的值。改driver端变量不会影响已经分发的executor副本,反而让代码逻辑变得不可预测。越稳定的字典越适合广播。
4.3 累加器只在driver端取值
累加器的典型用途是统计脏数据数量、记录处理行数。Scala写法很简单:
val badCount = spark.sparkContext.longAccumulator("bad_count") df.foreach { row => if (row.isNullAt(row.fieldIndex("amount"))) { badCount.add(1L) } } log.warn(s"bad rows: ${badCount.value}")这里有两个强制纪律。第一,只应该在driver端读value;在executor端读value看到的往往是本地初始值,没有任何全局意义。第二,要等一个action执行完成后再读,executor端的数据需要作业结束才汇总到driver。很多人把badCount.value打在foreach循环里面,等于白写。
如果想要更细粒度的统计,Spark 3.x推荐直接用Aggregator或者groupBy count;只有在统计"整体处理了多少行"这种全局累计数字时,累加器才更直接简单。
5. RDD转换与Dataset之间,容易写错的那几行
5.1 什么时候该回到RDD
DataFrame API已经覆盖了绝大多数场景,但仍有几种情况必须或者值得回到RDD:自定义分区、访问底层分区信息、精细控制shuffle,以及做图计算前把数据转成PairRDD的KV结构。
RDD最常用的转换写法:
val rdd = df.select("uid", "amount").rdd .map(row => (row.getLong(0), row.getDouble(1)))RDD和reduceByKey这类算子搭配时,能明显减少shuffle数据量。reduceByKey会在map端先做一次局部聚合,然后才传输,网络开销小很多。能用reduceByKey就尽量别用groupByKey,尤其是groupByKey后面接的value数组很大时,差别是肉眼可见的。这也是很多老工程师愿意在特定步骤用RDD的原因——它在shuffle控制上更细。
5.2 Row.getAs与类型擦除的常见问题
DataFrame转RDD后每一行是Row对象,取列最常用的是getAs[T]:
row.getAs[String]("name") row.getAs[Double]("amount")这里有个类型擦除的坑。getAs Seq[String] 这种写法在JVM运行时,泛型信息被擦除,经常抛ClassCastException。更好的方式是通过Row底层方法取:
row.getSeq[String](row.fieldIndex("tags")).toList位置取值也很容易错位。如果DataFrame列顺序可能变化,强烈建议用列名而不是序号:row.getAs Long 比row.getLong(0)更抗schema演进。一旦上游加了一列,位置取值的代码很可能取到完全不同的字段。
5.3 map、filter和flatMap别混用
RDD和Dataset的map、flatMap、filter都遵循函数式语法,但实际写起来有几个容易偏的方向。
最常见的是这种"调试代码":
df.rdd.map { row => println(row.getString(0)) (row.getString(0), 1) }println在executor上执行,输出打在每台机器的stdout里,driver端控制台能看到的内容非常有限。想要看分布式数据,用df.take(10).foreach(println)是driver端操作,没毛病;在map里println既没帮助又污染日志。
flatMap则常用于一对多展开,典型是把一行里的数组拍平:
df.rdd.flatMap { row => row.getSeq[String](0).map(item => (row.getString(1), item)) }务必记住flatMap的返回必须是一个可迭代集合,不能像map那样返回单个值。两个函数一旦混错,运行时结果可能莫名其妙少数据。我养成的习惯是:返回单个值用map,把一个元素展开成多个元素用flatMap。
6. 分区与调优时,常用语法怎么搭配
6.1 repartition和coalesce的使用时机
repartition与coalesce是被问得最多的一对函数。repartition会强制shuffle;coalesce只在合并分区时减少shuffle。实际决策流程很简单:需要增加分区数只能用repartition;想减少分区数优先coalesce。但coalesce有个限制——如果当前只有一个分区,coalesce到N个分区并不能增加并行度,因为coalesce只合并,不切分。这时候必须用repartition(N)。
判断分区数是否合理,可以先看Spark UI里的Input大小和Task数。Task数等于分区数,如果200个Task每个都在处理几KB数据,分区数可能过大;反过来Task数只有2个却要各处理几十GB,并行度就明显不足。做个简单的数据总量除以期望单Task数据量的小计算,就能定出合理分区数。
6.2 shuffle partitions与AQE的调整方向
groupBy、join这类操作会产生shuffle,shuffle输出分区数由spark.sql.shuffle.partitions控制,默认是200。小作业数据只有几MB时,200个shuffle分区意味着大量空Task空跑;每天处理TB级数据的大作业,200个又往往不够。
推荐做法不是全局写死一个数,而是观察Spark UI里的Shuffle Write和Shuffle Read量。大表作业有人调高到1000,小批量任务设成64或者50更合适。调完之后对比前后耗时,比拍脑袋定参数靠谱得多。
Spark 3.0以后AQE默认开启,很多手动调参可以少管一些。AQE会在shuffle后根据实际输出自动合并小分区,也能处理一定程度的倾斜。不过初始的spark.sql.shuffle.partitions还是值得手动设置合理值,因为AQE生效前,文件数和中间结果数据量仍然取决于这个初始分区数。
cache和persist的语法本身很简单,难点是决定什么时候用。同一个DataFrame被多个action连续使用时,cache才有意义;用一次就扔的临时结果,cache反而增加额外开销。cache是惰性的,需要等一个action真正触发后才落存储。可以cache之后立刻执行df.count()让它"热"起来,再做其他操作的测试,这样比较结果才准确。
6.3 动态分区与小文件治理的语法组合
写动态分区表,也就是df.partitionBy("dt").write这种场景时,上游分区数很多的情况下一天会产生大量目录。一个实用做法是repartition后再写:
df.repartition(col("dt")) .write .partitionBy("dt") .mode(SaveMode.Overwrite) .parquet("/data/warehouse/xxx")这个做法的核心思想:让每个dt只落到一个reducer,落盘文件数量可控。如果直接用上游300个分区去写文件,同一个dt的数据会被分散到300个task里,文件数暴涨。
遇到已经存在的小文件目录,治理方案通常是读入后coalesce再写新表:
spark.read.parquet("/data/bad_dir") .coalesce(20) .write .mode(SaveMode.Overwrite) .parquet("/data/good_dir")注意这种重写会变更路径,先确认下游引用关系再动手。写完需要做一次对账:源目录总文件数、目标目录总文件数、总行数和关键字段sum值。我自己经历过一次目标目录名写错导致数据少一段的事故,从那以后凡是涉及重写都会补上这类校验。
7. 排错与日志里常见的三类报错
7.1 Task not serializable的根源
Task not serializable可能是Spark Scala初学者最常撞上的异常。一句话解释:算子里引用了没有实现Serializable的外部对象,整个闭包要从driver序列化发给executor,序列化失败就报Task not serializable。
最常见的病根是在class里写ETL方法,无意间把class的成员变量带进了闭包:
class UserETL(val config: Map[String, String]) { def run(df: DataFrame): DataFrame = { df.map(row => doSomething(config.get("key"), row)) } }修复思路有两类:把需要的数据抽成局部值,进算子之前先取出来;或者把ETL逻辑写成object方法,不依赖class实例状态。我更喜欢把不可变字典用广播变量包一层,算子内部拿bc.value,既解决序列化问题,又顺手完成性能优化。
7.2 空值与类型不匹配导致的隐形Bug
空值在Spark里表现为null,参与计算时会静默产生null结果。常见场景:amount列存在null时,sum(amount)不报错,但结果里可能出现null;后续如果想把null转成0但漏了na.fill,最终数值就对不上。
排查这类问题很难直接定位到某一步,所以我的习惯是在聚合前主动清洗目标字段:
df.withColumn("amount", when(col("amount").isNull, 0).otherwise(col("amount")))类型不匹配多发生在Decimal和Double混合计算里。比如源字段是String类型的"123.4",cast成Double没问题,但如果是"123.4元",cast会返回null而不报错,这在数仓链路里特别隐蔽。强烈建议cast之后立刻做一次空值检查,或者在schema定义阶段就严格约束类型。
还要提醒一个常见误用:想要过滤空值,应该用df.filter(col("amount").isNotNull),不要写"amount != null"这种表达式。因为null和任何值比较结果仍然是null,null在filter语义里等价于false,等于把所有行都滤掉了。
7.3 collect、foreach这类触发执行的误用
collect和foreach都是action,一旦调用就会触发整个作业计算。collect把全量数据拉到driver端成为Array,数据量一大,driver内存直接被压爆。很多新手排查问题时习惯在DataFrame上collect然后println,任务量一大就OOM。
只想看抽样几行时,用df.limit(10).collect()或者df.take(10),返回的只有指定条数,比全量collect安全得多。想输出到日志,df.take(5).foreach(println)是driver端操作,不会给executor带来压力。
foreach误用更多体现在把println写进了executor端算子。之前说过,这既看不到有用信息,又拖慢任务。想看最终结果,正确方式是等action结束后在driver端处理返回值,比如聚合后用first()或show()查看。
写代码这么多年,我自己有一个很笨但很有效的习惯:每一份新Spark作业,先在本地用小数据集把整条链路跑通,确认逻辑正确之后再上集群跑全量。这个习惯帮我躲过几乎所有的列名错误、空值异常和分区爆炸问题。如果对里面某一个具体场景想深入聊,比如UDF序列化或者小文件治理,下一篇可以再单开一篇细讲。