资讯详情

资讯详情

SparkSQL实战笔记:RDD/DataFrame/DataSet转换与DSL高频操作指南

1. 为什么我强烈建议把SparkSQL作为大数据开发的必备入口最近在带几个新同学做离线数仓项目的时候发现一个很普遍的现象大家一上来就用RDD做各种算子操作map、flatMap、reduceByKey用得飞起但是一到业务逻辑稍微复杂一点——比如要按多个字段关联两张表、要做窗口函数、要处理脏数据再写回数仓——RDD写出来的代码就变得又长又难维护debug的时候更是想砸键盘。这个场景我相信做过一段时间大数据开发的人都有体会。我自己的经验是从SparkSQL切入数据处理是性价比最高的选择。它在RDD之上封装了一层更友好的结构化数据抽象把数据当成一张表来对待你既可以用SQL风格去操作也可以用DSLDomain-Specific Language链式调用来写两种方式底层共享同一套Catalyst优化器和Tungsten执行引擎也就是说不管你怎么写Spark都会尽量帮你把执行计划优化到最优。这篇内容适合谁看呢如果你已经会基本的Spark RDD操作但是对DataFrame、DataSet这两套API感到模糊搞不清什么时候该用哪个也不太清楚怎么在三种API之间自由切换那这篇文章就是写给你的。我会把RDD、DataFrame、DataSet的相互转换、DSL常用方法、SQL风格语法、读写外部数据源还有获取Column对象的四种方式全部串起来讲透并且附带我实际开发中踩过的坑和验证过的写法保证你能直接拿去用。2. RDD、DataFrame、DataSet三者之间转换一张表讲清楚所有对应关系2.1 先把三者的定位说透在动手写转换代码之前必须先理解这三者的本质区别否则转换的时候容易晕头转向。RDDResilient Distributed Dataset是Spark最底层的抽象它是一种分布式的对象集合。RDD里的元素可以是任意Java/Scala对象完全没有结构化信息。它的优点是灵活什么都能装缺点是Spark不知道你数据里有哪些字段所以无法做深度的优化很多操作只能靠开发者自己手动控制。DataFrame是一个分布式的Row对象集合你可以把它理解成数据库里的一张表。它有Schema模式也就是每一列的名称和类型是什么。因为有了SchemaSpark就能读懂你的数据Catalyst优化器可以自动做谓词下推、列剪枝这些优化。DataFrame的特点是开发效率高写起来像操作一张表但类型不安全——编译期不会帮你检查某一列是否存在运行期才会报错。DataSet是DataFrame的超集它是强类型的。DataSet[T]里面的T可以是自定义的样例类case class也就是说每一行都是一个类型安全的Java/Scala对象。它在编译期就能发现列名错误、类型不匹配这些问题同时保留DataFrame的优化能力。从Spark 2.0开始DataFrame其实就是DataSet[Row]的类型别名所以它们底层是同一套东西只是视图不同。理解了这一点下面的转换就好办了。2.2 转换路线图与完整示例我画一个逻辑上的转换关系注意不是框架图只是一个思路RDD可以通过toDF()转成DataFrameRDD可以通过toDS()转成DataSetDataFrame可以通过rdd属性转成RDDDataFrame可以通过as[T]转成DataSetDataSet可以通过toDF()转成DataFrameDataSet可以通过rdd属性转成RDD需要特别强调的是所有的toDF、toDS、as这些隐式转换方法都必须先导入spark.implicits._否则编译器根本找不到这些方法。我见过不少初学者在第一步就卡住报错信息是value toDF is not a member of ...原因就是没导入这个隐式转换包。下面我给出一个完整可跑的示例用模拟电商订单数据来演示import org.apache.spark.sql.SparkSession // 创建SparkSession同样通过它拿到SparkContext val spark SparkSession.builder() .appName(RDD_DF_DS_Transform) .master(local[*]) .getOrCreate() // 这一步是必须的没有它后面的toDF/toDS全部不可用 import spark.implicits._ // 模拟一份订单数据订单号、用户ID、商品分类、金额 val orderSeq Seq( (O001, U1001, 手机数码, 3999.0), (O002, U1002, 家用电器, 2599.0), (O003, U1001, 图书文娱, 89.0), (O004, U1003, 食品生鲜, 156.5) ) // 从普通集合创建RDD val orderRDD spark.sparkContext.parallelize(orderSeq) // 方式一通过toDF指定列名生成DataFrame val orderDF orderRDD.toDF(order_id, user_id, category, amount) orderDF.show()如果你希望转换为强类型的DataSet推荐先定义一个样例类然后通过toDS()转换// 定义样例类字段与数据一一对应 case class Order(order_id: String, user_id: String, category: String, amount: Double) val orderDS orderRDD.map { case (oid, uid, cate, amt) Order(oid, uid, cate, amt) }.toDS() orderDS.show()2.3 反向转换与as方法的细节DataFrame转RDD非常简单直接调用.rdd属性即可// DataFrame - RDD[Row] val rddFromDF: RDD[Row] orderDF.rdd // DataSet - RDD[Order] val rddFromDS: RDD[Order] orderDS.rdd这里有一个新手容易忽略的点从DataFrame转出来的RDD每一行的类型是Row而不是原来的元组。如果你需要重新拿到元组或样例类需要自己遍历Row取字段。例如rddFromDF.map(row (row.getString(0), row.getString(1), row.getString(2), row.getDouble(3)))这种getString、getDouble的做法很啰嗦本质上是因为DataFrame丢掉了类型信息。所以如果你用强类型开发尽量选择DataSet。DataFrame转DataSet的方式是as[T]前提是DataSet的类型能够和DataFrame的字段对上。最常见的一个坑是用as[Order]的时候如果DataFrame的字段是nullable的或者字段顺序与样例类不一致运行期会直接报错。稳妥做法是先用select把字段顺序调整好再调用asval dsFromDF orderDF.select( $order_id, $user_id, $category, $amount ).as[Order]这里$order_id是获取Column对象的一种方式后面第5节会专门展开。DataSet转DataFrame很简单直接.toDF()即可。你可以不传参数默认保留字段名也可以传一个新的列名数组val dfFromDS orderDS.toDF() val dfRenamed orderDS.toDF(订单编号, 用户编号, 商品分类, 金额)2.4 实践经验什么时候不该强行转换我在实际项目中给团队定的一个粗粒度原则是如果数据是结构化、需要做大量SQL式分析的优先用DataFrame/DataSet如果数据是半结构化文本、或者需要在RDD层面做非常底层的自定义逻辑那才用RDD尽量避免在同一个job里反复横跳RDD和DataFrame因为每次转换都可能引入额外的序列化开销和schema推断成本。一句话总结RDD是基建DataFrame是日常主力DataSet是在对类型安全有要求时的补充。转换方法记住那一张对应表就够了但真正要用好还是要减少无谓切换。3. DSL链式操作里最高频的十几个方法以及它们的执行语义3.1 select和selectExpr选列与写表达式DSL最讨喜的地方是可以像流水线一样把多个操作串起来每一步都在处理一张表代码读起来很直白。我最常用的是select。它用来选择需要的列注意select接收的是Column对象不是字符串。比如orderDF .select($order_id, $category, $amount) .show()这里就有很多人会写错直接传字符串select(order_id)其实也可以因为DataFrame提供了重载方法但传字符串时没法做表达式计算。如果你想在选列的同时做表达式转换就要用selectExpr它接收的是SQL表达式字符串orderDF .selectExpr(order_id, category, amount * 1.1 as amount_with_tax, lower(category) as lower_cate) .show()selectExpr非常适合做字段加减乘除、字符串拼接、类型转换这些操作。你会发现它和SQL里的select语法是一一对应的学过SQL的人几乎零成本上手。这里补充一个很重要的执行细节select、where、groupBy这些DSL方法并不会立刻触发Spark执行任务它们只是构建逻辑执行计划。只有遇到show()、count()、collect()、write.save()这些action类操作时Spark才会真正提交任务去跑。这个机制和RDD的惰性求值一脉相承。3.2 where / filter、groupBy / agg条件过滤与聚合where和filter在DataSet/DataFrame的语义完全一样都是条件过滤只是名字不同。我觉得where写起来更像SQLfilter更像函数式看个人习惯我一般混用。// 过滤出金额大于100的记录 val filteredDF orderDF.where($amount 100) // 等价写法 val filteredDF2 orderDF.filter(amount 100)注意一个细节where(amount 100)传字符串的时候Spark会把它解析成SQL表达式所以字符串里的字段名必须是表里真实存在的列。如果列名里带空格或者特殊字符又要用反引号包裹。而where($amount 100)是直接在Scala里构造Column表达式编译期就能发现一部分错误。聚合操作groupBy搭配agg是必备技能// 按用户ID分组计算订单数、总金额、平均金额 val userStatDF orderDF .groupBy($user_id) .agg( count(*).alias(order_cnt), sum(amount).alias(total_amount), avg(amount).alias(avg_amount) ) .orderBy($total_amount.desc)这里有一个非常容易踩的坑groupBy($user_id)之后如果不调用agg而是直接.count()返回的DataFrame列名固定是count有时候不是你想要的名字。更推荐的方法是统一走agg用alias显式控制输出列名这样后续逻辑才不会被列名不一致坑到。3.3 join多表关联方式与条件写法大数据场景下join是家常便饭。DSL里的join语法如下val userDF Seq( (U1001, 张三, 北京市), (U1002, 李四, 上海市), (U1003, 王五, 广州市) ).toDF(user_id, user_name, city) // 内连接订单表关联用户表 val joinedDF orderDF .join(userDF, Seq(user_id), inner) .select( $order_id, $user_id, $user_name, $city, $category, $amount )join方法的第二个参数可以传字符串列名多列用Seq也可以传Column表达式。传Column表达式的写法更灵活比如orderDF.join(userDF, orderDF(user_id) userDF(user_id), left_outer)为什么这里用orderDF(user_id) userDF(user_id)而不是$user_id $user_id因为当两个DataFrame有同名列时如果直接用$user_idSpark无法确定你指的是哪张表的列会报Reference is ambiguous错误。解决办法就是用df(列名)这样带表前缀的方式显式指定。join中joinType的值有很多种inner、left_outer、right_outer、full_outer、left_semi、left_anti、cross。其中最容易被忽视的是left_semi和left_anti前者表示只保留左表在右表能匹配上的记录但不返回右表的任何列后者正好相反返回左表中右表匹配不上的记录。这两个join在做过滤型逻辑时性能极好而且代码语义清晰。// 只保留有订单的用户不返回订单信息 val activeUserDF userDF.join(orderDF, Seq(user_id), left_semi) // 找出没有任何订单的用户 val inactiveUserDF userDF.join(orderDF, Seq(user_id), left_anti)3.4 withColumn家族、drop、distinct与dropDuplicateswithColumn是给DataFrame新增一列或者替换已有列的常用方法val enrichedDF orderDF .withColumn(year, year(to_date($create_time))) .withColumn(amount_bucket, when($amount 100, 高).otherwise(低))withColumn有两个参数第一个是新列名第二个是Column表达式。注意如果列名已经存在它会把这一列覆盖掉所以在给已有列做清洗转换时很方便但要注意不要误覆盖。drop用来删除列distinct和dropDuplicates用来去重。它们的区别在于// 全字段去重 val distinctDF orderDF.distinct() // 指定字段去重保留第一次出现的记录 val dedupDF orderDF.dropDuplicates(user_id, category)实际开发中dropDuplicates比distinct更常用因为你通常想按业务主键去重而不是所有字段完全一致才算重复。比如订单明细表同一用户同一商品分类可能出现多次你要的是每个用户每个分类只保留一条记录这时就应该用dropDuplicates(user_id, category)。3.5 排序、limit和repartition的配合排序用orderBy或者sort两者没有本质区别。要注意的点是如果排序后的结果要写入文件并且你希望每个输出文件内部是有序的需要结合repartition来控制分区数因为每个分区最终对应一个文件分区内部有序不等于全局有序。val sortedDF orderDF.orderBy($amount.desc) // 如果你想输出到文件且只想要一个有序文件 val resultDF sortedDF.coalesce(1)但coalesce(1)会把所有数据拉到单个分区数据量大时非常危险容易直接OOM。更优雅的写法是repartition(1)原理类似但repartition会产生shuffle。数据量大时我建议直接输出多个文件再通过下游合并不要强求单文件。limit的作用是截取前N行它在逻辑计划层会被特殊优化通常只需要扫描一个分区就能得到结果这是limit和filter等其他算子不同的一点。3.6 高频DSL方法速查速记方法作用典型用法select选列接收Column对象df.select($a, $b)selectExpr选列并做表达式计算df.selectExpr(a, b * 2 as b2)where / filter条件过滤df.where($amount 100)groupBy agg分组聚合df.groupBy($city).agg(sum($amount))join关联两张表df1.join(df2, Seq(id), inner)withColumn新增/覆盖列df.withColumn(x, lit(1))dropDuplicates指定字段去重df.dropDuplicates(id)distinct全字段去重df.distinct()orderBy / sort排序df.orderBy($a.desc)limit取前N行df.limit(10)drop删除列df.drop(a)alias表/列别名df.alias(t1)这一节列出的方法基本覆盖了我日常开发中DSL操作85%以上的场景。剩下的比如union、intersect、except这些集合类操作用的频率不如上面这些高但也建议翻一遍官方文档。4. SQL风格语法临时视图、全局视图与spark.sql()的取舍4.1 为什么有了DSL还要用SQL很多刚接触Spark的人会有这个疑问DSL已经能把数据处理的每步写得清清楚楚为什么还要绕一圈去建临时视图再用spark.sql()写SQL我的看法是SQL风格的优点在于表达复杂关联和聚合时更紧凑而且对于从SQL数仓转过来的开发者完全没有学习障碍。你写的Spark SQL和你在MySQL、Hive里写的SQL几乎没有区别区别只是底层引擎从数据库换成了Spark。团队协作的时候SQL可读性也更强——一个复杂的多表join窗口函数用DSL写出来要一长串方法嵌套用SQL写可能十几行就完了而且一眼能看出逻辑。另外还有一个现实原因很多数据开发规范里SQL代码更容易做审批和审计因为业务方和数仓团队都熟悉SQL。4.2 createOrReplaceTempView与createGlobalTempView把DataFrame注册成一张临时视图然后就可以用SQL查询了// 注册临时视图 orderDF.createOrReplaceTempView(order_tmp) userDF.createOrReplaceTempView(user_tmp) // 用spark.sql查询 val resultDF spark.sql( |SELECT | t1.user_id, | t2.user_name, | count(*) as order_cnt, | sum(t1.amount) as total_amount |FROM order_tmp t1 |JOIN user_tmp t2 | ON t1.user_id t2.user_id |WHERE t1.category 手机数码 |GROUP BY t1.user_id, t2.user_name |HAVING count(*) 1 |ORDER BY total_amount DESC |.stripMargin)这里有几个关键点第一createOrReplaceTempView创建的视图生命周期是SparkSession范围内的session结束或者调用dropTempView时消失。它不会跨session共享。第二如果你希望这个视图在多个SparkSession之间共享就要用createGlobalTempView访问时要带上前缀global_temp.orderDF.createGlobalTempView(order_global) spark.sql(SELECT * FROM global_temp.order_global).show()实际项目中99%的场景用createOrReplaceTempView就够了因为同一个SparkSession里可以注册很多临时视图足够处理复杂的业务逻辑。全局视图用的少除非你明确有多session交互的需求。4.3 字符串插值SQL的坑与别名丢失问题用spark.sql()有一个常见的坑SQL字符串里写Scala变量容易踩到字符串插值的坑。比如val dateStr 2024-01-01 val df spark.sql(sSELECT * FROM order_tmp WHERE dt $dateStr)这里s插值是Scala的$dateStr会替换成变量的值。但如果你的SQL里本身有$符号比如Hive风格参数或者正则表达式就会冲突。解决办法是在需要字面量$的地方用$$转义或者在插值时避免在SQL里直接写$。另一个常见问题是spark.sql()返回的DataFrame如果SQL里没有给列起别名列名可能和预期不一致。尤其是聚合函数比如count(*)生成的结果列名默认是count(*)你后面要用这个字段去做别的事最好在SQL里显式as别名SELECT count(*) as order_cnt, avg(amount) as avg_amount这样后续DSL操作就可以直接$.order_cnt、$.avg_amount取值不用写那种带括号的古怪列名。4.4 DSL和SQL风格该选谁我自己的判断标准在同一个项目里我会这样划定边界简单过滤、单表列裁剪、快速查看数据用DSL写起来短类型安全IDE提示友好复杂多表关联、多层子查询、窗口函数、统计分析用SQL可读性强方便直接粘到数据库工具里跑通逻辑再搬进Spark两者可以混用比如先用DSL做数据清洗注册成视图再用SQL做复杂分析最后再用DSL做结果集处理。不要觉得混用不优雅。SparkSQL的底层执行计划是统一的DSL和SQL只是两种表达形式最终都被Catalyst解析成同样的逻辑计划。你按可读性和维护成本来选就行不用纠结纯度。5. Spark读写操作从JSON、CSV、Parquet到JDBC的完整样例5.1 读操作的统一入口spark.read.format()Spark针对不同数据源提供了统一的读取入口最通用的写法是val df spark.read .format(json) // 指定数据源格式 .option(path, hdfs:///data/source/xxx.json) .load()也可以省略format直接用json/csv/parquet等方法。我更喜欢显式写format因为这样统一而且可以配合option做很多细粒度控制。比如CSV文件普遍需要处理表头、分隔符、schema推断等问题val csvDF spark.read .format(csv) .option(header, true) // 首行作为表头 .option(sep, ,) // 分隔符 .option(inferSchema, true) // 自动推断类型 .option(encoding, UTF-8) // 文件编码 .load(/data/csv/orders.csv)inferSchema这个选项很关键但也很有坑。自动推断类型需要Spark额外扫描一遍数据数据量大时会拖慢读取速度。而且推断结果不一定准确比如一个字段大部分是数字但偶尔有几行是字符串推断出来的类型对不上后面处理就会报错。所以生产环境我更倾向于不用inferSchema而是手动指定Schemaimport org.apache.spark.sql.types._ val customSchema StructType(Array( StructField(order_id, StringType, nullable false), StructField(user_id, StringType, nullable true), StructField(amount, DoubleType, nullable true) )) val csvDF spark.read .format(csv) .option(header, true) .option(sep, ,) .schema(customSchema) .load(/data/csv/orders.csv)手动指定Schema的另一个好处是读取速度快因为Spark不需要再扫描一遍去做类型推断。如果你的CSV文件非常大这一点在耗时上的差异会非常明显。JSON文件读取也有类似问题。JSON是嵌套结构Spark解析时如果遇到字段缺失会填null如果同一个字段在不同record里的类型不一致解析也可能报错。读JSON时先printSchema()看一眼推断结果再继续后面逻辑是个值得养成的习惯。5.2 Parquet格式为什么生产中强推如果说CSV和JSON是日常工作常客那Parquet就是生产环境的“默认选项”。Parquet是列式存储格式具备几个最突出的优点高性能按列读取只需要扫描查询涉及的列IO量大幅减少自带Schema写入时就记录了字段类型读取时不用再推断高压缩率配合snappy压缩存储省、传输快。// 读Parquet val parquetDF spark.read .format(parquet) .load(/data/warehouse/orders) // 写Parquet parquetDF.write .mode(SaveMode.Overwrite) // 覆盖写入 .partitionBy(dt) // 按分区写 .format(parquet) .save(/data/warehouse/orders_out)partitionBy是写Hive风格分区表的常用方式。它会按照指定列的取值自动创建子目录比如dt2024-01-01、dt2024-01-02下游查询时Spark只扫描需要的分区性能提升非常明显。但要注意partitionBy的列必须是那些基数低、适合做过滤条件的字段不要选user_id这种高基数列否则会生成海量小文件反而不利于后续读取。5.3 写操作中的modeOverwrite、Append、Ignore、ErrorIfExists写操作里最让人困惑的其实是mode。四种模式的区别必须记清楚Mode行为Overwrite覆盖写入目标路径已存在时会先删除再写Append追加写入保留原有数据新数据追加到文件末尾Ignore如果目标路径存在直接忽略本次写入不报错ErrorIfExists默认模式目标路径已存在时直接抛异常一个我踩过的坑对同一个目录反复执行Overwrite写入有时会报“path already exists”异常。原因是不同批次的任务可能产生一样的临时文件名或者目标目录是一个分区表目录Overwrite的粒度与分区粒度需要匹配。解决办法是把写目录设计成分区目录然后按分区写入比如df.write .mode(overwrite) .format(parquet) .option(partitionOverwriteMode, dynamic) .partitionBy(dt) .save(/data/warehouse/orders)这样可以做到“只覆盖dt对应的分区”而不是把整个目录清空重来。对于离线数仓增量更新场景这个设置几乎是标配。5.4 JDBC读写数据库直连的注意点Spark读关系型数据库在抽取数据到数仓时很常见。核心写法val jdbcDF spark.read .format(jdbc) .option(url, jdbc:mysql://host:3306/analytics) .option(dbtable, orders) .option(user, root) .option(password, ******) .option(driver, com.mysql.cj.jdbc.Driver) .load()写回数据库时同理jdbcDF.filter($dt 2024-01-01) .write .mode(overwrite) .format(jdbc) .option(url, jdbc:mysql://host:3306/analytics) .option(dbtable, orders_result) .option(user, root) .option(password, ******) .save()JDBC读写最需要注意的是并发度。默认情况下Spark会用一个分区去读整个表数据量大时非常慢。你可以通过partitionColumn、lowerBound、upperBound、numPartitions来控制并发读取val jdbcDF spark.read .format(jdbc) .option(url, jdbc:mysql://host:3306/analytics) .option(dbtable, orders) .option(user, root) .option(password, ******) .option(partitionColumn, id) .option(lowerBound, 1) .option(upperBound, 1000000) .option(numPartitions, 10) .load()这样Spark会按id字段均匀切分10个区间并发读取。不过有个隐含条件partitionColumn必须是数值类型、且哪一行是均分可行的。如果表没有合适的数值型主键也可以先WHERE条件拆开用union合并结果。5.5 小文件的隐患与输出合并最后一个读写相关的实操经验千万注意小文件问题。Spark写文件时每个分区会对应输出若干文件如果数据经过了过多分区数大的shuffle产出的文件可能成千上万但每个文件就几百KB这对HDFS和下游分析引擎都是灾难。解决方法有两个方向写入前用repartition(n)或coalesce(n)控制分区数从而控制输出文件数写入后对小文件做合并比如Spark 3.x的repartition配合coalesce或者用AQE自适应查询执行打开spark.sql.adaptive.coalescePartitions.enabledtrue让Spark自动合并shuffle后的小分区。我自己的习惯是在写最终结果时先按业务需求确定合理的文件大小和数量再显式设置分区数。不要完全依赖默认配置因为默认情况下分区数可能非常大尤其在生产集群上跑大作业时这个坑很容易让人返工。6. 获取Column对象的四种方式以及各自的最佳使用场景6.1 四种方式逐个讲清楚在SparkSQL里Column对象是一切DSL表达式的基础。你要做过滤、聚合、排序、新增列本质上都是构造Column表达式。所以我专门用一节来讲获取Column对象的几种方式因为很多代码写不出来都是卡在不知道怎么写出一列。方式一col(列名)最常用也最没有歧义的方式。来自org.apache.spark.sql.functions.col可以在表达式任意位置使用import org.apache.spark.sql.functions.col orderDF.select(col(order_id), col(user_id)) .where(col(amount) 100)col的优势是它是一个全局可用的普通函数不依赖任何隐式转换在写UDF时、在构造表达式时都很方便。比如val dateCol col(dt) df.where(dateCol 2024-01-01)方式二$列名字符串前缀$是Scala的语法糖需要导入spark.implicits._才可用orderDF.select($order_id, $amount * 2).show()它的好处是写法最简洁读起来像自然语言。缺点是在某些场景下容易传错比如你用$列名去join同名字段时会因歧义报错。方式三df(列名)这是DataFrame本身提供的方法返回一个Column对象同时自动带有这个DataFrame的表前缀val orderDF2 orderDF.select( orderDF(order_id), orderDF(user_id), orderDF(amount) )这个方法最大的价值就是解决join时同名字段的歧义问题orderDF.join(userDF, orderDF(user_id) userDF(user_id), inner)当两个DataFrame都有user_id列时只有df(列名)这种写法能明确表示你选的是哪张表的列col和$都会在run time报“ambiguous reference”。方式四列名单引号加列名是Scala的Symbol字面量也需要导入spark.implicits._Spark把它隐式转换成Column对象orderDF.select(order_id, amount).show()这种方式在代码里很少见因为它太反直觉了容易和字符串混淆官方也不推荐新代码使用。我列出它只是为了让你在别人旧代码里见到时不懵。6.2 四种方式对照与选型建议方式写法需要implicits适用场景col(列名)col(user_id)否通用表达式、UDF、函数式编程$列名$user_id是简洁select/where表达式df(列名)df(user_id)否多表join时消除列歧义列名user_id是老代码、不推荐新写我自己的使用习惯是单表操作用$列名多表join用df(列名)写UDF或者复杂表达式用col(列名)。这样的组合既能保证简洁又能避免歧义报错还能让代码在不同场景下自解释。6.3 写表达式时绕不开的几个常见坑最后分享几个关于Column的实操坑都是我实际开发时遇到过并且带着团队排查过的坑一是字符串列和数值列比较时类型不对。比如DataFrame里amount被推断成了StringType你用$amount 100做过滤Spark会直接报错因为StringType不能和IntType比较。解决办法是读取时手动指定Schema或者写表达式时先转换orderDF.where($amount.cast(double) 100)坑二是和混用。在Spark的Column表达式里判等用的是和!不是Scala的。是在比较Column对象引用是否相同所以永远为false。这个错误几乎每个新人都踩过排查方法就是看过滤结果是不是空表。坑三是select多个Column时把lit字面量和列表达式混用。需要添加一个常量列时要用lit(1)而不是直接1orderDF.withColumn(source, lit(app))如果把lit漏了写成withColumn(source, app)编译会直接报类型不匹配因为withColumn第二参数要求Column类型而不仅仅是字符串。这类问题用IDE的编译期检查就能发现所以我说能编译过就尽早编译不要等到提交到集群再靠日志定位。Column相关的经验基本就这些。掌握了这四种获取列的方式、理解了Column本质上是一个表达式树你在写SparkSQL时遇到的绝大多数表达式问题都可以自己解决了。7. 我的一点使用体会把这些串起来的实战顺序写到这里核心内容基本都讲完了。最后我以自己带项目时的顺序给你一个可以直接照抄的上手路径先建立SparkSession并导入implicits然后从你的数据源用spark.read读入DataFrame如果数据是自定义对象就用toDS()转成DataSet保证类型安全接下来用DSL做清洗比如withColumn改类型、dropDuplicates去重、where过滤脏数据清洗完了注册成临时视图用spark.sql()写复杂统计逻辑结果再用DSL做收尾处理最后按Parquet格式分区写出。这条路径我在多个数仓项目和离线报表任务里都验证过稳定性和可维护性都比纯RDD编程高出一大截。建议你拿一份真实的业务数据哪怕是模拟的按今天讲的顺序完整跑一遍比单纯看十篇文章都有用。如果你在练手过程中遇到了我上面提到的某个坑比如ambiguous reference、类型推断出错、或者Overwrite写不进目录回头对照相应小节大概率就能定位到原因。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →