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 当“生成代码的工具”,就会出现“随手改编号、随手删字段”的操作——这些操作在编译期完全合法,在上线后才爆炸。

关联