跳转到正文
zeno's blog
返回

Go 工具链:Protobuf 的字段编号、Varint 与二进制序列化

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 字节
问题JSONProtobuf
体积字段名作为字符串重复传输,引号/逗号/大括号开销字段编号 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用于
VARINT0int32, int64, uint32, uint64, sint32, sint64, bool, enum
I641fixed64, sfixed64, double
LEN2string, bytes, 嵌套 message, packed repeated
I325fixed32, 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 字节数
001
-111
121
-231
214748364742949672945
-214748364842949672955

具体对比,编码 -2:

规则:值可能为负 → 用 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 不序列化零值字段:

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 包
Timestampgoogle/protobuf/timestamp.protoUTC 时间(纳秒精度)timestamppb
Durationgoogle/protobuf/duration.proto时间跨度durationpb
FieldMaskgoogle/protobuf/field_mask.proto指定要读/写的字段子集fieldmaskpb
Anygoogle/protobuf/any.proto包装任意 message(附 type URL)anypb
Structgoogle/protobuf/struct.protoJSON 风格动态 key-valuestructpb
Emptygoogle/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 兼容的类型变更(可以但要小心)

序列化格式对比

维度ProtobufJSONMessagePackFlatBuffers
体积很小(无字段名,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,「需验证」——具体数值因硬件和数据结构而异,量级关系稳定)

选型决策:

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 生成:

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 设计

Schema 演化

Go 代码


分享这篇文章:

上一篇
C++ 工程化(一):Modern CMake 的核心是 target-based 与传递性语义
下一篇
Go 工具链:Buf 如何替代 protoc 工作流