承接的问题
1.3 说字段号一旦发布就不能复用,但没有解释为什么。要回答这个问题,必须看 Protobuf 在线上的字节:字段号不是元数据,它直接参与编码,决定了 tag 的值,也决定了这条消息占几个字节。
这一节只讲三件事:一条消息怎么变成字节、字段号和类型怎么影响大小、proto3 的默认值为什么不会出现在线上。读完之后,你应该能拿一张字段表手算出 GetBookRequest{isbn="42"} 的字节,也能判断「把 int32 改成 sint32」为什么是破坏性变更。
一条消息的解剖
Protobuf 的每条字段编码都是三部分:
tag(varint) + [length(varint)] + value
tag 由字段号和 wire type 合成:
tag = (field_number << 3) | wire_type
wire type 只有五种,常用的四种:
| wire type | 数值 | 编码方式 | 对应类型 |
|---|---|---|---|
| VARINT | 0 | 变长整数 | int32、int64、uint32、uint64、sint32、sint64、bool、enum |
| I64 | 1 | 固定 8 字节 | fixed64、sfixed64、double |
| LEN | 2 | 长度前缀 + 字节 | string、bytes、嵌入消息、packed repeated |
| I32 | 5 | 固定 4 字节 | fixed32、sfixed32、float |
字段号 1、wire type 2 的 tag 是 (1 << 3) | 2 = 10,十六进制 0a。字段号 4、wire type 0 的 tag 是 (4 << 3) | 0 = 32,十六进制 20。这两个值在本节的例子里会反复出现。
解码器先读 tag,从 tag 里拆出字段号和 wire type,再按 wire type 决定怎么读 value。它不需要 schema 也能跳过不认识的字段:wire type 告诉它这个值有多长。这就是「旧端能忽略新字段」的底层原因。
varint 与 zigzag
varint 用 7 位表示数值,最高位表示「后面还有没有字节」。小数字占 1 字节,大数字占更多字节:
| 数值 | varint 字节 | 说明 |
|---|---|---|
| 0 | 00 | 1 字节 |
| 1 | 01 | 1 字节 |
| 127 | 7f | 1 字节 |
| 128 | 80 01 | 2 字节 |
| 300 | ac 02 | 2 字节 |
| 16383 | ff 7f | 2 字节 |
| 16384 | 80 80 01 | 3 字节 |
int32 的负数是一个陷阱。Protobuf 把负数按 64 位补码编码,-1 会变成 10 字节的 ff ff ff ff ff ff ff ff ff 01。如果字段经常取负值,应该用 sint32,它先做 zigzag 再 varint:
zigzag(n) = (n << 1) ^ (n >> 31)
-1 的 zigzag 是 1,1 的 zigzag 是 2,所以 sint32 的 -1 只占 1 字节。这不是「优化一点点」,而是 10 字节和 1 字节的差别;在高频消息里,它直接影响带宽和缓存。
下面这段代码只用 JDK 实现 varint 和 zigzag,不依赖 Protobuf 运行时,方便在没有生成代码的环境里验证:
package com.plume.linkd.v3.wire;
import java.io.ByteArrayOutputStream;
public final class Varint {
public static byte[] encode(long value) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
while ((value & ~0x7FL) != 0) {
out.write((int) ((value & 0x7F) | 0x80));
value >>>= 7;
}
out.write((int) value);
return out.toByteArray();
}
public static long decode(byte[] bytes, int offset, int[] nextOffset) {
long result = 0;
int shift = 0;
int index = offset;
while (true) {
int b = bytes[index++] & 0xFF;
result |= (long) (b & 0x7F) << shift;
if ((b & 0x80) == 0) {
break;
}
shift += 7;
if (shift >= 64) {
throw new IllegalArgumentException("varint too long");
}
}
nextOffset[0] = index;
return result;
}
public static int zigzagEncode(int value) {
return (value << 1) ^ (value >> 31);
}
public static int zigzagDecode(int value) {
return (value >>> 1) ^ -(value & 1);
}
public static void main(String[] args) {
for (long value : new long[]{0, 1, 127, 128, 300, 16383, 16384}) {
System.out.println(value + " -> " + toHex(encode(value)));
}
System.out.println("zigzag(-1)=" + zigzagEncode(-1));
System.out.println("zigzag(1)=" + zigzagEncode(1));
System.out.println("roundtrip(-7)=" + zigzagDecode(zigzagEncode(-7)));
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
预期输出:
0 -> 00
1 -> 01
127 -> 7f
128 -> 80 01
300 -> ac 02
16383 -> ff 7f
16384 -> 80 80 01
zigzag(-1)=1
zigzag(1)=2
roundtrip(-7)=-7
encode 用 >>> 而不是 >>,是为了处理负数时不在高位补 1;如果补 1,循环永远不会结束。decode 的 shift 上限检查不是防御性代码,而是防止恶意输入让循环跑满 10 字节以上。
字段号为什么值钱
tag 本身也是 varint。字段号 1–15 的 tag 落在 1–127,占 1 字节;字段号 16–2047 的 tag 落在 128–16383,占 2 字节。也就是说:
- 字段号 1–15:每个字段的 tag 省 1 字节。
- 字段号 16 以上:tag 多 1 字节。
- 字段号 2048 以上:tag 多 2 字节。
一条消息如果有 10 个高频字段,全部放在 1–15,比放在 16–25 少 10 字节。按每秒 10 万条算,一天少 86 GB 的原始字节。这个数字是估算,实际带宽还受 TCP/IP 头、HTTP/2 帧和压缩影响,但量级足以说明「字段号是资源」。
这也是为什么删除字段后不能把编号让给新字段:编号不仅标识字段,还决定 tag 的字节长度和线上位置。复用编号会让旧数据被解析成新字段,而且解码器不会报错——wire type 可能恰好兼容。
经验规则:
- 1–15 留给最高频、最稳定的字段。
- 16–2047 留给普通业务字段。
- 2048 以上留给低频、实验性或内部字段。
- 删除字段立即
reserved,编号和名字都保留。
proto3 的默认值与存在性
proto3 有一个容易被误解的行为:默认值不上线。
| 字段值 | 是否序列化 | 反序列化后的值 |
|---|---|---|
int32 stock = 0 | 否 | 0 |
string title = "" | 否 | "" |
bool active = false | 否 | false |
bytes data = empty | 否 | 空字节 |
repeated items = empty | 否 | 空列表 |
| 消息字段未设置 | 否 | null / hasXxx() == false |
这意味着 stock=0 和「没有设置 stock」在线上的字节完全一样。如果业务需要区分「库存为 0」和「库存未知」,不能靠 int32 的默认值,要用 optional int32 stock 或 google.protobuf.Int32Value。前者在 proto3 里提供显式存在性,生成 hasStock()。
optional 的代价是多一个存在性标记,编码时即使值为 0 也会写一个 tag + 0,多 2 字节。它换来的是语义清晰:hasStock() == false 表示未知,hasStock() == true && getStock() == 0 表示确实为 0。
消息字段(message)天然有存在性,不需要 optional。Book 里的 Author author = 5 如果未设置,hasAuthor() 返回 false;标量字段没有这个能力。
在 linkd-v3 里落地:手算与生成代码对照
取一条最小消息:
message Book {
string isbn = 1;
string title = 2;
string author = 3;
int32 stock = 4;
}
构造 Book{isbn="42", title="A", author="B", stock=7},逐字段编码:
| 字段 | tag | length | value | 字节 |
|---|---|---|---|---|
| isbn | 0a | 02 | 34 32 | 0a 02 34 32 |
| title | 12 | 01 | 41 | 12 01 41 |
| author | 1a | 01 | 42 | 1a 01 42 |
| stock | 20 | 无 | 07 | 20 07 |
拼起来是 12 字节:0a 02 34 32 12 01 41 1a 01 42 20 07。把 stock 改成 0,最后两个字节消失,消息变成 9 字节。
下面用生成代码验证这个手算结果,同时检查 roundtrip:
package com.plume.linkd.v3.demo;
import com.plume.linkd.v3.api.Book;
public final class BookEncoding {
public static void main(String[] args) throws Exception {
Book book = Book.newBuilder()
.setIsbn("42")
.setTitle("A")
.setAuthor("B")
.setStock(7)
.build();
byte[] encoded = book.toByteArray();
System.out.println("size=" + encoded.length);
System.out.println("hex=" + toHex(encoded));
Book zeroStock = book.toBuilder().setStock(0).build();
System.out.println("stock0.size=" + zeroStock.toByteArray().length);
Book decoded = Book.parseFrom(encoded);
System.out.println("roundtrip=" + decoded.getIsbn() + "/" + decoded.getTitle()
+ "/" + decoded.getAuthor() + "/" + decoded.getStock());
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
预期输出:
size=12
hex=0a 02 34 32 12 01 41 1a 01 42 20 07
stock0.size=9
roundtrip=42/A/B/7
手算和生成代码逐字节一致。stock=0 少了 20 07 两个字节,反序列化后 getStock() 仍然是 0——这就是「默认值不上线」的实际表现。
再看一个负数。int32 stock = -1 的编码是 20 ff ff ff ff ff ff ff ff ff 01,10 字节;如果字段定义成 sint32 stock = 4,-1 的编码是 20 01,2 字节。字段号相同,wire type 相同,但值的编码规则变了,所以 int32 改成 sint32 是破坏性变更。
编码之外:顺序、未知字段与确定性
Protobuf 不保证字段顺序,但主流实现按字段号升序写出。依赖顺序的代码是脆弱的:不同语言、不同版本的实现可能改变顺序,哈希或签名会跟着变。需要稳定字节时,用确定性序列化(如果运行时提供)或在应用层排序后再计算摘要。
未知字段在 Java 运行时里默认保留:旧端解析新消息时,不认识的字段被存进 unknown fields,重新序列化时原样带回。这让「网关透传」不会丢字段,但不要把这一点当成跨语言保证——某些实现会丢弃未知字段。
toByteArray() 每次都会分配新数组。高频路径上,这个消息的分配成本不能忽略;4.3 会讨论消息大小、压缩和复用。现在只需要记住:编码结果的长度取决于字段号、值的大小和是否默认值,不取决于 Java 对象的大小。
常见错误与边界
错误一:用字段名判断兼容性。 线上只有字段号,没有字段名。重命名字段在线上是兼容的,但源码会编译失败;改字段号在源码可能编译通过,线上却错位。
错误二:把负数放进 int32。 -1 占 10 字节,-1000000 也占 10 字节。高频负数字段用 sint32/sint64,否则带宽会被悄悄吃掉。
错误三:用默认值表达「未知」。 stock=0 既可能是「没库存」,也可能是「没设置」。需要区分时用 optional 或 wrapper 类型。
错误四:在 repeated 里放大量小整数却不打包。 proto3 的数值型 repeated 默认 packed,编码是「一个 tag + 总长度 + 连续 varint」,比每个元素一个 tag 省很多。手写编码时不要退化成非 packed。
错误五:把 bytes 当字符串。 bytes 不校验 UTF-8,string 校验。把二进制塞进 string,某些运行时会拒绝或替换非法字节;把文本塞进 bytes,接收方要自己解码。
边界:varint 最长 10 字节。 64 位整数最多 10 个字节,超过就是非法输入。解码器必须限制循环次数,否则恶意构造的连续 0x80 会让它一直读下去。
边界:字段号 19000–19999 保留。 Protobuf 内部保留这段编号,使用了会在 protoc 编译时报错。不要在这段范围里定义字段。
边界:消息嵌套层数有上限。 递归消息过深会触发解析限制,默认 100 层。设计树形结构时用扁平的 repeated 加 parent 字段,不要靠深层嵌套。
小结
- 每条字段是
tag + [length] + value,tag 由(字段号 << 3) | wire type合成。 - varint 用 7 位一组,小数字省字节;负数在
int32里占 10 字节,负值多的字段用sint32。 - 字段号 1–15 的 tag 占 1 字节,是最贵的编号资源;删除字段必须
reserved编号和名字。 - proto3 的默认值不上线,
0、空串、空列表和「未设置」在字节上无法区分;需要存在性时用optional。 Book{isbn="42", title="A", author="B", stock=7}的编码是 12 字节,stock=0时是 9 字节。- 编码顺序、未知字段保留和确定性序列化都不是跨语言强保证,不要依赖字节级相等做业务判断。
知道了字节怎么来,下一个问题是「什么时候不该用 Protobuf」。2.2 把 JSON 和 Protobuf 放在同一张表里比较,给出网关边界上的选择依据。
阅读导航:上一节 1.3 契约先行与演进边界 | 下一节 2.2 JSON 与 Protobuf 的取舍
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。