数据格式与序列化:JSON / Protobuf / Thrift 选型与对比

聊到数据格式,很多团队一开始都直接用 JSON,毕竟上手快、浏览器直接支持。但做到企业级 SDK 对接时,你会发现 JSON 的坑其实不少——解析慢、Schema 靠文档、版本兼容全靠约定。我做过几个跨语言 SDK,从 JSON 切到 Protobuf 或者 Thrift,性能翻倍不说,接口稳定性也高了一个档次。当然,不是所有场景都要上二进制协议,我们需要具体问题具体分析。

核心观点:选型不是比谁更“先进”,而是看你的团队语言栈、跨平台需求、以及流量敏感度。

1. 三种数据格式概览

JSON、Protobuf、Thrift 代表了三种不同的序列化哲学。我简单画一张对照图,方便你直观理解它们的位置。

数据格式选型决策框架 JSON 文本、无Schema、通用 Protobuf 二进制、强Schema、Google系 Thrift 二进制、RPC集成、Apache系 选型关键因素 性能(编解码速度) JSON 慢,PB/Thrift 快 可读性 & 调试 JSON 优,PB/Thrift 差 版本兼容能力 JSON 弱,PB/Thrift 原生 语言生态 & 工具 看团队栈 最终选择:根据场景匹配最优

从这张图你能看到,JSON 独占了“可读性”的优势,二进制协议则在性能和版本兼容上领先。下面我们逐项细说。

2. JSON vs Protobuf vs Thrift 核心对比

我列一个详细的对比表,把日常最关心的几个维度摆出来。这样你在做技术评审时可以直接参考。

维度 JSON Protobuf Thrift
序列化方式 文本 / UTF-8 二进制变长编码 二进制 + 可配置协议
Schema 要求 无(靠文档约定) 必需 .proto 文件 必需 .thrift 文件
编解码性能 慢(尤其大嵌套) 极快 快
数据体积 大(冗余键名) 小(字段编号替代键名) 小
语言支持 几乎全部原生 主流语言 + 插件 主流语言 + 自定义传输
版本兼容 手动协商 强,optional / deprecated 强,required / optional
调试友好度 极高(肉眼可读) 中(需工具解码) 低

我个人的小经验:如果你的 SDK 主要暴露给前端或第三方开发者,JSON 仍然是首选——降低集成门槛比省几个字节更重要。但后端服务之间的 RPC 调用,我几乎不用 JSON,直接上 Protobuf。

3. 字段命名风格:snake_case vs camelCase

这个看起来小,实际踩坑的人不少。我见过一个团队,后端 Java 用 camelCase,前端 JS 也用 camelCase,本来没问题。但后来接入了 Python 微服务,Python 社区习惯 snake_case,结果每次调用都要做字段映射转换,维护成本陡增。你说何必呢?

我的建议很简单:统一选 snake_case。

  • 理由 1:大部分后端语言(Go, Rust, Python, C++)原生偏好 snake_case,camelCase 反而需要额外转换。
  • 理由 2:Protobuf 和 Thrift 的官方示例几乎全是 snake_case,跟它们保持一致更容易理解。
  • 理由 3:数据库字段也常用 snake_case,数据层到传输层可以少一次映射。

我曾经踩过的坑:有一个项目用了 camelCase 命名 protobuf 字段,结果在 C++ 端生成的 getter/setter 方法名变得很别扭,比如 getUserID() 而不是 get_user_id()。后来被迫在 .proto 里加 json_name 注解做映射,维护非常痛苦。

举个例子,定义用户信息时两种风格的差异:

// —— JSON 原始格式(camelCase) ——
{
  "userId": 1001,
  "userName": "Alice",
  "lastLoginTime": "2025-01-20T10:00:00Z"
}

// —— 推荐 protobuf(snake_case) ——
protobuf:
message UserInfo {
  int64  user_id        = 1;
  string user_name      = 2;
  int64  last_login_ts  = 3;  // 存时间戳,不用字符串
}

如果你必须向客户端暴露 camelCase(比如前端框架习惯),可以在 Protobuf 中利用 json_name 字段选项做映射,但 .proto 内部依然坚持 snake_case。这样两边都舒服。

4. 版本兼容字段处理

SDK 对接最怕的就是“我升级了接口,旧客户端爆炸了”。版本兼容是数据格式选型的核心痛点,没有之一。

先说说 JSON 怎么做兼容。说白了,全靠手动约定。比如新增字段时,旧版本忽略未知字段;删除字段时保留字段名但标记 deprecated。我在一个项目里用过 JSON Schema 做校验,但客户端不强制遵守,效果大打折扣。

Protobuf 和 Thrift 在这块就优雅得多。它们通过字段编号(field number)来标记字段,而不是字段名。所以:

  • 新增字段:分配一个新的编号,服务端和客户端各自生成新代码,旧代码在反序列化时自动忽略未知的编号。
  • 删除字段:保留编号(不再使用),或者用 reserved 关键字声明已废弃,防止未来冲突。
  • 修改字段类型:千万别改已有编号的类型。我见过有人把 int32 改成 string,结果旧客户端解析出乱码。真要改就新增字段。

下面这段 protobuf 定义展示了兼容的做法:

syntax = "proto3";

message User {
  int64  id          = 1;   // 基础字段,永不删除
  string name        = 2;   // 基础字段
  reserved 3, 4;            // 曾用过 image_url 和 age,已废弃
  reserved "mobile";        // 字段名也保留,防止未来误用

  optional string avatar    = 5;  // 新增字段,旧客户端忽略
  optional int32  level     = 6;  // v2.0 新加
}

关键规则:每个字段编号在消息内必须是唯一的,且一旦发布就不可更改。Protobuf 中的 optional 关键字让你明确区分“字段不存在”和“字段值为默认值”,对版本兼容极其重要。

Thrift 的兼容做法类似,通过 required / optional 控制字段存在性。不过 Thrift 的 required 字段如果在客户端没有收到,会直接抛异常。我建议:新加的字段一律用 optional,等所有客户端都升级后再考虑改成 required。

我曾经在一个跨版本发布中,把一个 Thrift 的 optional 字段改成了 required,结果部分旧客户端没有发这个字段,服务端反序列化直接挂掉,线上报警半小时。从那以后我再也不会轻易动 required。

5. 实战选型建议

说了这么多,最后给个简单粗暴的建议表:

场景 推荐格式 理由
公开 REST API / Webhook JSON 客户端多样性,调试方便
内部微服务 RPC Protobuf (gRPC) 高性能,强Schema,流式支持
异构语言间高性能批量传输 Protobuf 或 Thrift 体积小,解析快
已有 Thrift 基础设施的团队 Thrift 复用代码生成与传输框架
纯前端/移动端 SDK JSON + 可选 gzip 浏览器/客户端原生支持

其实没有银弹。我在一个项目中同时用了 JSON(对外)和 Protobuf(对内),中间层做一次转换,两边都爽到。你想想看,如果一开始就定好规范,后面少踩多少坑?

最后一点:不管你选哪种格式,字段编号一旦分配就永远不要重用。这是版本兼容的最高准则,比命名风格和哪个协议更酷都重要。