DataFrame 与 Dataset:类型安全与性能的折中
上一篇解决了 Catalyst 优化器的四阶段流水线。这一篇进入用户 API 层——RDD、DataFrame、Dataset 三代 API 的演进逻辑。
三者的区别容易被简化为"RDD 是低级 API,DataFrame 是高级 API"。更准确的说法是:三代 API 在类型安全和执行优化之间做了不同的取舍。RDD 完全类型安全但无法被 Catalyst 优化;DataFrame 完全被 Catalyst 优化但放弃了编译期类型检查;Dataset 试图兼顾两者,但付出了 Encoder 序列化/反序列化的代价。
本文只抓一个问题:三代 API 在内部表示、优化路径和序列化机制上的具体差异。
三代 API 的内部表示
1 | |
关键区别在于"黑盒"问题。当用户传入一个 lambda 函数(如 dataset.map(person => person.age + 1)),Catalyst 无法解析 lambda 内部的逻辑。它只知道"这是一个从 Person 到 Int 的函数",不知道函数访问了哪个字段、做了什么运算。因此无法做谓词下推或列裁剪。
相比之下,DataFrame 的列操作(如 df.select($"age" + 1))使用 Spark 的 Expression 对象构建,Catalyst 可以完全解析和优化。
DataFrame:无类型但可优化
DataFrame 是 Spark 1.3 引入的 API,本质是 Dataset[Row] 的类型别名。Row 是一个无类型的容器,按索引或列名访问字段。
1 | |
DataFrame 的劣势是类型错误延迟到运行时。如果写了 df.select($"agee")(拼写错误),编译器不会报错,只有运行时 Analysis 阶段才会抛出 AnalysisException。
Dataset:类型安全但有序列化代价
Dataset 是 Spark 1.6 引入的 API,试图在 DataFrame 的优化能力和 RDD 的类型安全之间找平衡。
1 | |
Dataset 的核心机制是 Encoder。Encoder 知道如何在 UnsafeRow(二进制格式)和 JVM 对象(如 Person)之间转换。当用户调用 lambda 操作(如 map、filter with lambda)时,Spark 需要把 UnsafeRow 反序列化为 Person 对象,调用 lambda,再把结果序列化回 UnsafeRow。
1 | |
这个反序列化-序列化的往返是 Dataset lambda 操作的性能代价。如果整个查询都用关系操作(select、filter with column expression、groupBy、agg),Dataset 和 DataFrame 的性能完全相同,因为不触发 Encoder 转换。
实验:对比三种 API 的执行计划
1 | |
在 explain 输出中,如果看到 DeserializeToObject 和 SerializeFromObject 节点,说明发生了 Encoder 转换。纯关系操作(select/filter with column)的计划中不会出现这些节点。
选择指南
1 | |
一条务实的建议:默认用 DataFrame(关系操作),需要类型安全时用 Dataset(关系操作优先、lambda 备选),只有 Catalyst 真的无法表达的逻辑才退回 RDD。
模式提炼
三代 API 的演进体现了一个通用的设计张力:抽象级别 vs 优化空间。
抽象级别越高(RDD 的任意 lambda),系统越不了解用户意图,优化空间越小。抽象级别越受限(DataFrame 的关系操作),系统对用户意图的理解越完整,优化空间越大。Dataset 尝试用 Encoder 机制在两者之间架桥,但桥的两端仍然有转换代价。
同样的张力在其他系统中反复出现:SQL vs 存储过程(SQL 可优化但表达力受限,存储过程灵活但是优化器黑盒)、React 的声明式 JSX vs 命令式 DOM 操作、TensorFlow 的静态图 vs PyTorch 的动态图。
工程迁移表
| Spark 概念 | Flink 对应 | 数据库对应 |
|---|---|---|
| RDD[T](类型安全、无优化) | DataStream(处理函数是黑盒) | 存储过程(优化器无法介入) |
| DataFrame(无类型、全优化) | Table API / Flink SQL | SQL 查询(全路径优化) |
| Dataset[T](折中) | 无直接对应 | 带类型映射的 ORM 查询 |
| Encoder | Flink 的 TypeSerializer | JDBC ResultSet 映射 |
| UnsafeRow | Flink 的 BinaryRow | 数据库的行存储格式 |
常见误解
误解一:“Dataset 总是比 DataFrame 慢”。只有使用 lambda 操作时 Dataset 才有额外的 Encoder 开销。使用关系操作(select、filter with column、groupBy.agg)时,Dataset 和 DataFrame 走完全相同的执行路径,性能无差异。
误解二:“RDD API 已经废弃”。RDD API 没有废弃,但不再是推荐的主要 API。Spark 内部仍然在 RDD 层面执行物理计划——DataFrame/Dataset 最终被翻译成 RDD 操作。某些无法用关系操作表达的逻辑(如自定义分区器、非结构化数据处理)仍然需要 RDD。
误解三:“DataFrame 和 Dataset 可以随意转换而无代价”。DataFrame 到 Dataset(.as[T])的转换是懒惰的,本身没有代价。但转换后如果使用 lambda 操作,就会触发 Encoder 的反序列化。从 Dataset 到 DataFrame(.toDF())的转换也是懒惰的,没有实际的数据转换。
练习
-
对同一份百万行数据,分别用 RDD 的 map + reduceByKey、DataFrame 的 groupBy.agg、Dataset 的 groupByKey.reduceGroups 做 wordcount。在 Spark UI 中对比三者的执行时间和 Stage 数量。
-
对一个 Dataset[Person],分别用
ds.filter(_.age > 18)(lambda)和ds.filter($"age" > 18)(column expression)执行过滤。用 explain(true) 对比两者的物理计划,找出 DeserializeToObject 节点的有无。 -
思考:如果 Spark 能在编译期自动把 lambda
_.age > 18解析为 column expression$"age" > 18,Dataset 的 Encoder 代价是否可以完全消除?Scala 3 的 macro 或 inline 机制是否可以实现这一点?
系列导航
| 序号 | 主题 | 状态 |
|---|---|---|
| 00 | 导读:为什么 Spark 的核心是一张 DAG | 已完成 |
| 01 | RDD:弹性分布式数据集的五大属性 | 已完成 |
| 02 | 宽依赖与窄依赖:Stage 是怎样划分出来的 | 已完成 |
| 03 | DAG Scheduler 与 Task Scheduler:从逻辑计划到物理执行 | 已完成 |
| 04 | Shuffle 机制:数据跨分区交换的代价与优化 | 已完成 |
| 05 | 内存管理与 Tungsten:堆外内存、序列化与代码生成 | 已完成 |
| 06 | 存储体系:Block Manager、广播变量与累加器 | 已完成 |
| 07 | 容错机制:Lineage、Checkpoint 与推测执行 | 已完成 |
| 08 | Spark SQL 与 Catalyst:从 SQL 文本到物理算子树 | 已完成 |
| 09 | DataFrame 与 Dataset:类型安全与性能的折中 | 本篇 |
| 10 | Adaptive Query Execution:运行时改写执行计划 | 下一篇 |
| 11 | 数据源 API 与谓词下推:让存储层帮忙过滤 | |
| 12 | Structured Streaming:把流当成无界表 | |
| 13 | 水位线与状态管理:流处理的时间语义 | |
| 14 | Spark on YARN / K8s:资源管理与部署模式 | |
| 15 | 动态资源分配与作业调度:多租户集群的资源博弈 | |
| 16 | 性能诊断:Spark UI、Event Log 与调优思路 | |
| 17 | Spark 与 Flink:两种计算引擎的设计选择 |
参考资料
- Michael Armbrust et al. Spark SQL: Relational Data Processing in Spark. SIGMOD 2015.
- Apache Spark 官方文档:Datasets and DataFrames. https://spark.apache.org/docs/latest/sql-programming-guide.html
- Databricks Blog: Introducing Apache Spark Datasets. https://www.databricks.com/blog/2016-01-04/introducing-apache-spark-datasets.html
- Apache Spark 源码:
sql/catalyst/src/main/scala/org/apache/spark/sql/Encoder.scalahttps://github.com/apache/spark
