序列化是服务间通信的第一道工程问题:对象怎么变成字节、字节怎么变回对象、版本变了怎么兼容。Scala 的序列化生态从 JSON(Circe/upickle)到二进制(Scodec/Protobuf/Avro)覆盖完整。本文不讲「哪个库更好」的教条,而是把协议设计的决策框架讲清楚:什么时候用 JSON、什么时候上二进制、schema 演进怎么不破坏兼容、反序列化怎么防漏洞——这些才是生产里真正反复踩的坑。
前置:/scala-web-http-apps/(HTTP 与 JSON API)、/scala-microservices-practice/(服务通信)、/scala-type-system/(类型与编解码)、/scala-metaprogramming/(宏自动生成)。
目录
- 1. 序列化的本质与选型框架
- 2. Circe 实践:ADT 编码、字段命名与定制
- 3. upickle 与轻量 JSON
- 4. 二进制编码:Scodec 与自定义格式
- 5. 跨语言协议:Protobuf 与 Avro
- 6. schema 演进与兼容性规则
- 7. 版本化与迁移策略
- 8. 反序列化安全
- 9. 性能与数据大小权衡
- 10. 速查表与一句话记忆
- 延伸阅读
1. 序列化的本质与选型框架
序列化 = 「对象 ↔ 字节」的编解码,选型看四维:
选型四维:
□ 可读性:人能读吗(调试/日志/curl)→ JSON 赢
□ 性能:编解码速度 + 字节大小 → 二进制赢
□ 兼容性:schema 演进是否安全 → Protobuf/Avro 赢
□ 生态:Scala/跨语言支持 → JSON 最广,Protobuf 次之
主流选项:
□ JSON(Circe/upickle):可读、通用、默认首选
□ Protobuf/Avro:跨语言、强 schema、高性能
□ Scodec/自定义二进制:极致性能、格式完全可控
□ Java 原生序列化:危险(安全洞)+ 慢 → 避免
决策路径:
内网服务对性能敏感 + 跨语言 → Protobuf/Avro
对外 API / 日志 / 调试频繁 → JSON
极致场景(游戏/实时)→ 自定义二进制
工程要点:选型的核心是**「按通信对象与场景」**——对外 API 用 JSON(可读、通用),内部高性能跨语言用 Protobuf,极致场景才自定义二进制。默认 JSON,有充分理由再上二进制。
2. Circe 实践:ADT 编码、字段命名与定制
Circe 是 Scala 生态最主流的 JSON 库:
import io.circe._, io.circe.generic.auto._, io.circe.syntax._
case class User(id: Long, name: String, tags: List[String])
val u = User(1, "alice", List("tech", "scala"))
val json: Json = u.asJson // 编码
val back: Either[DecodingFailure, User] = json.as[User] // 解码
Circe 关键实践:
□ 半自动 vs 全自动:auto.* 全自动(宏);显式 Decoder 更可控
□ ADT 编码:sealed trait → JSON 需要「判别字段」
□ 字段命名:默认取 case class 字段名;可定制 snake_case
□ 非标 JSON:自定义 Decoder(手写 parse)
ADT 编码(需要判别器):
sealed trait Shape
case class Circle(r: Double) extends Shape
→ 默认编码 {"r": 1.0}(歧义)→ 定制成 {"type":"circle","r":1.0}
定制的时机:
□ 与外部系统对接(字段名不同)
□ 需要判别字段/默认值/容错解析
□ 字段缺失时给默认值(解码容错)
工程要点:Circe 实践的核心是**「默认够用,定制要懂」**——简单 case class 自动编解码即可;ADT 一定要定制判别字段(否则多态编码歧义);对外对接时按对方 schema 定制字段名与容错。
3. upickle 与轻量 JSON
需要轻量 JSON 时,upickle(µPickle)是不错选择:
upickle 特点:
□ 快:比 circe 快(更少宏开销)
□ 简单:单库搞定读写
□ 支持 Scala.js/Native(跨平台)
□ 格式:JSON + MessagePack(同一套 derive)
uPickle vs Circe:
□ upickle:轻、快、跨平台简单
□ circe:生态大、可定制性强、与 http4s/doobie 集成深
□ 选型:简单项目/跨平台 → upickle;复杂生态 → circe
import upickle.default._
case class User(id: Long, name: String)
val json = write(User(1, "alice")) // 序列化
val u = read[User](json) // 反序列化
工程要点:upickle 适合**「要快、要简单、可能要跨平台」**的场景;circe 适合「深度集成大生态」的场景。两者都能用 macro 自动 derive,选型主要看集成面。
4. 二进制编码:Scodec 与自定义格式
极致性能或特殊格式需求时用二进制编码:
Scodec(Scala 的二进制编解码库):
□ 声明式:把「位/字节布局」描述成组合器
□ 组合:byte * int32 * utf8_32 组成结构编解码
□ 可控:完全掌握字节布局(协议/文件格式)
何时需要二进制:
□ 内网高吞吐消息(带宽敏感)
□ 自定义协议/文件格式(游戏、物联网、存量协议)
□ 与 C/C++ 结构体对接(内存布局一致)
□ 极致延迟(减少编解码开销)
成本:
□ 不可读(调试难)
□ 格式由你负责演进(比 JSON/Protobuf 更费心)
□ 需要严格的「未知字段」处理
import scodec._
import scodec.codecs._
case class Packet(id: Int, payload: String)
val packetCodec: Codec[Packet] = (int32 :: utf8_32).as[Packet]
val bytes = packetCodec.encode(Packet(7, "hello"))
工程要点:自定义二进制的准则是**「有明确理由才上」**(带宽/格式兼容/性能),且必须自己负责格式演进——预留版本号、字段扩展位。Scodec 让字节布局声明式可控,适合协议开发,但不要为「可能更快」而牺牲可维护性。
5. 跨语言协议:Protobuf 与 Avro
跨语言(Scala + Go + Java + …)通信,用强 schema 协议:
Protobuf(gRPC 默认):
□ .proto 定义 schema → 生成各语言代码
□ 二进制紧凑、编解码快
□ 兼容性规则内建(字段编号、optional/repeated)
□ Scala 集成:ScalaPB / protobuf-scala
Avro(Kafka/数据湖常用):
□ schema 在数据里/注册表(Schema Registry)
□ 强演进:字段增删规则明确
□ Scala 集成:avro4s
对比:
□ Protobuf:RPC 服务间通信(gRPC)首选
□ Avro:数据管道/消息队列(Kafka)演进友好
□ 两者都是「schema 驱动的跨语言契约」
syntax = "proto3";
message User {
int64 id = 1;
string name = 2;
repeated string tags = 3; // 新增字段用新编号
}
工程要点:跨语言协议的准则是**「schema 即契约」**——Protobuf 用于 RPC(gRPC),Avro 用于数据管道(Kafka/Schema Registry)。字段用「编号」演进(不要重用编号),兼容性规则由协议内建,跨语言团队不必手写 JSON 映射。
6. schema 演进与兼容性规则
无论 JSON 还是二进制,schema 演进是长期服务绕不开的:
兼容性规则(前后向):
□ 前向兼容:新消费者能读旧数据(旧数据缺新字段 → 有默认)
□ 后向兼容:旧消费者能读新数据(新字段被旧消费者忽略)
具体规则:
□ 只加字段(不删/不改类型):最安全
□ 加字段给默认值:旧数据解析给默认,不崩
□ 删字段:危险 → 老消费者读新数据会崩(缺字段)
□ 改类型:极危险(JSON int→string 不兼容)
JSON 侧实践:
□ 解码容错:未知字段忽略(默认 Circe 忽略)
□ 缺失字段给默认(Decoder 定制)
□ 版本字段:payload 里带 schemaVersion,按版本解析
Protobuf/Avro 的演进保证:
□ Protobuf:编号唯一、optional 支持前向兼容、未知字段保留
□ Avro:字段默认值 + 演进规则表(add/remove 规则明确)
工程要点:schema 演进的第一铁律是**「只加字段、给默认值、不删不改」**——加字段配默认值保证前向兼容,未知字段忽略保证后向兼容。改类型/删字段是破坏性变更,必须走版本迁移(见下节),不能悄无声息。
7. 版本化与迁移策略
无法避免破坏性变更时,用版本化平滑迁移:
版本化策略:
□ 端点版本:/api/v1、/api/v2(API 清晰,维护多版本)
□ payload 版本:数据里带 version 字段,按版本解析
□ 双写/双读:新旧版本并存一段时间(迁移窗口)
□ 注册表版本:Schema Registry 管理 schema 版本 + 兼容级别
迁移流程:
1. 定义新 schema(v2),注册表注册,校验兼容性
2. 双写:生产端同时写 v1 + v2
3. 灰度读:消费者先读 v2(v1 兜底)
4. 验证稳定 → 停 v1 → 清理
兼容级别(Schema Registry):
□ BACKWARD:新读旧(默认)
□ FORWARD:旧读新
□ FULL:双向兼容
□ NONE:不校验(危险)
实际迁移示例(JSON API v1→v2):
用户对象字段 name: String → 拆分 firstName/lastName
v1 继续服务旧客户端;v2 服务新客户端
双写期:写时同时写 v1 格式与 v2 格式,切读 v2 后停 v1
工程要点:版本化的准则是**「双写 + 灰度切读 + 兼容校验」**——破坏性变更不要原地改 schema,而是开新版本、双写过渡、灰度切读。Schema Registry(Avro/Kafka)把兼容级别做成可校验的机制,避免「改完才发现不兼容」。
8. 反序列化安全
反序列化是安全重灾区,尤其是Java 原生序列化:
反序列化攻击:
□ Java 原生序列化:可构造恶意对象图 → RCE(历史漏洞无数)
□ JSON:一般安全(纯数据,无代码执行)
□ 二进制自定义:可能因「长度字段」构造越界/堆溢出
安全实践:
✗ 禁用 Java 原生序列化(ObjectInputStream)→ 替代 JSON/Protobuf
✓ 限制消息大小:超限拒绝(防内存耗尽)
✓ 校验入站 schema:未知字段/类型严格处理
✓ 输入验证:解码后做业务校验(防语义攻击)
✓ 依赖 CVE 监控:序列化库/解析库的漏洞跟踪
大小限制示例:
HTTP body 上限(如 10MB)、单条消息长度上限
解码前检查长度,超限直接 400/413
工程要点:反序列化安全的核心是**「禁用 Java 原生序列化 + 限制大小 + 校验入站」**——Scala/Java 服务最危险的序列化是 ObjectInputStream(RCE 面),一律用 JSON/Protobuf/Avro 替代。解码前限长、解码后校验,双保险。
9. 性能与数据大小权衡
序列化的性能影响在高吞吐路径上明显:
性能维度:
□ 编码速度:二进制 > JSON(Circe 快于通用 JSON)
□ 字节大小:Protobuf/自定义 < JSON(省带宽/存储)
□ GC 压力:编解码产生大量中间对象 → JSON 更明显
何时在意:
□ 高 QPS 内部服务:编解码开销占 CPU 比例可观
□ 大体积 payload:带宽/存储成本
□ 低 QPS/对外 API:可读性 > 性能
数据大小量级直觉:
"name": "alice" JSON ≈ 18B
Protobuf: 字段编号+长度+内容 ≈ 7B → 约 2-3x 差距
优化手法(JSON 内):
□ 压缩传输(gzip)
□ 减小字段名(定制 short key,牺牲可读性)
□ 避免无谓嵌套
工程要点:性能权衡的准则是**「在热路径上量化,不要臆测」**——先用 JSON 跑通,profile 后发现编解码是瓶颈、带宽吃紧,再针对性上二进制/压缩。为「可能更快」提前上二进制,牺牲可读性与开发效率,往往得不偿失。
10. 速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 默认用什么 | JSON(Circe/upickle),可读通用 |
| ADT 怎么编码 | 定制判别字段(type 字段) |
| 何时上二进制 | 带宽/性能/格式兼容有明确理由 |
| 跨语言用什么 | Protobuf(RPC)/ Avro(数据管道) |
| schema 怎么演进 | 只加字段、给默认值、不删不改 |
| 破坏性变更怎么办 | 版本化 + 双写 + 灰度切读 |
| 反序列化安全 | 禁 Java 原生序列化 + 限长 + 校验 |
| 性能怎么权衡 | 热路径量化再决定 |
一句话记忆:序列化设计 = 默认 JSON(Circe/upickle 可读通用)+ ADT 判别字段(多态不歧义)+ 有理由才二进制(Scodec)+ 跨语言上 Protobuf/Avro(schema 契约)+ 只加字段演进(前向兼容)+ 破坏性变更版本化(双写灰度)+ 禁原生序列化防 RCE(限长校验)——在可读性、性能、兼容性之间做「有理由的」选择。
延伸阅读
- /scala-web-http-apps/ — JSON API 与 HTTP 集成
- /scala-microservices-practice/ — 服务间通信与契约
- /scala-type-system/ — 类型系统与编解码
- /scala-metaprogramming/ — 宏自动 derive 编解码
- /scala-functional-error-handling/ — 解码错误处理
- Kafka 专题 — Avro 与 Schema Registry
- 数据工程专题 — 数据管道与契约
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。