3、Dataset 详解:Dataset 与 DataFrame 的关系、类型安全、Encoder 机制
好,咱们今天来聊聊 Dataset。说实话,这是 Spark SQL 里让我又爱又恨的一个概念。爱它是因为它真的能帮我们写出更健壮的代码,恨它是因为刚接触时确实容易搞混。我刚开始用 Spark 那会儿,就经常在 Dataset 和 DataFrame 之间来回切换,搞得自己都有点晕。
不过别担心,今天咱们就把这事儿彻底理清楚。
3.1 Dataset 与 DataFrame 的关系
先说说它们到底是什么关系。其实很简单——DataFrame 就是 Dataset[Row]。你没看错,就是这么回事。
DataFrame 是 Dataset 的一个特例。它里面的每一行都是 Row 类型。而 Dataset 可以是任意类型,比如 Dataset[Person]、Dataset[String] 等等。
我打个比方你就明白了。DataFrame 就像是一个装满杂货的箱子,每个格子都长得一样(都是 Row)。而 Dataset 呢,就像是一个专门放苹果的箱子,每个格子都是苹果,你拿出来的每个东西都知道它是苹果,不用再猜。
核心要点:
- DataFrame = Dataset[Row]
- Dataset 支持自定义类型(如 case class)
- DataFrame 是弱类型的,Dataset 是强类型的
在实际项目中,我一般这样用:如果只是做简单的数据清洗、过滤、聚合,用 DataFrame 就够了。但如果涉及到复杂的业务逻辑,需要把数据映射成对象来处理,我就会用 Dataset。举个例子:
// DataFrame 方式
val df = spark.read.json("people.json")
df.filter($"age" > 20).show()
// Dataset 方式
case class Person(name: String, age: Int)
val ds = spark.read.json("people.json").as[Person]
ds.filter(_.age > 20).show()
你看,Dataset 的写法更自然,就像操作普通的 Scala 集合一样。但这里有个坑——不是所有 DataFrame 操作都能直接转成 Dataset。我曾经在一个项目中,想把一个复杂的 DataFrame 转成 Dataset,结果发现有些嵌套结构解析不了,折腾了半天。
3.2 类型安全
说到类型安全,这其实是 Dataset 最大的卖点。你想想看,DataFrame 里存的都是 Row,你要取某个字段,得用 row.getAs[String]("name")。万一字段名写错了,或者类型写错了,运行时才会报错。
但 Dataset 就不一样了。它会在编译时就检查类型。比如:
// DataFrame - 运行时才能发现错误
df.select("nme").show() // 字段名写错了,运行时才报错
// Dataset - 编译时就报错
ds.map(p => p.nme) // 编译不通过,因为 Person 没有 nme 字段
我个人习惯在开发阶段尽量用 Dataset。为什么呢?因为能提前发现问题。我记得有一次,我们团队的一个同事用 DataFrame 写了一段很长的处理逻辑,结果上线后才发现有个字段名拼写错了。要是用 Dataset,这种低级错误根本不会发生。
我的建议:
在开发阶段,尽量用 Dataset。等代码稳定了,如果性能有要求,再考虑转成 DataFrame 优化。因为 Dataset 的序列化/反序列化是有开销的,这个咱们后面会讲。
类型安全还有一个好处——代码可读性更强。你看到 Person 就知道里面有什么字段,看到 Row 还得去查 schema。说白了,Dataset 让代码更「自文档化」。
3.3 Encoder 机制
好,接下来是重头戏——Encoder。这是 Dataset 能实现类型安全的关键。
简单来说,Encoder 就是负责把 JVM 对象和 Spark 内部二进制格式之间互相转换的东西。你可能会问:「为什么不用 Java 的序列化?」嗯,Java 序列化太慢了,而且占空间。Spark 自己搞了一套更高效的序列化机制。
我举个例子你就明白了。假设你有一个 Person 对象:
case class Person(name: String, age: Int)
当你要把这个对象存到 Dataset 里时,Encoder 会把它转成 Spark 内部的二进制格式。这个格式是经过优化的,占用内存更少,处理速度更快。
Spark 内置了很多常用的 Encoder:
| Scala 类型 | 对应的 Encoder |
|---|---|
| Int | Encoders.scalaInt |
| String | Encoders.STRING |
| Array[Byte] | Encoders.BINARY |
| case class | Encoders.product[T] |
| Tuple | Encoders.tuple |
这里有个重要的点——Encoder 是自动推导的。当你用 .as[Person] 时,Spark 会自动生成 Person 的 Encoder。但前提是 Person 必须是 case class 或者能被 Spark 识别。
注意:
如果你自定义了一个普通的 class,没有用 case class,那 Spark 可能无法自动生成 Encoder。这时候你需要自己实现 Encoder,或者用 Kryo 序列化。我曾经就踩过这个坑,写了一个普通 class,结果运行时一直报错,查了半天才发现是 Encoder 的问题。
说到性能,Encoder 比 Java 序列化和 Kryo 都要快。为什么呢?因为 Encoder 知道数据的 schema,可以针对性地优化。比如一个 Person 对象,Encoder 知道它有 name 和 age 两个字段,就可以直接按字段顺序存储,不需要存储额外的类型信息。
我做过一个测试,同样的数据量,用 Dataset 的 Encoder 比用 Java 序列化快了将近 10 倍,内存占用也少了 3-4 倍。所以,虽然 Dataset 在转换时有一些开销,但整体上还是值得的。
3.4 实际项目中的选择策略
说了这么多,到底什么时候用 Dataset,什么时候用 DataFrame?我总结一下我的经验:
- 数据探索阶段:用 DataFrame。因为灵活,可以随时改 schema,而且 API 更丰富。
- 业务逻辑实现:用 Dataset。因为类型安全,代码更健壮,不容易出 bug。
- 性能敏感场景:用 DataFrame。因为 Dataset 的 Encoder 转换有开销,如果数据量特别大,这个开销不可忽视。
- 需要序列化到外部系统:用 Dataset。因为 Encoder 生成的二进制格式更紧凑,传输效率更高。
嗯,差不多就这些。Dataset 和 DataFrame 没有绝对的好坏,关键看场景。我个人习惯是:能用 Dataset 就用 Dataset,除非性能瓶颈明显。毕竟,代码的可维护性和正确性,往往比那点性能差异更重要。