SERIALIZATION · DATA INTERCHANGE

序列化到底是什么?
JSON / Protobuf / Avro / Thrift 差在哪

程序里的对象住在内存中,是一堆带指针的结构;要把它发到网络对面、写进磁盘、塞进缓存,就得先变成一串字节。这个「对象 → 字节流」的翻译过程就是序列化,反过来的过程叫反序列化。本文用图 + 可交互的字节透视器,把四种主流格式的差别一次讲透。

1什么是序列化

内存里的对象是「靠地址和指针拼起来的」0x7f3a2c00 这种地址在别的进程、别的机器上毫无意义。序列化就是把对象拍平成一串自包含、可顺序读取的字节,让数据能「旅行」。

内存中的对象 User id = 150 name → 指针 0x7f3a.. email → 指针 0x7f3b.. active = true 地址只在当前进程内有意义 字节流(可传输 / 可存储) 08 96 01 12 05 41 6C 69 63 65 ... 一维、顺序排列、自包含 序列化 Serialize 对象 → 字节流 反序列化 Deserialize 字节流 → 对象
图 1 · 序列化:把「带指针的内存结构」翻译成「一维自包含的字节流」
📦

拍平结构

嵌套对象、数组、map 都要展开成线性的字节序列,读的时候再重建。

🧭

写清边界

字符串多长?字段从哪开始到哪结束?格式必须自带「说明书」,否则读不回来。

🔁

能还原

反序列化必须能在另一台机器、另一种语言里,重建出语义等价的对象。

💡 一个直觉类比 序列化就像「搬家打包」:家具(对象)没法直接塞进卡车,必须拆成零件、贴上编号、装进箱子(字节流);到了新家再按图纸装回去(反序列化)。不同的打包公司(JSON / Protobuf / Avro / Thrift)打包方式、箱子大小、说明书写法都不一样——这就是它们差别的来源。

2序列化用在哪里

只要有「数据要离开当前进程」的时刻,就有序列化。四个最典型的位置:

你的进程 内存对象 🌐 RPC / HTTP API 微服务之间调用 JSON / Protobuf(gRPC) ⚡ 缓存 Redis / Memcached JSON / Protobuf / Kryo 💾 存储 / 消息队列 Kafka、日志文件、数据湖 Avro / Protobuf / JSON 🗄️ 数据库字段 MySQL JSON 列 / ES 文档 JSON 居多
图 2 · 四大主战场:跨进程通信、缓存、持久化、消息传递
⚠️ 为什么格式选择很值钱 一个日均 10 亿次的 RPC 接口,如果序列化能省 30% 体积,就直接省下 30% 的带宽和相当一部分反序列化 CPU;反过来,一个高频接口选了笨重的格式,浪费的是真金白银的服务器。差别不是学术问题,是成本问题。

3差别从何而来:三个根本设计抉择

JSON、Protobuf、Avro、Thrift 的所有差别,几乎都能归结到三个「选择题」上。先建立这个坐标系,后面每一种技术的行为都会变得可以推理。

📝

抉择一:文本 or 二进制?

用「人类可读的字符」编码,还是「紧凑的原生字节」编码?

文本(JSON):人能直接看,调试爽,但体积大、解析要逐字符扫描。
二进制(其余三家):体积小、解析快,但肉眼不可读,必须靠工具。

📜

抉择二:Schema 放哪里?

数据的「字段名 + 类型」说明书,是随数据一起传,还是提前用 IDL 定义好、双方各自编译?

不随数据传(Protobuf/Thrift):体积省,但必须先交换 schema、做代码生成。
随数据存(Avro 文件):自描述,天然适合存档。

🔬

抉择三:只管格式 or 连 RPC 一起管?

只定义「字节长什么样」,还是顺便把跨语言的 RPC 框架、传输协议都打包给你?

只有格式(JSON、Avro、Protobuf 本体)。
全家桶(Thrift,Protobuf+gRPC 也算半个)。

三个抉择决定的四条技术路线 📝 JSON 文本 无 Schema 自描述 字段名跟着每条 消息重复传 可读性 优先 Protobuf 二进制 .proto 先行 字段编号代替字段名 编译期代码生成 搭配 gRPC 体积/速度 优先 🍃 Avro 二进制 Schema 与数据同行 读写双方各持 Schema 无字段编号 按字段名解析 Schema 演化 优先 🔧 Thrift 二进制(可选 JSON) .thrift IDL 多协议可切换 自带完整 RPC 栈 传输层/服务层全套 一站式 优先
图 3 · 四种技术的定位地图:同做序列化,取舍完全不同

4JSON:为人而生的文本格式

JSON(JavaScript Object Notation)用「字符」表达一切:数字 150 在网络上是 3 个字符(0x31 0x35 0x30),不是 1 个字节。字段名 "name" 原样出现在每一条消息里。

JSON
{ "id": 150, "name": "Alice", "email": "alice@example.com", "active": true }
同一条消息,JSON 的每个字节都是「给人看的字符」 "name" : "Alice" 字段名:6 个字节 每条消息都重复传输 值:7 个字节 加上引号、冒号、逗号等「语法税」 体积去哪了? 数字 150 → 3 个字符,而非 1 字节 true → 4 个字符 解析需逐字符扫描、构造字符串
图 4 · JSON 编码:可读性带来的「语法税」

JSON 的优势与代价

优势

· 人类直接可读,调试、日志、排查问题极方便
· 无需预定义 Schema,浏览器原生支持,跨语言零成本
· 生态无敌:REST API 事实标准,一切工具都认识它

代价

· 体积大:字段名重复 + 文本编码数字,通常比 Protobuf 大 3~10 倍
· 解析慢:文本解析 + 大量小字符串分配
· 类型弱:没有 int64/区分不了 int 和 float、没有日期类型、二进制数据必须 Base64 再膨胀 33%

5Protobuf:用「编号 + 类型」压缩一切

Protobuf 的两个核心点子:① 先用 IDL 定义结构,编译生成各语言的代码;② 数据里不再写字段名,只写「字段编号 + 类型 + 值」。字段 name 变成了 1 个字节的 0x12

user.proto
syntax = "proto3"; message User { int32 id = 1; // ← 字段编号是灵魂 string name = 2; string email = 3; bool active = 4; } // protoc --go_out=. user.proto → 生成 Go 代码

字段编号 + 类型合成了一个「tag 字节」:低 3 位是 wire type(值的编码方式),高 5 位是字段编号

例如字段 2(name,string)→ tag = 2 << 3 | 2 = 0x12,只需 1 个字节就替代了 JSON 里的 "name":(8 个字节)。

整数用 Varint 编码:小数字占更少字节。150 → 0x96 0x01(2 字节);带符号负数先用 ZigZag 映射成正数再 Varint。

Protobuf 编码 id=150, name="Alice"(共 10 字节,JSON 需 22 字节) 08 96 01 tag:字段1 + varint 型 150 的 varint 编码(低 7 位一组, 最高位=1 表示「后面还有」) 12 05 Alice tag:字段2 + 长度前缀型 长度:5 字节 字符串原始字节(UTF-8,无引号) wire type(tag 低 3 位)决定值怎么读 0 = Varint(int/bool/enum) 1 = 8 字节定长(double) 5 = 4 字节定长(float) 2 = 长度前缀(string/bytes/嵌套 message)
图 5 · Protobuf wire format:tag(字段编号+类型) + 值,紧凑到极致

编号带来的超能力:前后兼容

🔁 新增字段 = 前向兼容

旧程序读到不认识的字段编号时,根据 wire type 跳过(或暂存到 unknown fields),不会报错。服务端先升级加字段,老客户端照常工作。

🔁 删除字段 = 后向兼容

只要不回收、不重用已删除字段的编号(预留 reserved),新老版本就永远不冲突。所以 proto 文件里常见 reserved 5, 6;

⚠️ Protobuf 的铁律 字段编号一旦发布就不能再改含义。改编号 ≈ 换字段名还不通知对方,线上会出现「张冠李戴」的静默数据错乱。另外它是二进制格式:排查问题要靠 protoc --decode 这类工具,肉眼无法直接读。

6Avro:Schema 会「随行」和「演化」

Avro 出身 Hadoop 生态(作者 Doug Cutting 也是 Hadoop 之父),核心创新是「写数据时用的是哪个版本 Schema,读数据时用的是哪个版本 Schema,两者可以不同」——靠一个叫 Schema Resolution(模式解析) 的算法按字段名对齐。

user.avsc(注意:Schema 本身就是 JSON)
{ "type": "record", "name": "User", "fields": [ { "name": "id", "type": "int" }, { "name": "name", "type": "string" } ] }
Avro:写入 Schema 与 读取 Schema 各自演化,按字段名对齐 Writer Schema v2(写数据时) id : int name : string email : string ← 新增 (age 已被删除) 数据 只有值,无 tag 顺序由 Schema 定 Reader Schema v3(读数据时) id : int name : string (不认识 email → 丢弃) phone : string = 默认值 ← 填默认 Schema Resolution 规则(按字段名,而非编号) · 两边都有 → 按类型兼容规则转换(int → long 可以,long → int 溢出报错) · 只有 writer 有 → 直接跳过丢弃(如 email)  · 只有 reader 有 → 填默认值,没有默认值则报错(如 phone) · 改名也可以:alias 别名映射到旧名  → 演化自由度比 Protobuf 更高,代价是必须维护 Schema 注册表(如 Confluent Schema Registry)
图 6 · Avro 双 Schema 机制:新旧版本各读各的,永远能对话

📦 Avro 容器文件

存文件时 Schema 写在文件头部,数据块之间有 sync 标记——这让 Hadoop/Spark 可以按块并行切分处理一个大文件,且多年后读回时依然自描述。数据湖场景(含 Parquet 的思想渊源)大量受益于此。

⚡ 极致紧凑

因为双方都握着 Schema,编码时连 Protobuf 那个 tag 字节都不需要——只按顺序写值。同样数据通常是四者中最小的。Kafka 社区(Confluent)用它 + Schema Registry 做消息格式是经典组合。

7Thrift:不只是格式,是整套 RPC

Thrift 由 Facebook 开源(2007),动机就是「跨语言的 RPC 太难写」。所以它的定位和另外三个不同:序列化格式只是它三层架构中最底下的一层,上面还长着传输层和 RPC 服务层。

user.thrift(注意:不只定义数据,还定义方法)
struct User { 1: i32 id, 2: string name, 3: bool active } service UserService { User getUser(1: i32 id), // ← RPC 方法签名 list<User> listUsers(1: i32 limit), void addUser(1: User u), } // thrift --gen go user.thrift → 一次生成:数据结构 + 序列化 + 客户端桩 + 服务端骨架
Thrift 分层架构:格式只是地基,RPC 全家桶才是卖点 Client(任意语言) 生成的 Stub(调用像本地函数) Protocol(序列化协议) Transport(传输方式) Socket / HTTP / 文件… 网络字节流(协议可换) Server(任意语言) Processor(业务处理) Protocol(序列化协议) Transport(传输方式) 线程 / 线程池 / NonBlocking 协议可换 TBinary TCompact TJSON (历史: TDense 等) 同 IDL 多格式 调试可切 JSON 生产可切二进制
图 7 · Thrift 的「全家桶」:IDL 一次生成 Client/Server 两端所有层的代码
🆚 Thrift vs Protobuf+gRPC:今天怎么选 两者解决同一个问题(跨语言 RPC + 二进制序列化)。历史上 Thrift 出现更早、自带服务端线程模型;而如今 gRPC 生态(HTTP/2、流式、负载均衡、鉴权拦截器)更繁荣,新项目多选 Protobuf + gRPC。Thrift 的 TCompactProtocol 编码思路(field id + zigzag varint)与 Protobuf wire format 非常接近,学会一个就懂另一个。

8交互演示:同一份数据的编码透视

{ "id": 150, "name": "Alice", "active": true } 为例(不含 email,方便逐字节看清)。切换标签,看四种格式分别把它编码成什么样子、各占多少字节。也可以改改 id 和 name 试试。

JSON 文本

* 字节数为按各格式规范推算的示意值(JSON 含换行缩进前的紧凑形式;Thrift TCompactProtocol 的消息内字段编码)。真实实现可能有 1~2 字节出入,用于感受量级差异。

9对比总表

一张表收拢全部差别——可以先扫「定位」一行,再按需细看。

维度 📝 JSON ⚡ Protobuf 🍃 Avro 🔧 Thrift
出身 源于 JS 语言,2001 由 Douglas Crockford 推广 Google,2008 开源 Apache / Hadoop 体系,Doug Cutting Facebook,2007 开源
编码形态 文本 二进制 二进制 二进制(TJSON 可选)
可读性 肉眼直接读 需工具解码 需工具 + Schema TJSON 可读,二进制需工具
体积(同数据) 大(基准 1x) 约 1/3~1/10 最小(无 tag 字节) 与 Protobuf 相当
Schema 定义 无(JSON Schema 非核心) .proto IDL + 编译器 .avsc(本身是 JSON) .thrift IDL + 编译器
字段标识方式 字段名字符串,每条都传 字段编号 + wire type(tag 字节) 不编码标识,按 Schema 顺序,按字段名解析 字段编号 + 类型(TCompact 与 pb 类似)
Schema 演化 随意(反正没人管) 靠编号规则:加/删字段安全,禁改号 最灵活:按名解析 + alias + 默认值 与 Protobuf 类似的编号规则
代码生成 通常不需要 必须(protoc) 可选(可纯运行时解析,动态语言友好) 必须(thrift 编译器)
RPC 能力 无(配 REST/HTTP 手写) 本体无,配 gRPC 成全家桶 有内置 RPC(但少用,多用于消息/存储) 自带完整 Client/Server RPC 栈
典型场景 REST API、配置、日志、浏览器交互 微服务 gRPC、内部高吞吐接口 Kafka 消息、数据湖、Hadoop 存储 多语言 RPC、遗留大系统内部通信
一句话定位 为人服务:可读优先 为机器服务:小而快 为数据服务:演化优先 为工程服务:一站优先

10场景选型

点选你的场景,看推荐结论(也可以多个场景都看看,选型往往是几个约束的加权)。

🌐 对外 REST API

给第三方/前端调用的公开接口

⚙️ 内部微服务高频 RPC

QPS 高、服务间内网调用

📮 Kafka 消息流 / 事件管道

生产者消费者可能不同步升级

💾 数据湖 / 长期归档存储

几年后还要被新团队新引擎读

⚡ Redis 缓存对象

读写频繁、追求延迟低

🏢 多语言遗留系统互联

C++ / Java / Python / Go 混合老系统

选型决策树 需要人直接可读 / 浏览器消费? 是 → JSON REST / 配置 / 日志 / 调试 否(追求性能)→ 二进制 消息队列 / 数据湖 / 归档? 是 → Avro(+ Schema Registry) 演化自由、自描述文件、可切分 需要 RPC 服务调用? 是 → Protobuf + gRPC 新项目默认答案;需要全套内置栈/老系统 → Thrift 只要缓存序列化? 小对象可 JSON;高频用 pb Java 圈也可用 Kryo/FST 现实中的常见组合 对外 JSON(REST)+ 内部 Protobuf(gRPC)+ 消息管道 Avro(Schema Registry)——三种格式各守一段,互不替代
图 8 · 一棵决策树收尾:先问可读性,再问场景,最后看生态

11实战坑点

选型之外,真正让线上出事的往往是这些细节:

🔥 Protobuf:改字段编号 = 数据错乱 编号是唯一标识,删掉字段 5 又新建字段 5,老数据会被解析进新字段。务必用 reserved 占住已删编号。
🔥 JSON:大整数精度丢失 JS 的 Number 只有 53 位安全整数,订单号/雪花 ID(64 位)经 JSON 传到浏览器会丢精度。约定「超长整数传字符串」。
⚠️ Avro:Schema 没管好 = 解析失败 双 Schema 机制的前提是「能拿到写入时的 Schema」。Kafka 场景必须配 Schema Registry 并制定兼容性策略(BACKWARD 等),否则消费者直接崩。
⚠️ Thrift:两端协议/传输配错 = 玄学超时 服务端 TCompactProtocol + 客户端 TBinaryProtocol,连得上但解不出数据。客户端构造时必须与服务端 Protocol/Transport 严格一致。
💡 反序列化是攻击面 历史上 Java 原生反序列化、Python pickle 都出过 RCE 漏洞。永远不要反序列化「不可信来源」的数据;跨信任边界优先选无执行能力的格式(本文四种都远好于 pickle/Java 原生)。
💡 别忘了压缩可以叠加 文本格式的劣势可部分被 gzip/snappy 找回(HTTP JSON + gzip 很常见)。但压缩消耗 CPU 且对小消息效果有限,二进制格式 + 压缩仍然更快更小。

12总结

🧠

序列化的本质

把带指针的内存结构翻译成自包含的一维字节流。一切格式都在回答:字段怎么标识、类型怎么表达、边界怎么划分、Schema 放哪里。

⚖️

差别的根源是取舍

可读 vs 体积、自描述 vs 预编译、纯格式 vs 全家桶。没有最好的格式,只有匹配场景的格式。

JSON

为人而生,可读优先,REST 的事实标准。

Protobuf

为机器而生,编号 + varint,小而快,配 gRPC。

Avro

为数据而生,双 Schema 按名解析,演化自由,消息/数据湖标配。

Thrift

为工程而生,IDL 到 RPC 的一站式全家桶。

🎯 一句话带走 对外用 JSON 让人看懂,内部 RPC 用 Protobuf 让机器省电,消息管道和存储用 Avro 让数据活得久,Thrift 留给需要整套框架的地方——它们不是竞争关系,而是分别站上了「人、机器、数据、工程」四个不同的出发点。