数据契约的 Schema 设计:Avro、Protobuf、JSON Schema 对比

选 Schema 格式,说白了就是选一种「双方都认的方言」。我在好几个项目里都见过这种场景:后端同学拍脑袋定了 Protobuf,前端同学一看直接懵了——浏览器里没法直接用。或者团队选了 JSON Schema,结果数据量一上来,序列化性能扛不住。

所以这一章,咱们把 Avro、Protobuf、JSON Schema 这三兄弟拉出来遛遛。不搞虚的,直接看它们各自擅长什么、短板在哪、什么时候该选谁。

三者的核心差异

先给个总览。我习惯用一张表来快速对齐认知,你想想看,面试或者方案评审时,这张表能帮你省不少口水。

特性 Avro Protobuf JSON Schema
序列化格式 二进制(紧凑) 二进制(紧凑) 文本(JSON)
Schema 定义语言 JSON / IDL .proto 文件 JSON
Schema 演化 强(读写分离) 中(需谨慎) 弱(需手动兼容)
跨语言支持 好(Java 生态强) 极好(官方多语言) 一般(依赖工具链)
自描述能力 强(Schema 嵌入数据文件) 弱(需外部 Schema) 强(JSON 本身可读)
典型场景 大数据管道、Kafka 微服务 RPC、gRPC API 文档、配置校验

一句话总结:Avro 是「数据湖的守门员」,Protobuf 是「微服务的快递员」,JSON Schema 是「API 的说明书」。

Avro:为大数据而生

Avro 是 Hadoop 生态的亲儿子。我最早接触它是在做实时数仓的时候,Kafka 里的消息全用 Avro 序列化。为什么?因为它有一个杀手级特性——Schema 演化。

什么叫演化?举个例子:你上游的订单系统突然加了一个 discount 字段,下游的消费者不需要改代码,只要 Schema Registry 里注册了新版本,Avro 自动帮你做字段映射。这在数据管道里太重要了,你想想看,几百个消费者同时升级?不现实。

Avro 的 Schema 长什么样

{
  "type": "record",
  "name": "Order",
  "namespace": "com.example",
  "fields": [
    {"name": "orderId", "type": "string"},
    {"name": "userId", "type": "string"},
    {"name": "amount", "type": "double"},
    {"name": "items", "type": {"type": "array", "items": "string"}},
    {"name": "discount", "type": ["null", "double"], "default": null}
  ]
}

注意看 discount 字段。我用了 ["null", "double"] 加 default: null,这就是 Avro 演化的核心——读 Schema 和写 Schema 可以不同。只要字段有默认值,老消费者读到新数据也不会崩。

我的经验:在 Kafka 里用 Avro,一定要配合 Schema Registry。我曾经见过一个团队直接把 Avro Schema 写死在代码里,结果上游改了字段类型,下游反序列化直接抛异常。Schema Registry 就是你的「契约中心」,所有变更都得过它。

Protobuf:微服务界的通用语

Protobuf 是 Google 家的,现在几乎是 gRPC 的标配。我个人习惯在微服务间通信时首选 Protobuf,原因很简单——性能好、约束强。

Protobuf 的 Schema 定义在 .proto 文件里,字段有编号,序列化时用编号代替字段名,所以体积小、速度快。但它的演化能力比 Avro 弱一些,尤其是删除字段时,必须用 reserved 关键字,否则新老版本可能对不上。

Protobuf 的 Schema 长什么样

syntax = "proto3";

package com.example;

message Order {
  string order_id = 1;
  string user_id = 2;
  double amount = 3;
  repeated string items = 4;
  optional double discount = 5;  // proto3 中 optional 需要显式声明
}

这里有个坑。Protobuf 3 默认所有字段都是 optional,但如果你不显式声明 optional,它其实用的是「默认值」语义——比如 int32 默认是 0,string 默认是空字符串。这就导致一个问题:你没法区分「用户没传」和「用户传了 0」。

我曾经踩过的坑:有一次做支付服务,金额字段用 Protobuf 的 int64,默认值是 0。结果上游没传金额,下游收到 0 直接当「免费订单」处理了。后来我们统一改用 google.protobuf.wrapper 类型,或者显式声明 optional,才把这个问题堵住。

JSON Schema:人类可读的契约

JSON Schema 跟前面两个不一样——它不是序列化格式,而是描述 JSON 数据结构的规范。说白了,它是一份「说明书」,告诉调用方:你的请求体应该长什么样,哪些字段必填,值的范围是多少。

我一般在以下场景用 JSON Schema:

  • API 文档:配合 OpenAPI(Swagger)生成接口文档,前端直接看 Schema 就知道传什么。
  • 配置校验:微服务的配置文件用 JSON Schema 校验,避免上线后因为少个字段炸了。
  • 数据交换:跨组织、跨语言的数据交换,JSON 是通用语言,Schema 是约束。

JSON Schema 长什么样

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["orderId", "userId", "amount"],
  "properties": {
    "orderId": {"type": "string"},
    "userId": {"type": "string"},
    "amount": {"type": "number", "minimum": 0},
    "items": {
      "type": "array",
      "items": {"type": "string"}
    },
    "discount": {"type": "number", "minimum": 0}
  }
}

JSON Schema 的优点是可读性强,你不需要任何工具,肉眼就能看懂。但它的缺点也很明显——没有序列化能力。你没法用 JSON Schema 直接把数据压缩成二进制,所以不适合高吞吐场景。

我的建议:如果数据量不大(比如 API 请求/响应),JSON Schema 完全够用。但如果你的数据要经过 Kafka 或者存到 HDFS,还是老老实实用 Avro 或 Protobuf。

如何选择?一张决策流程图

下面这张图是我自己总结的决策流程,每次做技术选型时我都会过一遍。你直接拿去用就行。

Schema 格式选型决策流程 数据需要序列化? 是 → 需要 Schema 演化? Avro(大数据管道) Protobuf(微服务 RPC) 否 → 需要人类可读? JSON Schema(API/配置) 自定义二进制协议 💡 提示:如果数据需要跨组织交换,优先考虑 JSON Schema;如果数据量巨大,优先考虑 Avro; 如果微服务间通信频繁,优先考虑 Protobuf。

实战中的选择建议

说了这么多,到底怎么选?我直接给结论:

  1. 数据管道场景(Kafka、HDFS、Flink):无脑选 Avro。Schema 演化能力太强了,而且配合 Schema Registry,上下游解耦得非常干净。
  2. 微服务 RPC 场景(gRPC、内部 API):选 Protobuf。性能好、约束强,而且 gRPC 生态已经非常成熟。
  3. 外部 API / 配置校验场景:选 JSON Schema。可读性强,前端、后端、测试都能看懂,不需要额外工具。
  4. 混合场景:我见过不少团队在 Kafka 里用 Avro,在 gRPC 里用 Protobuf,在 API 文档里用 JSON Schema。三者不冲突,可以共存。

注意:不要试图用一个 Schema 格式打天下。我见过一个团队强行用 Protobuf 做数据管道,结果每次字段变更都要改 .proto 文件、重新编译、重新部署,折腾得够呛。选型要因地制宜。

小结

这一章我们聊了 Avro、Protobuf、JSON Schema 的核心差异和选型思路。记住一句话:没有最好的 Schema 格式,只有最合适的场景。你只要搞清楚自己的数据流是「管道型」还是「请求-响应型」,答案就出来了。