Table of contents
Open Table of contents
TL;DR
Protobuf 用字段编号(而非字段名)标识数据、用 Varint 压缩整数、省略零值字段,实现了比 JSON 小 2-5 倍、快 5-10 倍的序列化性能,同时通过字段编号机制天然支持 schema 前向/后向兼容演化。
解决什么问题
JSON 在服务间通信中的三个代价:
{ "weight": 150, "name": "Apple" } // 29 字节
08 96 01 12 05 41 70 70 6C 65 // Protobuf: 10 字节
| 问题 | JSON | Protobuf |
|---|---|---|
| 体积 | 字段名作为字符串重复传输,引号/逗号/大括号开销 | 字段编号 1 字节,无冗余符号 |
| 速度 | 文本解析(~42,000 ns/op Go) | 二进制直接映射(~6,500 ns/op Go) |
| 契约 | 无强制 schema,生产者和消费者可以无声地不一致 | .proto 文件是编译期检查的契约 |
| 演化 | 增删字段靠人工协调 | 字段编号机制让增删字段机械化兼容 |
不适合用 Protobuf 的场景: 浏览器直接消费的 API(JSON 原生支持)、配置文件/日志(需要人读)、纯动态 schema 数据、超低延迟零拷贝场景(FlatBuffers/Cap’n Proto 更合适)。
Wire Format:字节级编码原理
序列化后的 Protobuf 消息 = 一系列 record,每个 record = tag + payload。
Tag 编码
Tag = (field_number << 3) | wire_type,自身是一个 Varint。
| Wire Type | 值 | 用于 |
|---|---|---|
| VARINT | 0 | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| I64 | 1 | fixed64, sfixed64, double |
| LEN | 2 | string, bytes, 嵌套 message, packed repeated |
| I32 | 5 | fixed32, sfixed32, float |
字段编号 1-15 的 tag 只占 1 字节,16-2047 占 2 字节。 因为 tag 本身是 Varint,字段 15 的 tag = (15 << 3) | 0 = 120(7 位,1 字节),字段 16 的 tag = (16 << 3) | 0 = 128(8 位,需要 2 字节 Varint)。把 1-15 留给最常用的字段和 repeated 字段。
Varint 编码算法
每个字节的最高位(MSB)是延续位:1 = 后面还有字节,0 = 最后一个字节。剩余 7 位携带数据,小端序拼接。
编码 300:
300 = 0b100101100(9 位)
拆成 7 位一组(从低位开始):
低 7 位: 0101100 = 44
高 2 位: 0000010 = 2
加延续位:
字节 1: 1_0101100 = 0xAC(MSB=1,后面还有)
字节 2: 0_0000010 = 0x02(MSB=0,结束)
结果:AC 02(2 字节)
编码 1: 01(1 字节,< 128 直接写)
编码 150: 96 01(2 字节)
编码 0: 00(1 字节)
ZigZag 编码:sint32/sint64 的关键
int32 用二进制补码表示负数。-1 的补码是 64 位全 1(为了与 int64 兼容),Varint 编码需要 10 字节。所有负 int32 都是 10 字节。
ZigZag 把有符号数映射到无符号数:(n << 1) ^ (n >> 31)
| 原始值 | ZigZag 编码值 | Varint 字节数 |
|---|---|---|
| 0 | 0 | 1 |
| -1 | 1 | 1 |
| 1 | 2 | 1 |
| -2 | 3 | 1 |
| 2147483647 | 4294967294 | 5 |
| -2147483648 | 4294967295 | 5 |
具体对比,编码 -2:
int32:FE FF FF FF FF FF FF FF FF 01(10 字节)sint32:03(1 字节,ZigZag(-2) = 3)
规则:值可能为负 → 用 sint32/sint64。值总是非负 → int32/int64 就行。
Length-Delimited 编码(字符串/字节/嵌套消息)
格式:tag | length_varint | raw_bytes
字段 2,值 "testing":
12 → tag (field=2, wire_type=2)
07 → length = 7
74 65 73 74 69 6E 67 → UTF-8 "testing"
嵌套消息也是 LEN 类型——先序列化内部消息,再用 length 前缀包裹。
Packed Repeated 编码
Proto3 对标量 repeated 字段默认使用 packed 编码:一个 tag + 一个 length + 连续的值。
repeated int32 ids = 1, 值 [3, 270, 86942]
Unpacked(每个元素一个 tag):
08 03 08 8E 02 08 9E A7 05 → 9 字节
Packed(一个 tag,值连续排列):
0A 06 03 8E 02 9E A7 05 → 8 字节
tag len 3 270 86942
元素越多,packed 节省越明显(省去了重复的 tag 开销)。
零值省略(Proto3)
Proto3 不序列化零值字段:
int32 age = 1值为 0 → 不写入(0 字节)string name = 2值为 "" → 不写入bool active = 3值为 false → 不写入repeated空列表 → 不写入
100 个字段只有 3 个非零,只为那 3 个字段付出字节。代价:无法区分「显式设为 0」和「未设置」。 需要区分时用 optional 关键字。
Proto3 核心语法
syntax = "proto3";
package myservice.v1;
import "google/protobuf/timestamp.proto";
message User {
string id = 1;
string name = 2;
optional string nickname = 3; // 显式 presence,Go 生成 *string
int32 age = 4; // 隐式 presence,无法区分 0 和未设置
repeated string tags = 5; // 有序列表
map<string, string> metadata = 6; // key 必须是标量类型(不能是 float/bytes/message)
oneof contact { // 至多设一个,设新值自动清除旧值
string email = 7;
string phone = 8;
}
Status status = 9;
google.protobuf.Timestamp created_at = 10;
}
enum Status {
STATUS_UNSPECIFIED = 0; // 零值必须是 UNSPECIFIED
STATUS_ACTIVE = 1;
STATUS_INACTIVE = 2;
}
Well-Known Types
| 类型 | 导入 | 用途 | Go 包 |
|---|---|---|---|
Timestamp | google/protobuf/timestamp.proto | UTC 时间(纳秒精度) | timestamppb |
Duration | google/protobuf/duration.proto | 时间跨度 | durationpb |
FieldMask | google/protobuf/field_mask.proto | 指定要读/写的字段子集 | fieldmaskpb |
Any | google/protobuf/any.proto | 包装任意 message(附 type URL) | anypb |
Struct | google/protobuf/struct.proto | JSON 风格动态 key-value | structpb |
Empty | google/protobuf/empty.proto | 无返回值的 RPC 占位 | emptypb |
BoolValue/Int32Value 等 wrapper types 已过时——在新代码中用 optional 替代。
Schema 演化规则
Protobuf 的核心设计承诺:旧代码能读新数据,新代码能读旧数据。这靠字段编号机制实现。
安全操作
| 操作 | 安全? | 说明 |
|---|---|---|
| 新增字段 | 是 | 旧 reader 忽略未知字段;新 reader 对缺失字段用默认值 |
| 停止写入某字段 | 是 | 标记 [deprecated = true],编号加入 reserved |
| 重命名字段 | 二进制安全 | wire format 用编号不用名字。但会破坏 JSON 序列化 |
| 新增 enum 值 | 是 | 未知 enum 值保留其整数值 |
破坏性操作
| 操作 | 后果 |
|---|---|
| 改字段编号 | 旧数据的字段被读到错误位置 |
| 改为不兼容类型(如 int32 → string) | 字节被按错误类型解释,数据损坏 |
| 复用已删除的字段编号 | 静默数据损坏(见下例) |
| 删除字段编号但不 reserve | 未来开发者可能复用该编号 |
字段编号复用导致的数据损坏
// V1
message User {
string name = 1;
string email = 2; // 后来删了
}
// V2(错误:复用了编号 2)
message User {
string name = 1;
int32 age = 2; // 危险!
}
V1 客户端写 email = "alice@example.com" → 编码为 field 2, LEN 类型,17 字节 UTF-8。V2 reader 读到 field 2,期望 int32(VARINT 类型)。Wire type 不匹配 → 解析器崩溃或产生垃圾值。
// 正确做法
message User {
reserved 2;
reserved "email"; // 同时保留编号和名字
string name = 1;
int32 age = 3; // 新编号
}
Wire 兼容的类型变更(可以但要小心)
int32↔uint32↔int64↔uint64↔bool(都是 VARINT,可能截断)string↔bytes(如果是合法 UTF-8)fixed32↔sfixed32;fixed64↔sfixed64sint32和int32不兼容! 不同的编码方式(ZigZag vs 原生补码)
序列化格式对比
| 维度 | Protobuf | JSON | MessagePack | FlatBuffers |
|---|---|---|---|---|
| 体积 | 很小(无字段名,Varint) | 大(字段名+引号+括号) | 较小(二进制 JSON,保留字段名) | 中等(对齐填充) |
| 编码速度 | 快 (~6,500 ns/op) | 慢 (~42,000 ns/op) | 中 (~12,000 ns/op) | 中 |
| 解码速度 | 快 (~9,000 ns/op) | 很慢 (~68,000 ns/op) | 中 (~19,000 ns/op) | 近零(零拷贝) |
| 人可读 | 否 | 是 | 否 | 否 |
| Schema | 必须(.proto) | 不需要 | 不需要 | 必须(.fbs) |
| 零拷贝 | 否 | 否 | 否 | 是 |
| Schema 演化 | 优秀(字段编号) | 手动 | 手动 | 良好 |
| 生态 | 极好(gRPC, buf) | 极好 | 一般 | 良好 |
(基准数据来源:Go 1.22, AMD Ryzen 7950X,「需验证」——具体数值因硬件和数据结构而异,量级关系稳定)
选型决策:
- 服务间通信 → Protobuf(配合 gRPC)
- 浏览器 API / 配置 / 日志 → JSON
- 想要二进制但不想 schema + codegen → MessagePack
- 纳秒级解码延迟(游戏/交易系统) → FlatBuffers
Go 实现:google.golang.org/protobuf
核心 API
import "google.golang.org/protobuf/proto"
// 序列化 / 反序列化
bytes, err := proto.Marshal(msg)
err := proto.Unmarshal(bytes, &msg)
// 深拷贝(不要浅拷贝 proto struct)
clone := proto.Clone(msg)
// 结构相等(处理 NaN、空 bytes、unknown fields)
equal := proto.Equal(msg1, msg2)
// 合并(标量覆盖,repeated 追加,map 合并)
proto.Merge(dst, src)
// wire 大小(不实际序列化)
size := proto.Size(msg)
protojson:Proto ↔ JSON
import "google.golang.org/protobuf/encoding/protojson"
// 默认:camelCase 字段名,enum 用字符串名
jsonBytes, err := protojson.Marshal(msg)
// 自定义选项
jsonBytes, err := protojson.MarshalOptions{
UseProtoNames: true, // snake_case 字段名
EmitUnpopulated: true, // 输出零值字段
}.Marshal(msg)
// 反序列化(同时接受 camelCase 和 snake_case)
err := protojson.Unmarshal(jsonBytes, &msg)
永远不要用 encoding/json 处理 proto message——它不理解 oneof、Any、well-known types、enum 名字。
Well-Known Type 辅助函数
import "google.golang.org/protobuf/types/known/timestamppb"
// Timestamp ↔ time.Time
ts := timestamppb.Now()
goTime := ts.AsTime()
ts = timestamppb.New(someTime)
// Duration ↔ time.Duration
dur := durationpb.New(5 * time.Second)
goDur := dur.AsDuration()
代码生成产物
protoc-gen-go 为每个 message 生成:
- Go struct(exported 字段,snake_case → CamelCase)
GetXxx()访问器(对 message 字段 nil-safe)ProtoReflect()反射方法
optional 标量 → 指针类型(*int32、*string),可用 nil 检测 presence。
oneof → interface 类型 + 具体实现 struct:
// switch msg.Contact.(type) 做类型分发
type User_Email struct { Email string }
type User_Phone struct { Phone string }
Service 定义不由 protoc-gen-go 生成,需要 protoc-gen-go-grpc 插件。
Pitfalls
1. int32 编码负数的 10 字节陷阱
int32 编码 -1 需要 10 字节(二进制补码扩展到 64 位)。如果字段值可能为负,必须用 sint32/sint64。 一个 repeated 字段 1000 个负值 → 浪费 8-9 KB。
2. 零值 vs 未设置不可区分
Proto3 隐式 presence 下,age = 0 和”没传 age”在 wire 上完全一样(都不序列化)。需要区分时用 optional 关键字。对布尔值,考虑用 enum(ENABLED/DISABLED/UNSPECIFIED)替代 bool。
3. JSON 往返的字段名不一致
Proto 字段 user_name 默认 JSON 输出为 userName。不同实现对输入端的接受程度不同。FieldMask 路径在 JSON 中用 camelCase、在 proto 中用 snake_case,如果 proto 字段名本身是 camelCase 会导致转换错误。
4. Map 字段迭代顺序不确定
map<string, int32> 的序列化顺序每次可能不同。不要用序列化结果做 cache key 或 checksum。 比较用 proto.Equal(),如果必须 hash 用 MarshalOptions{Deterministic: true}(但跨版本不保证稳定)。
5. 字段编号 19000-19999 是保留的
Protobuf 实现内部使用这个范围。定义字段时跳过。
6. Packed 和 Unpacked 的跨版本兼容
Proto3 默认 packed,proto2 默认 unpacked。合规解析器必须同时接受两种格式,但如果对接遗留的 proto2 客户端,需要显式测试。
7. 大消息无法增量解析
Protobuf 没有内部 framing——必须整体反序列化。消息 > 1 MB 会造成内存压力,硬限制 2 GiB。大数据集应该用 streaming 或分页,不要塞进一个 message。
生产 Checklist
Schema 设计
- enum 零值是
XXX_UNSPECIFIED - 字段编号 1-15 留给高频字段和 repeated 字段
- 可能为负的值用
sint32/sint64 - 每个 RPC 独立的 Request/Response message,不跨 RPC 复用
- 需要区分零值和未设置时用
optional - 单条消息 < 1 MB,大数据用 streaming
Schema 演化
- 删除字段时同时
reserved编号和名字 - 永远不改字段编号
- 永远不改字段为 wire 不兼容的类型
- 考虑异步部署:新旧代码共存时双方都能读对方的数据
Go 代码
- 用
protojson不用encoding/json - 用
proto.Equal()不用reflect.DeepEqual - 用
proto.Clone()不浅拷贝 - 用
buf替代裸protoc(lint + breaking change 检测 + 统一代码生成)