42.2 序列化与 IDL
一句话
IDL 的价值不是“生成代码”,而是把字段编号变成契约:二进制序列化的体积与兼容性由“编号 + 编码方式”决定,字段名只给人看——所以改字段名通常安全,改字段编号一定破坏。
IDL 解决的三件事
| 能力 | 没有 IDL | 有 IDL |
|---|---|---|
| 跨语言契约 | 各语言各自猜 JSON 字段 | 一份 .proto/.thrift 生成所有语言的类型 |
| 向后兼容 | 靠文档与口头约定 | 字段编号 + reserved 把规则写进编译期 |
| 演进成本 | 改字段 = 全链路联调 | 加字段(带默认值)通常零改动 |
序列化本身的“52 字节 vs 17 字节”这笔账在 42.1 RPC 原理与 Stub 里已经算过;本节往下钻一层:为什么二进制能这么小,以及哪些改动会让兼容性瞬间断裂。
六种格式,六个取舍
| 格式 | 自描述 | schema | 典型体积 | 演进能力 | 适合 |
|---|---|---|---|---|---|
| JSON | 是(字段名在内) | 无/弱(JSON Schema 可选) | 大(元数据占一半) | 加字段安全,改名即破坏 | 对外 API、调试 |
| Protobuf | 否 | .proto | 小(tag + 变长整数) | 强(编号 + reserved) | 内网 RPC、事件 |
| Thrift Binary | 否 | .thrift | 小 | 中(字段 ID) | 已有 Thrift 生态 |
| Avro | 否 | schema 随数据走 | 小 | 强(读写 schema 解析) | 大数据/批处理 |
| MessagePack | 是(类型标记) | 无 | 中(比 JSON 小) | 弱(同 JSON) | 替代 JSON 的轻量场景 |
| FlatBuffers | 否 | .fbs | 小 | 中 | 零拷贝读取(游戏/移动端) |
🔑 选型的真正问题是:契约要不要跨团队/跨语言长期演进。要 → schema 驱动(Protobuf/Thrift/Avro);只是“把 JSON 压小一点” → MessagePack;需要“读到即用不解析” → FlatBuffers。
代码:varint、zigzag 与字段编号
package main
import "fmt"
// varint:每字节 7 位有效位,最高位表示“还有后续字节”
func varint(v uint64) []byte {
var out []byte
for v >= 0x80 {
out = append(out, byte(v)|0x80)
v >>= 7
}
return append(out, byte(v))
}
// zigzag:把有符号数映射成无符号,让绝对值小的负数也短
// -1 -> 1, 1 -> 2, -2 -> 3 ...
func zigzag32(v int32) uint64 { return uint64(uint32(v<<1) ^ uint32(v>>31)) }
// key = (field_number << 3) | wire_type
func key(field, wire int) []byte { return varint(uint64(field)<<3 | uint64(wire)) }
// 普通 int32:负数会被符号扩展成 64 位,固定 10 字节
func encodeInt32(field int, v int32) []byte {
return append(key(field, 0), varint(uint64(v))...)
}
// sint32:先 zigzag 再 varint
func encodeSint32(field int, v int32) []byte {
return append(key(field, 0), varint(zigzag32(v))...)
}
// 极简解析器:读 key、读值、遇到未知字段就跳过(前向兼容的关键)
func skipUnknown(buf []byte, known map[int]string) map[string]string {
out := map[string]string{}
i := 0
for i < len(buf) {
k, n := readVarint(buf[i:])
i += n
field, wire := int(k>>3), int(k&7)
switch wire {
case 0: // varint
v, n := readVarint(buf[i:])
i += n
if name, ok := known[field]; ok {
out[name] = fmt.Sprint(v)
}
// 未知字段:读掉了长度,直接丢弃 → 不报错
case 2: // length-delimited
l, n := readVarint(buf[i:])
i += n
if name, ok := known[field]; ok {
out[name] = string(buf[i : i+int(l)])
}
i += int(l)
}
}
return out
}
func readVarint(buf []byte) (uint64, int) {
var v uint64
for i, b := range buf {
v |= uint64(b&0x7f) << (7 * i)
if b < 0x80 {
return v, i + 1
}
}
return v, len(buf)
}
func main() {
fmt.Println("=== 负数为什么贵:int32 vs sint32 ===")
for _, v := range []int32{-1, -2, 150, 300} {
fmt.Printf("值 %-5d int32 -> %2d 字节,sint32 -> %2d 字节\n",
v, len(encodeInt32(1, v)), len(encodeSint32(1, v)))
}
fmt.Println("\n=== 字段编号:1–15 的 key 只占 1 字节 ===")
for _, f := range []int{1, 15, 16, 2047} {
fmt.Printf("field %-5d key = %v(%d 字节)\n", f, key(f, 0), len(key(f, 0)))
}
small, big := 0, 0
for i := 0; i < 1000; i++ {
for f := 1; f <= 8; f++ { // 编号 1–8
small += len(key(f, 0))
}
for f := 16; f <= 23; f++ { // 编号 16–23
big += len(key(f, 0))
}
}
fmt.Printf("8 个字段 × 1000 条消息:编号 1–8 的 key 开销 %d 字节,编号 16–23 是 %d 字节(2 倍)\n", small, big)
fmt.Println("\n=== 前向兼容:旧代码遇到新字段 ===")
// 新版本消息:field 1 = "张三"(已知),field 99 = 12345(旧代码不认识)
var buf []byte
buf = append(buf, key(1, 2)...)
buf = append(buf, byte(6))
buf = append(buf, []byte("张三")...)
buf = append(buf, key(99, 0)...)
buf = append(buf, varint(12345)...)
got := skipUnknown(buf, map[int]string{1: "name"})
fmt.Printf("解析结果 %v —— 未知字段 99 被跳过,没有报错\n", got)
}
本机实测(macOS,Apple M1,Go 1.27):
=== 负数为什么贵:int32 vs sint32 ===
值 -1 int32 -> 11 字节,sint32 -> 2 字节
值 -2 int32 -> 11 字节,sint32 -> 2 字节
值 150 int32 -> 3 字节,sint32 -> 3 字节
值 300 int32 -> 3 字节,sint32 -> 3 字节
=== 字段编号:1–15 的 key 只占 1 字节 ===
field 1 key = [8](1 字节)
field 15 key = [120](1 字节)
field 16 key = [128 1](2 字节)
field 2047 key = [248 127](2 字节)
8 个字段 × 1000 条消息:编号 1–8 的 key 开销 8000 字节,编号 16–23 是 16000 字节(2 倍)
=== 前向兼容:旧代码遇到新字段 ===
解析结果 map[name:张三] —— 未知字段 99 被跳过,没有报错
读数:
| 现象 | 含义 |
|---|---|
int32 -1 要 11 字节(10 字节 varint + 1 字节 key) | 负数被符号扩展成 64 位,所以“有负数”的字段要用 sint32/sint64 |
sint32 -1 只要 2 字节 | zigzag 把 -1 映射成 1,1 字节就够 |
| 字段编号 1–15 的 key 是 1 字节,16–2047 是 2 字节 | 高频小字段用小编号;编号 16 以上的字段每条消息多 1 字节 |
| 8 字段 × 1000 条:小编号省一半 key 开销 | 这不是微优化:100 万条消息差几 MB |
| 未知字段被跳过、不报错 | 这就是“加字段向后兼容”的机制:旧解析器按长度跳过 |
🔑 19000–19999 是 Protobuf 保留编号(被内部实现占用),用了会编译报错;删除字段时要用
reserved占住编号,防止后人复用——复用一个已删除的编号,就是让旧数据被按新类型解析。
演进规则:什么能改,什么不能改
| 改动 | 二进制协议 | JSON 协议 | 说明 |
|---|---|---|---|
| 加字段(带默认值/optional) | ✅ 安全 | ✅ 安全 | 旧解析器跳过未知字段 |
| 加字段(required) | ❌ 破坏 | ❌ 破坏 | 旧生产者不会填,新消费者解析失败 |
| 删字段 | ⚠️ 要 reserved 编号 | ✅ 一般不破坏 | 删了但别复用编号 |
| 改字段名 | ✅ 安全(编号没变) | ❌ 破坏 | JSON 靠字段名定位 |
| 改字段编号 | ❌ 破坏(旧数据按旧编号解析成别的字段) | —(无编号) | 最隐蔽的事故来源 |
| 改类型(int32 → string) | ❌ 破坏 | ❌ 破坏 | 线格式不同 |
| 枚举加值 | ⚠️ 旧代码可能当未知值处理 | ✅ | 保留 UNKNOWN = 0 |
| 改默认值 | ⚠️ 行为变化 | ⚠️ | 老数据没有“显式的默认值” |
⚠️ 最容易踩的一条:“字段编号 3 不用了,删掉腾出来给新字段”。旧数据/旧消息里编号 3 还是老类型,新代码按新类型解析 → 静默把
int读成string或直接解析失败。规则是:删字段必须reserved 3;,永不复用。
反模式
| 反模式 | 后果 |
|---|---|
| 用 JSON 传大整数 ID(超过 2^53) | JS 端精度丢失,ID 变了 |
| 每次传输都带完整字段名 | 元数据占一半带宽(42.1 的 3.1 倍) |
| 直接复用已删除的字段编号 | 旧数据被按新类型解析,静默错乱 |
用 required 字段做演进 | 一端升级失败就整条链路解析失败 |
| 把浮点金额放进二进制协议 | 精度问题(钱要用整数分或 decimal 字符串) |
| 没有 IDL 仓库,各服务各写一份结构体 | 字段编号冲突,直到上线才发现 |
| 用 schema-less 的二进制(如自定义 TLV)当长期契约 | 没有演进规则,三年后没人敢改 |
自检
Q1 为什么 Protobuf 里“改字段名”通常安全,而“改字段编号”一定不安全?
▸ 答案:二进制编码里,字段的身份是 (编号, 线格式),不是名字:解析器按编号找字段,字段名只在生成代码时使用。改名后编号不变,线上字节完全一样,所以安全(但要注意:某些反射/JSON 映射会用到名字)。改编号则让解析器把“3 号字段的字节”当成“7 号字段”来解释,线格式一致时甚至不会报错,只是值错了——这是最危险的静默故障。
Q2 为什么负数要用 sint32 而不是 int32?什么时候可以用 int32 存负数?
▸ 答案:Protobuf 的 int32 用 varint 编码,负数会被符号扩展成 64 位(因为 varint 是无符号的),所以 -1 变成 10 字节。sint32 先做 zigzag(-1 → 1),绝对值小的负数只占 1 字节。如果字段值经常是大正数、偶尔负数,用 int32 没问题;如果负数常见且绝对值小(温度、差额、坐标偏移),必须用 sint32。
Q3 一个团队把“字段编号 5”从 string 改成了 int64,测试环境没报错,为什么线上可能出问题?
▸ 答案:因为线格式不同:string 是 length-delimited(wire type 2),int64 是 varint(wire type 0)。当旧生产者发来 wire type 2 的数据,新消费者按 wire type 0 解析时通常会因为“线格式不匹配”报错;但如果这个字段被放在 repeated 或 oneof 里、或解析器宽松处理,就可能把长度值当成数字读走,产出错误数据而不报错。正确做法是新增编号,而不是改类型。
Q4 为什么说“IDL 是契约,而不是序列化格式”?
▸ 答案:因为 IDL 约束的不只是字节布局,还有演进规则:哪些改动兼容、哪些必须 reserved、新老版本如何共存。序列化格式只是实现(Protobuf 可以换编码器、可以换语言),而 IDL 定义的字段编号与语义是要跨团队、跨年份保持稳定的接口。把 IDL 当“生成代码的工具”,就会出现“随手改编号、随手删字段”的操作——这些操作在编译期完全合法,在上线后才爆炸。