1. 先理清三套API的关系
做大数据开发的人,从接触Spark开始,就绕不开RDD、DataFrame、DataSet这三个概念。我在团队里带了几个新人,发现大家最迷茫的地方不是语法本身,而是“这三样东西到底什么关系、平时开发用什么、转换来转换去到底图什么”。这个基础如果不打牢,后面写DSL还是写SQL都会别扭,经常是看着文档能跑,一换场景就犯迷糊。
RDD是Spark最底层的抽象,全称Resilient Distributed Dataset,弹性分布式数据集。你可以把它理解成一个分布式的普通集合,里面的数据就是一个个Java或者Scala对象,你写map、filter、flatMap这类函数式操作,Spark按分区并行执行。RDD的特点是灵活、可控,缺点也很明显:没有Schema,Spark不知道每个字段是什么类型,只能靠JVM对象的反射去推断,性能优化空间有限。早期Spark 1.0时代全靠RDD打天下,但后来大家发现RDD处理结构化数据太吃力了,相当于拿一把万能瑞士军刀去专门切菜——能用,但费劲且不专业,于是DataFrame出现了。
DataFrame在RDD的基础上引入了Schema信息,数据被组织成一张“表”的样子,有列名和类型。Spark的Catalyst优化器能看懂这张表的语义,会自动做谓词下推、列剪枝、常量折叠这些优化;底层存储还用上了Tungsten的堆外内存和二进制编码,所以同样一段逻辑,DataFrame往往比RDD快不少。DataSet更进一步,在DataFrame的表结构上加了强类型约束,每条数据对应一个自定义的Java/Scala类型,把“编译期类型安全”也给你补上了。
你直接记这句话就行:DataFrame其实就是DataSet[Row]。Spark官方在2.0之后把它们做了统一,DataSet有强类型能力,DataFrame只是弱类型场景的特化版本。我们平时开发,八成时间在用DataFrame/DataSet,RDD主要是在处理非结构化文本、或者需要细粒度控制分区的时候才掏出来。三者之间互相转换是家常便饭,转换的核心钥匙有两把:一把是spark.implicits隐式转换,一把是Encoder编码器。下面我会把这些细节一步步掰开讲。
2. 三种数据抽象怎么选、怎么转
2.1 RDD转DataFrame:两条路线
最常用的方式是通过隐式转换调用toDF方法。前提是你先有一个SparkSession对象,然后引入了隐式转换。
import org.apache.spark.sql.SparkSession val spark = SparkSession.builder() .appName("ConvertExample") .master("local[*]") .getOrCreate() import spark.implicits._ // 方式一:元组序列转DataFrame val rdd1 = spark.sparkContext.parallelize(Seq( ("Alice", 25), ("Bob", 30), ("Cathy", 27) )) val df1 = rdd1.toDF("name", "age") df1.show()这种方式适合列不多、类型简单的场景。toDF里传入的字符串就是列名,列类型由Scala类型自动推断为StringType、IntegerType等。
方式二:通过样例类Case Class转DataFrame,这也是我日常推荐的做法,因为类型明确、结构清晰,后面转DataSet也方便。
case class Person(name: String, age: Int) val rdd2 = spark.sparkContext.parallelize(Seq( Person("Alice", 25), Person("Bob", 30), Person("Cathy", 27) )) val df2 = rdd2.toDF() df2.printSchema()输出Schema的格式大概是:
root |-- name: string (nullable = true) |-- age: integer (nullable = false)如果用SparkSQL的createDataFrame方法,就需要先构造Row对象再配上StructType。这条路比较繁琐,一般是在动态Schema场景才用,比如你运行时候才知道有哪些列。
import org.apache.spark.sql.types._ val rows = spark.sparkContext.parallelize(Seq( Row("Alice", 25), Row("Bob", 30) )) val schema = StructType(Seq( StructField("name", StringType, nullable = true), StructField("age", IntegerType, nullable = false) )) val df3 = spark.createDataFrame(rows, schema)我个人的经验是:生产环境里能用Case Class的坚决不用Row+StructType,可读性好太多,而且Case Class自带了Product特质,Spark能自动生成Encoder,不需要额外折腾。
2.2 DataFrame/DataSet转RDD:简单但注意类型
DataFrame转RDD本质上就是调用.rdd方法,拿到一个RDD[Row]。
val rddFromDf: RDD[Row] = df1.rdd rddFromDf.foreach(row => { val name = row.getString(0) val age = row.getInt(1) })DataFrame对应的RDD里面元素是Row,你取值时要注意类型匹配:getString、getInt、getLong这些方法,索引从0开始,类型对不上会直接抛异常。如果你觉得Row取值太啰嗦,可以先转成DataSet,再转RDD。
val ds: Dataset[Person] = df2.as[Person] val rddPerson: RDD[Person] = ds.rdd rddPerson.foreach(p => println(s"${p.name}: ${p.age}"))这样取值就舒服多了,直接p.name、p.age,比Row一堆getXXXX方法好维护。
2.3 DataFrame与DataSet互转
DataFrame转DataSet用.as[T],这里要求样例类是Product类型,而且字段名和顺序要和DataFrame的Schema对得上。
case class Person(name: String, age: Int) val df: DataFrame = ... // 假设有name和age两列 val ds = df.as[Person]如果字段对不上,比如DataFrame里有个列叫username,而样例类是name,运行时会报“Cannot cast column username to name”之类的编码错误,这种问题越早通过编译期或printSchema排查越好。
DataSet转DataFrame就简单了,.toDF()一把梭。
val dfAgain: DataFrame = ds.toDF()2.4 Encoder:最容易踩坑的地方
RDD转DataFrame和DataFrame转DataSet,背后都是Encoder在起作用。Encoder负责把JVM对象序列化成Spark内部的二进制格式,Tungsten才能做堆外存储和优化。Scala常见的原生类型、Case Class,Spark内置的Encoder都能自动生成,但你要是用了一些不可序列化或者非Product类型,比如自定义Java类,编译期可能没事,运行时就给你抛NotSerializableException或者“Unable to find encoder for type”。
提示:生产环境遇到Encoder报错,优先检查两点——Case Class是不是定义在方法内部(最好放在顶层或者伴生对象里);字段类型是不是都支持自动编码(如Option、List、Map是支持的,但某些嵌套自定义类需要你手动提供隐式Encoder)。
我曾经遇到过一个坑:把Case Class定义在main方法内部,结果转DataSet时Spark死活找不到Encoder,报错信息还特别隐晦,排查了半天。后来把Case Class提到main外面,问题瞬间消失。这个经验建议记下来。
3. DSL写法:日常开发的主力
DSL全称Domain Specific Language,Spark的DataFrame API就是一套针对数据处理场景设计的领域专用语言。用DSL写代码,最大的好处是编译期能帮你查出列名拼写错误这类低级问题,而且逻辑链式调用读起来像在写一段自然语言。
3.1 select与列的构建
select是出现频率最高的操作,关键是搞明白怎么传参。最常见的形式是直接传列名字符串:
df.select("name", "age")也可以传Column对象,这样能对列做表达式运算:
import org.apache.spark.sql.functions._ df.select( upper(col("name")).alias("name_upper"), (col("age") + 1).alias("age_plus") )这里的col("name")返回的就是一个Column对象,alias给它重命名。你也可以用$"name"或者'name来代表一列,我统一建议用col()这种方式,原因后面专门讲。
select时还可以用selectExpr,直接写SQL表达式,介于DSL和SQL之间的体验。
df.selectExpr("upper(name) as name_upper", "age + 1 as age_plus")3.2 过滤和聚合:filter、groupBy、agg的组合
filter就是where,两者等价,按条件筛行:
df.filter(col("age") >= 18) df.where("age >= 18") // 字符串表达式也可以注意:用字符串写条件,Spark照样能解析,但编译期就检查不了错误了。如果条件里涉及动态拼接,强烈建议用Column方式,避免注入或语法拼接问题。
聚合是DSL的核心戏码。以前写SQL是GROUP BY加一堆聚合函数,DSL里就拆成groupBy+agg:
import org.apache.spark.sql.functions._ df.groupBy("name") .agg( count("*").alias("cnt"), avg("age").alias("avg_age"), max("age").alias("max_age") )groupBy可以传多个列名做分组;agg里面可以放多个聚合表达式。这里要注意一个点:如果你的聚合逻辑比较复杂,比如同一列要算多种指标,或者聚合指标有自定义UDAF,agg是最好扩展的地方。你还可以直接嵌套整个聚合表达式在select里:
df.select( count("*").over(Window.partitionBy("name")).alias("cnt_by_name") )3.3 join和扩展列
多表关联在DSL里是这样写的:
val dfLeft = ... val dfRight = ... dfLeft.join(dfRight, Seq("id"), "inner")用Seq("id")做关联时,join后结果中只保留一个id列,不会有重复。如果你用字符串表达式或者Column条件关联,比如dfLeft.join(dfRight, col("a.id") === col("b.id")),那结果里会同时出现两个id列,后续引用时容易报列歧义。我在项目里遇到过好多次“Reference 'id' is ambiguous”的报错,根源就在这里。
扩展列的操作是withColumn,它是函数式的,不修改原DataFrame,返回一个新的DataFrame:
df.withColumn("age_group", when(col("age") >= 18, "adult").otherwise("minor") )withColumn的核心点是:永远不要指望df本身变了,在Scala/Java这种不可变设计里,所有转换都是生成新对象。
3.4 窗口函数:排名和累计
窗口函数在DSL里的写法也比较直观。以分组排名为例:
import org.apache.spark.sql.expressions.Window val windowSpec = Window.partitionBy("dept").orderBy(col("salary").desc) df.withColumn("rank", row_number().over(windowSpec))窗口函数能做很多SQL里常见的分组TopN、累计求和、环比计算,我强烈建议你在DSL和SQL两条路都练熟窗口函数,它俩的语义一致。
实操经验:在开发阶段用show(false)观察结果,在生产代码里日志和输出级别要注意,不要无脑把全量数据打在Driver端,会撑爆内存。
4. SQL风格:什么时候用、怎么用
DSL虽然好用,但SQL风格依然是很多数据分析场景的最爱,尤其是团队里有纯SQL背景的成员时。SparkSQL让你先把DataFrame注册成临时视图,然后就能写标准SQL去操作它。
4.1 临时视图:local还是global
注册临时视图的方法就一句话:
df.createOrReplaceTempView("people") df.createGlobalTempView("people_global")- createOrReplaceTempView创建的临时视图生命周期和SparkSession绑定,只对当前会话可见,不同会话之间看不到。
- createGlobalTempView创建的全局临时视图生命周期也是SparkSession,但跨会话可见,引用时要用global_temp.people这种全限定名。
用完建议显式清理,尤其是逐个跑任务的开发环境里,视图名重复注册会互相覆盖,容易出诡异问题。
spark.catalog.dropTempView("people")4.2 SQL玩转复杂逻辑
注册好视图后,就直接上spark.sql:
val result = spark.sql( """ |SELECT name, | age, | ROW_NUMBER() OVER (PARTITION BY dept ORDER BY age DESC) AS rn |FROM people |WHERE age > 20 |""".stripMargin )这里要注意,spark.sql的返回结果本身就是DataFrame,你可以继续接DSL操作,也可以继续注册成别的视图。多段SQL之间可以用视图串起来,就跟写SQL临时表一样。
SQL风格的优点是表达复杂关联逻辑时,可读性非常强,业务方容易review;缺点是把列名检查放到了运行时,一旦表视图结构变了,SQL文本里的错误要跑起来才能发现。我见过不少人用字符串拼接SQL做动态筛选,那真是在危险边缘试探,变量一多就是事故高发地。动态条件如果只能用SQL,至少用spark.sql里带参数的方式,或者先用DataFrame做过滤再注册成视图。
4.3 DSL和SQL怎么选
我的个人倾向非常明确:能DSL就DSL,复杂到DSL实在绕不清再SQL。理由有三个:
第一,DSL编译期能发现大部分列名的拼写错误,SQL要等作业跑起来炸;第二,DSL可以细粒度复用和组合,你可以在函数里传Column对象、拼接逻辑,SQL文本做组合就只能拼字符串;第三,DSL的链式调用兼容了代码重构、单元测试这些工程习惯,SQL在测试上天然吃亏。
但如果你面对的是临时探索性分析,或者团队伙伴是SQL熟手,那直接用SQL更高效。两条路不是对立的,我在同一个Pipeline里经常混着用——清洗字段用DSL,统计复杂指标用SQL,最后再回归DSL做结果加工。
5. 读写操作,绕不开的细节
5.1 基础读写格式:parquet还是csv
Spark读写外部数据的统一入口就是DataFrameReader/Writer,打法套路固定:format指定格式、option传参数、load/save执行。
读parquet:
val df = spark.read .format("parquet") .load("hdfs:///data/warehouse/orders")读csv:
val df = spark.read .option("header", "true") .option("sep", ",") .option("inferSchema", "true") .csv("hdfs:///data/raw/trade_log")写出去:
df.write .format("parquet") .mode("overwrite") .save("hdfs:///data/warehouse/orders_cleaned")这里我要重点说下mode。SaveMode有四种:Append追加、Overwrite覆盖、ErrorIfExists报错、Ignore忽略。Overwrite不是“清空目标路径重新写”这么简单——它默认会把整个目标路径删掉再写,如果你目标路径是个重要的业务表目录,手一抖就事故。我建议高危路径写之前,先确认目录归属,或者用Append模式。
5.2 分区写入与动态分区
生产环境里很少直接写一个平表,基本都要按业务字段分区。分区写入的语法:
df.write .partitionBy("dt", "region") .mode("overwrite") .parquet("hdfs:///data/warehouse/orders")partitionBy里的列如果还在DataFrame里,写出去后自动会变成目录层级,比如dt=2025-01-01/region=cn/。这个操作能大大加快后续按分区过滤查询的速度。
需要注意数据倾斜问题。如果分区键某个取值特别多,比如“2025-01-01”的数据占了80%,那么写入时这个分区的文件会特别大,而其他分区很小。解决办法是在写入前做一轮repartition或者使用动态分区配合合理的文件大小控制。关于小文件,我的经验是写完后统计一下目标路径的文件数量和大小,如果大量几十KB的小文件,就考虑用coalesce减少输出分区数,或者把分区粒度调整大一点。
5.3 JDBC读写
跟关系型数据库打交道,Spark也有一套:
val jdbcDF = spark.read .format("jdbc") .option("url", "jdbc:mysql://host:3306/db") .option("dbtable", "orders") .option("user", "xxx") .option("password", "xxx") .option("numPartitions", "8") .load()写回数据库:
jdbcDF.write .format("jdbc") .option("url", "jdbc:mysql://host:3306/db") .option("dbtable", "orders_sink") .mode("append") .save()JDBC读写最容易踩的坑是连接配置和并发数。numPartitions不是越大越好,它决定了同时开辟多少数据库连接,数据库并发连接数有限,你设个100,目标数据库很可能直接拒绝连接。要根据表数据量和数据库压力来调,我一般先给4~8,不够再慢慢加。
注意:JDBC写库metadata重试这类问题也很常见,尤其是大批量写入时,可以考虑先写出parquet临时文件,再用其他同步工具导入,避免Spark直连数据库把库拖垮。这不是说Spark JDBC不行,而是要看场景和量级。
6. Column对象的花样拿法
这个点很多人不重视,以为不过是个辅助语法,实际上Column对象的获取方式直接决定你DSL代码的风格和坑的多少。
6.1 六种常见方式
第一,col("列名"),最经典最推荐:
import org.apache.spark.sql.functions.col df.select(col("name"))第二,column("列名"),和col等价,看个人习惯。
第三,df.col("列名"),用DataFrame自己去找列,当有多个DataFrame join过之后,如果列名重了,这种写法能明确指定是哪个df的列。
第四,df("列名"),DataFrame的apply方法,本质等价于df.col。
第五,$"列名",来自spark.implicits,壳子是字符串插值,非常简洁,我最喜欢这种。但要保证你import了spark.implicits。
df.select($"name")第六,'列名,Scala的Symbol,也能隐式转换成Column。我用得少,因为Symbol类型在Scala里本身不太常见,团队新人看到会懵。
6.2 踩过的一些坑
第一个坑:单引号和$符号容易混。$"name"是Column,'name是Symbol,如果你在代码里写df.select('name)而import没配好,编译直接报错;配好了,它也能转成Column。问题是混用风格会让代码难维护,我建议团队统一约定,最好只留col()和$""两种写法。
第二个坑:带shuffle操作的列引用。比如join之后字段重名,你用col("id")就是歧义引用,直接给你抛Ambiguous Reference异常。这时要用带前缀的写法:
df1.join(df2, df1.col("id") === df2.col("id"), "inner")或者干脆join时用Seq("id")避免重复列。
第三个坑:动态列名。有时候列名是运行期拼出来的,比如按业务维度动态选择字段,这时候你需要用col(nullString)这类写法。col(null)会报空指针,所以要做空判断:
val colName = if (flag) "age" else "name" df.select(col(colName))第四个坑:expr和别名。expr("name as n")可以在字符串里写表达式,但拿到的是Column。我见过有人把expr当函数式API乱用,最后代码一行几千个字符,review的时候压根没法看。合理的做法是:简单场景用col,复杂表达式交给selectExpr或者expr,但要有度。
建议:初始化项目时就把Column获取方式的规范写进团队开发规约。我在不同项目里见过“四国混编”式代码,col、$、单引号、字符串满天飞,出问题查找成本极高。
7. 常见问题与排查技巧实录
7.1 高频错误速查表
| 报错现象 | 根因 | 排查/解决办法 |
|---|---|---|
| Unable to find encoder for type | Case Class位置不对或类型不可编码 | 把Case Class提到顶层/伴生对象,检查字段类型 |
| Reference ‘xxx’ is ambiguous | join后多个DataFrame有相同列名 | 用带前缀df.col("xxx"),或者join用Seq统一列名 |
| AnalysisException: Table not found | 视图没注册或者名字拼错 | 检查spark.catalog.listTables()确认视图是否注册成功 |
| SparkException: Task not serializable | 闭包里引用了不可序列化对象 | 不要在map等算子内部引用SparkSession等对象,改用广播变量或局部变量 |
| java.sql.SQLTransientConnectionException | JDBC并发连接太多,数据库拒绝 | 调低numPartitions,检查连接池配置 |
| 写文件产生大量小文件 | 分区/分区内数据量小但任务并行度高 | 用coalesce或调整Spark SQL的shuffle分区数spark.sql.shuffle.partitions |
| Overwrite误删了历史数据 | SaveMode.Overwrite覆盖整个目录 | 高危目录先确认,优先采用分区覆盖 |
7.2 定位思路
大数据的排查思路有一个“三部曲”:先查Schema,用printSchema看结构有没有问题;再查数据形态,用show或取小样本查看内容;最后才查作业日志,找那个“根因Exception”。很多人第一步就走反了,一头扎进日志大海捞针,效率极低。
我曾经处理过一个看起来很诡异的job:SQL结果偶发为空。用printSchema看没问题,全量数据也有,但spark.sql跑出来就是空。后来发现是我在视图注册前用了broadcast join,小表广播的条件判断导致分析器把过滤条件下推到了错误的视角。这种case靠看日志低效,靠逐层show结果能快速缩小范围。
还有一个常见的坑是时间字段。从CSV或者文本读进来,默认当字符串处理,做时间过滤时千万先to_date或cast。我在生产里见过无数人栽在“字符串比较时间”上,看起来结果对,实际字典序跟时间序在格式不统一时根本不一致。
7.3 性能调优的几个实用建议
第一,过滤条件下推。如果是Parquet文件,Spark支持谓词下推,能在扫描文件层就把无关分区和行过滤掉。你写DSL或SQL时,过滤条件尽量写在最前面,让Catalyst早点做裁剪。
第二,避免在shuffle之前做过多的不必要的列选择。shuffle的代价跟数据量成正比,能用select先瘦身就先瘦身。
第三,合理设置spark.sql.shuffle.partitions。默认200在集群规模小的时候偏多,生成很多碎片小文件;集群大、数据量大时200可能又不够。这个值要根据Reduce端处理的数据量和下游文件数量来调整,我通常先根据目标输出文件的数量预估,再上下浮动几次找到相对稳定点。
第四,广播小表。一个几MB的维度表和百亿级事实表join,如果不广播,shuffle的数据量会让你怀疑人生;用了broadcast join,直接内存哈希关联,速度能提升几十倍。Spark 2.4之后自动广播阈值spark.sql.autoBroadcastJoinThreshold默认是10MB,你可以手动hint:
df_large.join(df_small.hint("broadcast"), Seq("key"))第五,序列化方式。如果程序里还有不少RDD操作,可以试试Kryo序列化,Java序列化的性能和压缩比都不如Kryo,尤其在复杂嵌套对象场景。
7.4 最后的经验分享
说回RDD、DataFrame、DataSet的转换和SparkSQL的日常使用,我个人的体会是:不要把这套东西背成语法书,而是要建立“查表—操作—结果”的心智模型。拿到一批数据,先想清楚它是不是结构化数据、Schema是什么、最终要什么结果,再去选DSL还是SQL。转换只是为了适配不同阶段的处理需求,不是炫技。
我最近写一套离线数仓清洗流程,清洗段用DataFrame的DSL,字段加工用了大量withColumn和窗口函数;中间有一次临时要做复杂业务口径的统计,直接注册视图写SQL,配了十几个窗口条件,SQL明显比DSL好写;最终输出给下游用DataSet强类型封装,跑了两轮repartition控制好小文件,一次通过。你看,三种数据抽象和两种语法风格在一套流程里都能用上,关键是你知道每种武器最适合的战场。
大家如果刚开始上手,我建议用一份真实数据把RDD、DF、DS三种形态各写一遍同样的逻辑,再把同一逻辑用DSL和SQL各实现一遍,感受一下性能差异和写代码的差异。这套组合拳打下来,SparkSQL的基本功就算扎实了,后面碰到更复杂的调优、流批一体、数据湖场景,你也不会慌,因为这些上层技术最终都绕不开你今天练的这些基础操作。