每天写 gRPC 接口,
stub.search(request) 一行搞定。gRPC 帮你把对象变成字节、从字节变回对象,全程不用操心。但总有些时候你得操心。
抓包看到一串 hex,想知道里面是什么。排查 RPC 丢字段,需要确认 wire 上到底传了什么。做网关或协议转换,得在没有生成类的前提下读懂 protobuf 字节。
这些场景都指向同一个问题:gRPC 请求变成字节后,到底长什么样?
先给一个反常识的事实:同一个值
-1,如果字段类型是 int32,序列化后占 10 个字节;换成 sint32,只占 1 个字节。差 10 倍。这篇文章会带你逐字节拆一段真实的 gRPC 请求,看清 wire format 的全貌。
1. 一个贯穿全文的 gRPC 请求
先看
.proto:9 个字段,覆盖了 protobuf 里几乎所有常见类型。
填上值:
query = "protobuf wire format"、page_number = 2、result_per_page = 50、score_delta = -5、ratio = 0.85、weight = 1.5、verbose = true、filters = [10, 20, 30]、page = {offset: 40, limit: 20}。用生成类序列化:
出来 55 个字节:
这就是 gRPC 传输时 wire 上真正跑的东西。下面逐字节拆。
2. wire format:字节流里到底存了什么
2.1 Tag:字段号 + wire type
JSON 是自描述的。
{"pageNumber": 2} 这一串,光 "pageNumber" 这个 key 就十几个字节,真正的值只占一个字符。字段名跟着数据走,传一万次就重复一万次。protobuf 不这么干。它把字段名留在
.proto 里,wire 上只放一个编号。每个字段在字节流里就是两段:
tag 把两件事压进一个 varint:
field_number 是你在 .proto 里写的 = 1、= 2。wire_type 占低 3 位,告诉解码器"接下来该怎么读这个值"。常用的 wire type 就 4 种:
wire type | 编号 | 谁用它 |
VARINT | 0 | int32 / int64 / uint / bool / enum / sint(ZigZag 后) |
I64 | 1 | fixed64 / sfixed64 / double |
LEN | 2 | string / bytes / 嵌套 message / packed repeated |
I32 | 5 | fixed32 / sfixed32 / float |
术语说明:protobuf 官方文档现在叫 VARINT / I64 / LEN / I32,早几年叫 Varint / 64-bit / Length-delimited / 32-bit。同一回事,新旧叫法。

2.2 Varint:用多少字节,值说了算
Varint 是 protobuf 最基础的编码方式。每字节低 7 位存数据,最高位(MSB)是延续位:1 表示后面还有字节,0 表示到此为止。
小数字只占 1 字节。
标本里
page_number = 2 只要 2 字节:10 是 tag:0x10 = 0b0001_0000,低 3 位 000 是 wire type 0(VARINT),其余 00010 就是字段号 2。算一下:(2 << 3) | 0 = 16 = 0x10。02 是值。varint 最高位是 0,直接就是 2。完事。再看个大数字。假设值是 150,varint 需要 2 字节:
拆开看:
一个字段名都没出现。这就是 protobuf 省的地方。

2.3 ZigZag:为什么 sint 存负数更小
回到开头那个反常识问题。
int32 存 -1 时,protobuf 先符号扩展到 64 位再做 varint。-1 的 64 位补码全是 1,varint 编出来就是 10 个字节。sint32 先做 ZigZag 变换,把小的负数映射成小的正数:标本里
score_delta = -5,字节是:20 是 tag:(4 << 3) | 0 = 32 = 0x20,字段 4、VARINT。09 就是 ZigZag 后的 9,还原回去是 -5。1 字节搞定。结论:经常出现负数的字段,用
sint32 / sint64。2.4 Length-delimited:字符串和嵌套
wire type 2 的格式是
tag + 长度(varint) + 内容。看 query = "protobuf wire format":0a 是 tag:(1 << 3) | 2 = 10 = 0x0a,字段 1、LEN。14 是长度 20 的 varint。后面 20 字节是 UTF-8 的 "protobuf wire format"。string、bytes、嵌套 message、packed repeated 都用 LEN。解码器靠长度前缀知道该读多少。2.5 float / double 是位模式
字段 5
ratio = 0.85,字节是:29 是 tag:(5 << 3) | 1 = 41 = 0x29,字段 5、I64(64 位定长)。后面 8 字节不是把 0.85 当整数存,是把它的 IEEE 754 位模式小端存进去。Java 里就是 Double.doubleToLongBits(0.85)。float 同理,字段 6
weight = 1.5:I32,4 字节。
Float.floatToIntBits(1.5f) 的小端表示。解码时忘了位模式转换,直接把字节当整数读,出来就是天文数字。
2.6 完整字节对照表
55 字节,全貌:
字节 | tag | 字段 | wire type | 值 |
0a 14 + 20 字节 | 0a | 1 | LEN | "protobuf wire format" |
10 02 | 10 | 2 | VARINT | 2 |
18 32 | 18 | 3 | VARINT | 50 |
20 09 | 20 | 4 | VARINT | 9(ZigZag → -5) |
29 + 8 字节 | 29 | 5 | I64 | 0.85(double 位模式) |
35 + 4 字节 | 35 | 6 | I32 | 1.5(float 位模式) |
38 01 | 38 | 7 | VARINT | 1(true) |
42 03 0a 14 1e | 42 | 8 | LEN | packed [10, 20, 30] |
4a 04 08 28 10 14 | 4a | 9 | LEN | 嵌套 {offset:40, limit:20} |
拎几个细节出来说。
packed。 字段 8
repeated int32 filters = [10, 20, 30]。proto3 里标量 repeated 默认 packed:不给每个元素单独写 tag,而是把三个值的 varint 拼在一起(0a 14 1e,就是 10、20、30),整体当一个 LEN。三个元素省两个 tag。嵌套 message。 字段 9
page 是个子消息。wire 上就是一个 LEN:长度 04,后面 08 28 10 14 是子消息完整序列化的结果。08 28 是子消息字段 1 = 40(0x28 = 40),10 14 是字段 2 = 20。嵌套就是「一段 protobuf 塞进另一段 protobuf 的某个字段里」。
3. proto3 的几条重要规则
3.1 默认值不上线
proto3 最反直觉的一点:字段值等于默认值时,不会出现在字节流里。
数值默认 0、字符串默认
""、bool 默认 false。解码端 getPageNumber() 返回 0,你分不清是"没设"还是"设了 0"。3.2 optional 找回存在性
如果确实需要区分"0"和"没设",用
optional:optional 字段即使被显式设成默认值也会被序列化,因为运行时单独跟踪了 presence。代价是生成代码里多一套 hasXxx()。wire 上的编码格式和普通字段一模一样。3.3 repeated 标量默认 packed
前面表里
filters = [10, 20, 30] 只用了一个 tag + 一段长度前缀的连续 payload。这是 proto3 对标量 repeated 默认开的 packed 编码。不 packed 的话,每个元素都要写一遍 tag。元素越多省得越多。
3.4 两条补充
- proto3 没有
required。历史教训:required 是兼容性灾难。
- enum 收到未知数值时不报错,原样保留为 int,保证前向兼容。
4. Java 引擎:protoc 生成的代码到底干了什么
4.1 不可变 message + Builder
protoc 生成的 SearchRequest 是不可变的。所有字段只读,构造只能通过 SearchRequest.Builder。好处是线程安全,可以放心共享。想改就
request.toBuilder() 拿一个新 Builder。4.2 编码:CodedOutputStream
toByteArray() 底层走的是 CodedOutputStream,负责把每个字段写成 tag + value:序列化前框架先算一遍
getSerializedSize() 并缓存,好一次性分配够大的 buffer。4.3 解码:CodedInputStream
parseFrom(byte[]) 底层是 CodedInputStream,核心是一个按 tag 循环 dispatch 的状态机:switch 里的数字就是提前算好的 tag 值。字段 1 的 string → (1 << 3) | 2 = 10,字段 2 的 int32 → (2 << 3) | 0 = 16。这段代码虽然是
protoc 生成的,但和你拿着前面那张表手工对字节没有本质区别。5. gRPC 编解码路径:就是标准 protobuf
用 gRPC 的时候,你可能会想:gRPC 的 marshaller 编出来的字节,和直接调
toByteArray() 一样吗?我在 protobuf-codec-demo 工程里做了三方对比:
三者完全一致。生成类
toByteArray()、gRPC ProtoUtils.marshaller()、甚至用 UnknownFieldSet 手搓的——编出来的字节一模一样。又用
DynamicMessage(只有 Descriptor 没有生成类)做了同样的对比,字节也完全一致。gRPC 在 HTTP/2 帧层有 5 字节的 framing header(1 字节压缩标记 + 4 字节消息长度),但 message body 就是标准 protobuf 二进制。
结论:搞清楚 protobuf 的 wire format,就搞清楚了 gRPC 的 wire。

6. 兼容性:加了新字段,旧代码不会坏
protobuf 的演进规则靠字段编号,不是字段名。
- 加字段:用新编号。旧代码不认识,但不会报错。
- 删字段:别删编号、别复用编号。用
reserved锁住。
- 改类型:只在 wire 兼容的类型间安全(如 int32 ↔ int64),否则读出乱码。
旧代码遇到不认识的字段不会丢弃,而是塞进
UnknownFieldSet,再序列化时原样写回:这个机制在微服务网关和中间代理场景特别重要:中间层不需要知道所有字段的 schema,也能保证数据不丢。
7. 收个尾
几条工程规约留着速查:
- 高频字段用 1~15 号。这些编号的 tag 只占 1 字节,编号 ≥ 16 起 tag 要 2 字节。
- 绝不复用已删字段编号,用
reserved锁住。
- 常含负数的字段用
sint32/sint64。差别真的很大。
- 需要区分"未设置 vs 默认值"时才上
optional。
验证工具推荐:
protoc --decode_raw < data.bin,无需 .proto 就能反解字节,核对你的理解。这篇把 protobuf 编解码的原理和 Java 引擎讲清楚了。但如果当你拿到一段 protobuf 字节,
.proto 不在你手上,这时候怎么办?我们下篇见~
- 作者:Yibin
- 链接:https://yibin.dev/article/38860b50-99a4-80e3-b7f8-fd57e6b5bec0
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章






