Spark 数据序列化:Kryo 比 Java 默认序列化快了多少
一个配置参数就能让 Spark 作业快 2-3 倍,还省一半内存,这种好事你不了解一下?
一、序列化是 Spark 的"隐形税"
Spark 是分布式计算引擎,数据要在不同节点之间传来传去——从 Driver 发到 Executor,从 Map 端 shuffle 到 Reduce 端,中间还有缓存和持久化。这些场景下,数据对象必须变成字节流才能传输和存储,这个过程就叫序列化。
问题在于,Java 的默认序列化(ObjectOutputStream)不是为了性能设计的。它追求的是兼容性和功能完整性,而不是速度和体积。每次序列化一个对象,Java 会把类的元信息、继承链、每个字段的描述都塞进去,结果一个 100 字节的对象,序列化后可能有 500 字节。
Spark 的默认序列化器是 Java 的原生机制,但支持切换到 Kryo。Kryo 是一个专门为高速序列化设计的库,它的核心优化就两点:不存元信息(靠注册类来约定格式)、用更紧凑的二进制编码。
为什么:Java 序列化膨胀的根源是"自描述"设计——序列化后的字节流里包含了类名、字段名、字段类型描述、继承链甚至父类的完整信息。这让序列化流可以在没有任何外部约定的情况下被反序列化("我读到什么类就构造什么类"),但代价是每条数据都携带一套完整的"使用说明书"。在 Spark 的 shuffle 场景中,同一个 UserAction 对象要被序列化 1000 万次——Java 就等于在网络上传输了 1000 万份相同的"使用说明书"。Kryo 的思路是"外部约定":你在代码里
registerKryoClasses(Array(classOf[UserAction]))注册一次,Kryo 就给这个类分配一个数字 ID。后续所有该类的对象序列化时只写这个 ID 和数据本身,不再写类名和字段描述信息。1000 万份"使用说明书"变成了一份,体积自然暴降。
二、Kryo vs Java:实测数据说话
说再多不如跑个数据。我写了一个对比测试,模拟 Spark 中最常见的 shuffle 场景:
import org.apache.spark.{SparkConf, SparkContext} import org.apache.spark.rdd.RDD // ============================================ // 测试用的数据类 —— 模拟一条用户行为记录 // ============================================ case class UserAction( userId: Long, // 用户 ID actionType: String, // 行为类型,如 click / view / order itemId: Long, // 商品 ID timestamp: Long, // 时间戳 device: String, // 设备类型 channel: String // 渠道来源 ) object SerializationBenchmark { def main(args: Array[String]): Unit = { // ============================================ // 配置 1:使用 Java 默认序列化 // ============================================ val confJava = new SparkConf() .setAppName("JavaSerialization") // 不设置 spark.serializer,默认就是 Java 序列化 val scJava = new SparkContext(confJava) // 生成 1000 万条测试数据 // random 随机生成 user/action/item 组合 val dataJava = scJava.parallelize( (1 to 10000000).map { i => UserAction( i % 100000, // 10 万个不同用户 if (i % 3 == 0) "click" else if (i % 3 == 1) "view" else "order", i % 50000, // 5 万个不同商品 System.currentTimeMillis() + i * 1000, if (i % 2 == 0) "iOS" else "Android", if (i % 4 == 0) "organic" else "paid" ) }, numSlices = 100 // 100 个分区,制造大量 shuffle ) val t1 = System.currentTimeMillis() // groupBy 触发大量 shuffle,序列化开销明显 dataJava.groupBy(_.userId).count() val t2 = System.currentTimeMillis() println(s"Java 序列化耗时: ${t2 - t1}ms") scJava.stop() // ============================================ // 配置 2:使用 Kryo 序列化 // ============================================ val confKryo = new SparkConf() .setAppName("KryoSerialization") .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") // 切换到 Kryo // 注册需要序列化的类 —— 这是 Kryo 性能的关键 // 注册后 Kryo 用 ID 代替类名,大幅压缩体积 .registerKryoClasses(Array(classOf[UserAction])) val scKryo = new SparkContext(confKryo) val dataKryo = scKryo.parallelize( (1 to 10000000).map { i => UserAction( i % 100000, if (i % 3 == 0) "click" else if (i % 3 == 1) "view" else "order", i % 50000, System.currentTimeMillis() + i * 1000, if (i % 2 == 0) "iOS" else "Android", if (i % 4 == 0) "organic" else "paid" ) }, numSlices = 100 ) val t3 = System.currentTimeMillis() dataKryo.groupBy(_.userId).count() // 同样的操作 val t4 = System.currentTimeMillis() println(s"Kryo 序列化耗时: ${t4 - t3}ms") scKryo.stop() } }在我的测试环境下(3 节点集群,每节点 16G 内存),结果大致如下:
| 指标 | Java 默认 | Kryo | 提升 |
|---|---|---|---|
| Shuffle 阶段耗时 | 180s | 65s | 2.8x |
| Shuffle 数据量 | 2.3GB | 0.9GB | 2.6x |
| 总作业耗时 | 210s | 82s | 2.6x |
Kryo 在 shuffle 场景下的表现,速度提升 2-3 倍,数据体积减少 50-60%,而且几乎零成本——就是改一个配置参数的事。
为什么:Kryo 快的不只是 I/O,还有 CPU。序列化/反序列化本质上是在做"内存中的对象 → 字节流 → 内存中的对象"的转换,这个过程中有两个瓶颈:一是字节流的体积决定了网络和磁盘 I/O 的时间,二是编码/解码算法本身消耗的 CPU 时间。Java 的序列化使用反射来解析对象的字段结构——每次反序列化都要通过反射获取类的字段信息、解析类型层次、检查继承关系——这是一个 CPU 密集型操作。Kryo 在注册类之后,字段的读写路径是编译期确定的(类似写了一个定制的序列化器),不走反射,CPU 开销显著降低。所以 Kryo 的快是"双重的快"——数据更小(I/O 更快)+ 编解码更快(CPU 更快)。
三、Kryo 的正确配置姿势
虽然 Kryo 好用,但乱配反而可能出 bug。以下是生产环境的推荐配置:
val conf = new SparkConf() .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") // ============================================ // 关键配置 1:注册自定义类 // ============================================ // 不注册也能用,但 Kryo 会存完整的类名,体积优势就没了 .registerKryoClasses(Array( classOf[UserAction], classOf[Array[UserAction]], // 数组类也要单独注册 classOf[(Long, Iterable[UserAction])], // shuffle 产出的 Tuple )) // ============================================ // 关键配置 2:要求必须注册(生产环境强烈建议开启) // ============================================ // 开启后,未注册的类会直接报错,防止漏注册导致的性能退化 .set("spark.kryo.registrationRequired", "true") // ============================================ // 关键配置 3:序列化缓冲区大小 // ============================================ // 默认 64KB,数据对象大时需要调大 // 如果报 "Buffer overflow" 错误,就加大这个值 .set("spark.kryoserializer.buffer.max", "128m") val spark = new SparkSession.Builder().config(conf).getOrCreate()另外,以下 Spark 内置类型已经自动注册,不需要手动加:
String、Long、Int、Double等基础类型Array[Byte]、Array[Int]等基础数组Option、scala.collection.mutable.WrappedArray- 常用集合类:
ArrayBuffer、HashMap、HashSet
为什么:
registrationRequired=true是一个"防退化"开关,它的重要性远超过"推荐"。想象一下:你团队里来了一个新同事,在 UserAction 里加了一个内嵌的Address类,但他不知道需要注册classOf[Address]。如果registrationRequired=false(默认),Kryo 检测到 Address 没注册后会自动切换到"全名编码"模式——编译时发现不了,运行时性能默默退化直到有人注意到。如果registrationRequired=true,Address 没注册就立即报Class is not registered异常,逼着你在提交代码前把注册补上。这个开关的核心价值不是"提升性能",而是"防止性能退化"。
四、哪些场景不适合 Kryo
虽然 Kryo 大部分情况下都有优势,但有两个场景需要注意:
1. 流式处理(Structured Streaming):Spark Streaming 的 checkpoint 机制依赖 Java 序列化的某些特性(比如引用处理),用 Kryo 可能造成 checkpoint 写入失败。流式场景建议保持 Java 默认序列化,或者在充分测试后再切。
2. 复杂嵌套对象:如果数据类里嵌套了多层自定义对象,Kryo 需要注册所有层级的类,漏一个就报错。对于这种场景,建议用 Avro 或 Protobuf 做序列化,不用 Kryo 硬扛:
// 复杂嵌套场景,用 DataFrame + Encoder 比 RDD + Kryo 更好 // DataFrame 的 Encoder 使用 Tungsten 二进制格式,不需要序列化 case class Order(id: Long, items: Seq[Item], user: User) case class Item(sku: String, price: Double) case class User(name: String, age: Int) // 直接用 Dataset,Spark 内部用 Encoder 处理 import spark.implicits._ val orders: Dataset[Order] = Seq(...).toDS() // Encoder 的性能不输 Kryo,且不需要手动注册踩坑提醒
别忘了注册 Map 和集合类:
case class本身注册了,但 shuffle 过程中的中间产物是(key, Iterable[value])这样的 Tuple 和集合,必须一起注册。经验法则是:把你的数据类、数据数组、shuffle 产出的 Tuple 全部注册。Kryo 缓冲区调大不要盲目:
spark.kryoserializer.buffer.max设太大(比如 2G)会造成每个 Executor 都分配 2G 的序列化缓冲区,内存直接被占满。先设小(64M),报Buffer overflow再调大一倍,逐步找到最优值。切换 Kryo 后要重跑一次历史作业做对比:不只是看总耗时,还要看 Shuffle Write/Read 的数据量变化、GC 时间变化。有时候 Kryo 让 shuffle 更快了但 GC 更频繁了(因为反序列化更快意味着更多对象进入内存),需要整体衡量。
五、总结
Kryo 序列化是 Spark 调优中性价比最高的优化之一:
- 改动成本:一个配置参数 + 注册自定义类
- 收益:Shuffle 阶段快 2-3 倍,网络/磁盘数据减半
- 风险:只要注意注册类和流式场景的兼容性,基本没坑
如果你现在的 Spark 作业还跑在 Java 默认序列化上,强烈建议今天就加spark.serializer=org.apache.spark.serializer.KryoSerializer试试。别忘了一起加上spark.kryo.registrationRequired=true和注册你的核心类——光改一个序列化器名而不注册类,效果会打折扣。
本文由朱大喜原创,Spark 调优系列还会继续更新,欢迎关注~