一个承载每秒百万级请求的微服务系统,工程师发现某个JSON端点静悄悄地成为了CPU的头号消费者——没有任何错误日志,没有goroutine泄漏,服务看起来运行正常。但火焰图不会说谎:30%到40%的CPU时间消耗在JSON序列化上。这是Go语言社区2024年一份真实的生产环境报告。

这不是个例。JSON确实是人类友好的格式,但它的"友好"是有代价的。而这个代价,在特定场景下可能比你想象的更大。理解各种序列化格式的本质差异,比记住"哪个更快"这个结论重要得多。

JSON的意外崛起

2001年4月,硅谷的一个车库里,Douglas Crockford和Chip Morningstar正在测试一个想法。他们想构建一种在页面加载后还能继续获取数据的应用——这在今天听起来稀松平常,但在那个Internet Explorer 5刚刚支持XMLHttpRequest的年代,跨浏览器数据获取是一场噩梦。

第一条JSON消息看起来是这样的:

<html><head><script>
    document.domain = 'fudco';
    parent.session.receive(
        { to: "session", do: "test",
          text: "Hello world" }
    )
</script></head></html>

这不是什么新的数据格式——它就是JavaScript。Crockford后来承认,早在1996年,Netscape就有人用JavaScript数组字面量来传递数据。JSON的"发明"其实是一次发现:JavaScript对象字面量本身就是一种完美的数据交换格式。

但Crockford做了一件关键的事。2002年,他注册了JSON.org域名,放上语法规范和示例解析器。更重要的是,他规定所有JSON键必须加引号——这个决定源于一个实际问题:他和Morningstar在第一条消息中使用了do作为键名,而do是JavaScript保留字。引号让它成为字符串,绕过了这个问题。这个"修复"成为JSON规范的核心特征。

2005年,Jesse James Garrett创造了"AJAX"这个词。讽刺的是,AJAX中的"X"代表XML,但Garrett在同一篇文章中明确指出JSON是XML的可行替代品。历史开了个玩笑:AJAX的流行反而推动了JSON的崛起。

到2014年,JSON已经同时被ECMA和RFC标准化。Twitter在2013年完全放弃XML支持。Stack Overflow上关于JSON的问题数量超过了任何其他数据交换格式。

为什么JSON"慢"

2024年的一份Go语言基准测试揭示了一个完整的性能画像。测试使用一个约500字节的订单对象,包含字符串、数值、布尔值、时间戳和元数据映射。

格式 编码速度(ns/op) 解码速度(ns/op) 数据体积(字节)
JSON ~42,000 ~68,000 ~500
MessagePack ~12,000 ~19,000 ~295
Protobuf ~6,500 ~9,000 ~190

JSON编码比Protobuf慢6.5倍,解码慢7.5倍,数据体积大2.6倍。这不是微优化——这是数量级的差距。

差距的根源在于设计哲学。JSON是人类可读的文本格式,这意味着:

反射开销。Go的encoding/json使用反射在运行时发现结构体字段。反射本身是动态的,比静态代码生成慢得多。

文本解析。每个数字都要从字符串解析,每个布尔值都是文本比较,每个字段名都在数据中重复出现。

内存分配。JSON编码过程产生大量临时对象,增加垃圾回收压力。火焰图显示,JSON处理中有相当比例的时间花在内存分配上。

这不是JSON的"缺陷"——这是它设计目标的必然结果。JSON追求的是简单和可读性,不是性能。

Protobuf的编码哲学

Google在2008年开源Protocol Buffers时,带来的不仅是一种二进制格式,更是一整套数据建模方法论。

Protobuf的核心是varint编码。一个无符号64位整数理论上需要8字节,但varint让它能用1到10字节表示,小数值用更少字节。每个字节的最高位是"继续位"——如果为1,说明后面还有数据;如果为0,这是最后一个字节。

数字150的编码过程:

原始值:10010110 (150)
分割为7位组:0000001 0010110
小端序排列:0010110 0000001
添加继续位:10010110 00000001
结果:0x96 0x01

两个字节,而不是传统int32的四个字节。

负数是另一回事。标准varint对负数效率极差——-1在64位补码表示下需要10个字节。Protobuf引入ZigZag编码解决这个问题:

0 → 0
-1 → 1
1 → 2
-2 → 3
2 → 4

公式是(n << 1) ^ (n >> 31)。正数变成偶数,负数变成奇数,数值的绝对值越小,编码后的值也越小。-1现在只需要1个字节。

Protobuf消息结构是**Tag-Length-Value (TLV)**模式。每个字段以一个tag开始,包含字段编号和wire type。wire type告诉解析器如何读取后续数据:

Wire Type 含义 适用类型
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64位 fixed64, sfixed64, double
2 长度前缀 string, bytes, 嵌套消息, packed repeated
5 32位 fixed32, sfixed32, float

这种设计带来一个关键优势:未知字段可以安全跳过。解析器即使遇到它不认识的字段编号,也能根据wire type知道要跳过多少字节。这是schema演进的基石。

sequenceDiagram
    participant Sender as 发送方(v2)
    participant Receiver as 接收方(v1)
    Sender->>Receiver: [tag:1, value] [tag:2, value] [tag:3, value]
    Note over Receiver: tag:1 已知,解析<br/>tag:2 已知,解析<br/>tag:3 未知,根据wire type跳过
    Receiver-->>Sender: 成功处理

Schema演进:向前兼容与向后兼容

Martin Kleppmann在2012年的经典文章中详细对比了三种主流格式的schema演进策略。

Protobuf使用字段编号。字段名不进入二进制数据,只有编号和类型。这意味着:

  • 可以重命名字段,因为名字不在数据中
  • 不能改变字段编号,因为旧数据使用的是旧编号
  • 可以添加新字段,旧解析器会跳过它们
  • 可以删除字段,但编号永远不能重用

Avro使用字段名匹配。这是完全不同的设计哲学:

  • 字段没有编号,只有名字
  • 可以重排字段顺序,解析器按名字匹配
  • 改名字很麻烦,需要别名过渡
  • 添加字段必须提供默认值

Avro的设计让它更适合大数据场景。Hadoop生态系统中,Avro是首选格式,因为:

  1. 数据文件自带writer schema,完全自描述
  2. 没有字段编号,schema更清晰
  3. 支持丰富的元数据

但Protobuf在RPC场景更流行。字段编号机制让消息体积更小,编码更高效。

Confluent Schema Registry的兼容性规则总结了最佳实践:

兼容类型 允许的变更 适用场景
BACKWARD 删除可选字段,添加可选字段(有默认值) 消费者先升级
FORWARD 添加可选字段,删除可选字段 生产者先升级
FULL 添加可选字段(有默认值),删除可选字段 任意升级顺序

零拷贝的极限

2014年,Google发布了FlatBuffers,Kenton Varda(Protobuf原作者)发布了Cap’n Proto。两者都追求同一个目标:零拷贝序列化

FlatBuffers的官方基准测试展示了零拷贝的威力。测试对象是包含约10个对象、数组、4个字符串和各种数值类型的游戏场景数据。

格式 解码+遍历+释放(秒) 编码(秒) 数据体积(字节)
FlatBuffers 0.08 3.2 344
Protobuf LITE 302 185 228
Rapid JSON 583 650 1475

FlatBuffers比Protobuf快3700多倍。这不是魔法——这是根本性的架构差异。

传统序列化格式的数据流:

磁盘/网络 → 字节缓冲区 → 解析 → 内存对象 → 应用访问

零拷贝格式的数据流:

磁盘/网络 → 内存映射 → 应用直接访问

FlatBuffers预先计算好所有偏移量,数据在内存中的布局和在磁盘/网络上的布局完全一致。读取一个FlatBuffer文件,只需要mmap(),然后直接访问。没有解析,没有解码,没有对象构造。

代价是复杂性。FlatBuffers要求:

  • 构建消息必须自底向上(先创建子对象,再创建父对象)
  • 字段按固定偏移量存储,未设置字段也要占位
  • 指针(相对偏移量)也占用空间

Cap’n Proto的创建者Kenton Varda总结了一张特征对比表:

特性 Protobuf Cap’n Proto FlatBuffers SBE
零拷贝
随机访问
安全性(恶意输入) 可选验证
未知字段保留 proto3移除
填充占用空间 可选压缩

SBE(Simple Binary Encoding)由Real Logic开发,专注于金融交易场景。它的限制最多——不支持随机访问,必须按顺序遍历——但这种限制换来了最高的确定性性能。

选择的艺术:没有万能格式

序列化格式的选择是典型的权衡问题。2022年发表在arXiv上的一篇论文对13种JSON兼容的二进制格式进行了全面基准测试,使用SchemaStore的400多个真实JSON文档。

核心发现:没有哪个格式在所有维度上都最优

按场景选择

公开API和Web前端:JSON

这是JSON的主场。人类可读、浏览器原生支持、调试方便。REST API公开端点几乎都是JSON。性能损失相对于开发效率提升是值得的。

内部微服务通信:Protobuf

服务间调用不需要人类可读,性能和类型安全更重要。Protobuf的代码生成提供编译时类型检查,schema registry确保演进安全。gRPC基于Protobuf,提供完整的RPC框架。

事件流和大数据:Avro

Kafka生态系统的首选。Avro的schema演进机制最完善,数据文件自包含schema,Pig/Spark等工具可以直接加载无需配置。Confluent Schema Registry原生支持Avro。

游戏和实时系统:FlatBuffers

游戏需要加载资源时零延迟,FlatBuffers的直接访问特性完美匹配。Unity游戏引擎推荐FlatBuffers用于场景数据。

资源受限环境:MessagePack

需要JSON的灵活性但想要更好的性能?MessagePack是最简单的升级路径。它和JSON数据模型相同,但二进制编码让它体积小40%、速度快3.5倍。不需要.proto文件,不需要代码生成。

金融交易:SBE

确定性延迟比吞吐量更重要。SBE的严格顺序遍历消除了分支预测的不确定性。

按数据特征选择

数据特征同样影响格式选择:

大量小整数:Protobuf的varint编码最优。数字1到127只需要1字节。

大量负数:确保使用sint32/sint64,ZigZag编码让小负数也高效。

大量字符串:MessagePack或Avro可能更好。它们对字符串处理更直接。

嵌套很深:考虑扁平化数据结构。任何格式的深度嵌套都会增加解析开销。

频繁演进:Avro的schema演进最灵活,字段按名字匹配。

序列化的隐藏成本

性能不是唯一考量。几个容易被忽视的因素:

调试难度。JSON可以直接打印查看,Protobuf需要专门的解码工具。生产环境出问题时,可读性格式能节省大量调试时间。

版本管理。Protobuf要求管理.proto文件,确保所有服务使用兼容版本。JSON schema可选,但缺乏强制意味着更容易出现不一致。

学习曲线。团队成员可能都熟悉JSON,但Protobuf/Avro需要学习新概念。

工具生态。JSON工具无处不在,从jq命令行工具到浏览器扩展。二进制格式需要专门的工具链。

调试代理。HTTP代理、API网关可以检查JSON内容,实现限流、缓存、日志等功能。二进制格式让这些中间件无法理解数据内容。

回到最初的问题

那个消耗30% CPU的JSON端点,工程师最终做了什么?他们将内部服务间通信迁移到Protobuf,保留公开API的JSON格式。CPU使用率下降了25%,同时获得了类型安全和schema演进能力。

JSON没有"错",只是被用在了错误的场景。它最适合的,是那些人类需要直接交互的边界——公开API、配置文件、调试日志。

而机器与机器之间的对话,二进制格式用更少的字节、更少的CPU周期,说着更高效的语言。

选择序列化格式,本质上是在回答一个问题:这份数据的读者是谁?

如果是人,JSON的"冗余"是特性,不是bug。如果是机器,二进制格式让每次对话都更高效。

这不是一个关于"哪种格式更好"的问题,而是关于"在什么场景下哪种格式更合适"的问题。理解每种格式的权衡,才能在需要做出选择时,知道自己在交换什么。


参考资料

  1. Viotti, P., & Kinderkhedia, M. (2022). A Benchmark of JSON-compatible Binary Serialization Specifications. arXiv preprint arXiv:2201.03051.

  2. Protocol Buffers Official Documentation - Encoding. https://protobuf.dev/programming-guides/encoding/

  3. Kleppmann, M. (2012). Schema evolution in Avro, Protocol Buffers and Thrift. https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html

  4. Crockford, D. The Rise and Rise of JSON. Two-Bit History. https://twobithistory.org/2017/09/21/the-rise-and-rise-of-json.html

  5. FlatBuffers Official Benchmarks. https://flatbuffers.dev/benchmarks/

  6. Cap’n Proto News - Cap’n Proto, FlatBuffers, and SBE. https://capnproto.org/news/2014-06-17-capnproto-flatbuffers-sbe.html

  7. Confluent Schema Registry Documentation - Schema Evolution. https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html

  8. RFC 8949 - Concise Binary Object Representation (CBOR). https://www.rfc-editor.org/rfc/rfc8949.html

  9. RFC 8259 - The JavaScript Object Notation (JSON) Data Interchange Format. https://datatracker.ietf.org/doc/rfc8259/

  10. Wikipedia - Comparison of data-serialization formats. https://en.wikipedia.org/wiki/Comparison_of_data-serialization_formats