深蓝黑背景封面:左侧示意图——一张「内存中的对象」卡片(示例 User{id:1,name:"A"})向右分出三条箭头,分别连到三张编码结果卡片:上路 JSON 文本路线,预览 {"id":1,"name":"A"},标注「可读 · 体积大」;中路 Kryo 二进制路线,预览一串十六进制字节,标注「紧凑 · 快 · 仅 Java」;下路 Protobuf IDL 路线,预览「08 01 12 01 41」字段编码,标注「跨语言 · Schema 演进」;底部一行小字「同一个对象,三种编码,体积与能力完全不同」。右侧大标题「序列化协议 / 到底怎么选」,副标题「文本 / 二进制 / IDL 三条路线的取舍」。左上标注「知识延伸 · 序列化协议」,右上圆角胶囊标注「Day 06 · 11-13」 图 1:本文封面,同一个对象的三种编码形态。

同批的面试真题讲了 Java 原生序列化的机制与缺陷:能跑,但安全、兼容、体积三座大山让新系统不该再选它。那换掉它之后选什么?这篇把视角从「一个机制讲透」换成「一张坐标系做选型」:常见方案在体积、速度、跨语言、Schema 演进四个维度上各站在哪,不同业务链路该怎么落子。

先看清三条路线:文本、二进制、IDL。

所有序列化方案大体分三派。文本派以 JSON 为代表:人可读、自描述,字段名直接写在数据里。二进制派以 Kryo 为代表:把对象图编码成紧凑字节,类名、字段名尽量省掉或压缩成数字 ID。IDL 派以 Protobuf 为代表:先用 proto 文件定义 Schema、给每个字段分配编号,数据里只存编号和值,编解码靠生成的代码。三派的分歧本质上是同一个问题——元数据(这个字段叫什么、是什么类型)放在哪里:放在数据里、干脆省掉、还是放到两端的代码里。

编码本身差多少:同一个对象跑一遍。

用 Node.js 22.15.0 手工编码同一个 User{id:1, name:"A"} 做对比(node encoding-size.js):

const user = { id: 1, name: "A" };

// 1) JSON 文本
const json = Buffer.from(JSON.stringify(user), "utf8");
console.log(`JSON: ${JSON.stringify(user)} → ${json.length} 字节`);

// 2) Protobuf wire format(手工编码 message User { int32 id = 1; string name = 2; })
//    字段 1 varint: tag = (1<<3)|0 = 0x08, 值 1 → 0x01
//    字段 2 LEN:    tag = (2<<3)|2 = 0x12, 长度 1, "A" = 0x41
const proto = Buffer.from([0x08, 0x01, 0x12, 0x01, 0x41]);
console.log(`Protobuf: ${proto.toString("hex").toUpperCase().match(/../g).join(" ")} → ${proto.length} 字节`);

// 3) 手工 varint 编码,对照官方文档示例:150 → 96 01
function varint(n) {
  const out = [];
  do {
    let b = n % 128;
    n = Math.floor(n / 128);
    if (n > 0) b |= 0x80;
    out.push(b);
  } while (n > 0);
  return Buffer.from(out);
}
console.log(`varint(150) = ${varint(150).toString("hex").toUpperCase().match(/../g).join(" ")}`);
console.log(`varint(1) = ${varint(1).toString("hex")}(小整数只占 1 字节)`);

实测输出:

JSON: {"id":1,"name":"A"} → 19 字节
Protobuf: 08 01 12 01 41 → 5 字节
varint(150) = 96 01
varint(1) = 01(小整数只占 1 字节)

19 对 5,差距来自两处:JSON 把字段名和引号、括号都写进了数据;Protobuf 只存字段编号和值,整数还用 varint 变长编码——每个字节的最高位是「还有后续」的标记,小整数只占一个字节。官方编码文档里 150 编码为 96 01 的示例,我们的手工实现逐字节对上了。要说明的是,这是同一个小对象上的体积形态对比,不是严谨基准测试;方案之间真正的差距在大对象和高并发下会进一步拉开。

三个方案的画像。

JSON 的优势是可读性与生态:任何语言、任何抓包和调试工具都认识它,对外 API、配置文件、日志几乎默认选它。代价是体积大、解析慢,而且 Schema 弱——字段叫什么、该是什么类型全靠两端约定,接口悄悄改字段只能靠评审和测试兜底。

Kryo 是 Java 系的二进制方案,官方定位就是「高速、小体积」的对象图序列化框架:类注册后用数字 ID 代替类名,输出远比原生序列化紧凑,编解码也快得多,适合 Java 内部链路——RPC 调用、缓存里存对象。短板是跨语言基本没戏;反序列化同样有安全面,Kryo 从 5.x 起默认要求类注册,正是把「能还原出什么类」收口到白名单里。

Protobuf 为跨语言与长期演进而生:字段编号代替字段名,每个字段带 wire type,老版本解析器遇到不认识的新字段能按类型安全跳过——这就是「加字段不破坏旧客户端」的机制基础。多语言代码生成让前端、后端、跨团队共用同一份 proto 文件。代价是要维护 IDL 与代码生成流程,数据不可读,调试得靠工具解码。

深蓝黑背景。顶部小字「概念图 · 序列化选型」,大标题「四种方案 × 五个维度」。主体是一张五行六列的对比矩阵:列依次为方案、编码形态、体积与速度、跨语言、Schema 演进、适合场景。第一行 JSON:文本自描述、体积大解析慢、天然跨语言、Schema 弱靠约定,适合对外 API 与配置文件;第二行 Java 原生:二进制但自带类元数据、体积最大较慢、仅 Java、兼容性差(serialVersionUID 敏感),只适合遗留系统维护,新系统勿用;第三行 Kryo:二进制紧凑、体积小速度快、基本仅 Java、Schema 演进中等(可注册类 ID),适合 Java 内部 RPC 与缓存序列化;第四行 Protobuf:二进制 IDL 驱动、小体积高吞吐、跨语言强(多语言代码生成)、Schema 演进强(字段编号向后兼容),适合跨语言服务通信与持久化存储。矩阵下方注明体积速度为相对量级而非精确基准。底部三张选型决策卡:要人读、对外接口选 JSON;Java 内部链路追性能选 Kryo;跨语言、长期演进选 Protobuf 图 2:四种方案在五个维度上的选型矩阵。

选型只问三个问题。

第一,数据要给人看吗?要——配置文件、开放 API、需要肉眼排查的链路,选 JSON。第二,链路跨语言吗?纯 Java 内部且追性能,选 Kryo;跨语言服务通信,选 Protobuf。第三,数据活多久、演不演进?写一次读一次的 transient 消息,Schema 弱也能忍;要落库存上几年、字段必然增删的,Protobuf 的编号兼容机制值回票价。三个问题答完,矩阵里通常只剩一个格子。顺带一提,同批真题讲的 Java 原生序列化在这张坐标系里的位置是「仅 Java、兼容性差、体积大」,这就是它被挤出选型清单的原因。

你负责的系统里,对外接口和内部缓存分别用了哪种序列化方案?当时最看重的维度是哪一个?欢迎评论区聊聊。