字节跳动踩坑记:一知半解protobuf
共 975字,需浏览 2分钟
·
2020-09-17 02:18
本篇写个小坑,别期望太高…
在广告系统里,对延迟是毫秒必争(毕竟省下来的每一毫秒都可以用在后端优化效果),因此我们和外部媒体之间的通信往往使用 protobuf 。
相比 json、xml,protobuf 确实节省了不少编解码的时间以及网络开销,不过相应的代价是牺牲了便利性,不能用 vi 等文本编辑器查看/修改,遇到问题时排查也比较麻烦。
- 入坑 -
比如 7 月份,某媒体希望一次请求中拉到多条广告(用于信息流场景),因此在 imp 添加一个 ads_count 字段,用于标识本次请求需要的广告数量。
过程是这样,在 xxx.proto 里给 Impression 类型添加一个新字段:
package com.xxx;
message BidRequest {
string id = 1;
int32 ver = 2;
message Impression {
...
int32 ads_count = 9;
}
Impression imp = 3;
...
}
然后用 protoc 编译,生成新版的 xxx.pb.go
protoc --go_out=. xxx.proto
看起来挺简单一个流程,结果还是出了问题:不论媒体请求中填了什么值,这边 decode 出来,imp.GetAdsCount() 得到的总是 1 。
- 排查 -
由于我方代码是自测过的,能够正常取到 ads_count 的值,因此猜测是对方请求有点啥问题。
于是将对方的请求录下来,存到文件 req.pb 中,然后用 protoc 暴力解码:
protoc --decode-raw req.pb
1 {
6: 0x3938373635343332
}
2: 1
3 {
1: 1
2: "6f63bd4df111480"
3: 1
}
...
可以看到,我们什么也没看懂。
不过还好我们有 xxx.proto,借助已知信息,可以更好地解码请求:
protoc --decode=com.xxx.BidRequest xxx.proto < req.pb
id: "123456789"
ver: 1
imp {
id: 1
...
ads_count: 1
10: 3
}
...
看到了点不太对的东西。
- 填坑 -
在 imp 里面,除了 ads_count 之外,还看到了个 "10: 3"。
由于 protobuf 的变量名不能是纯数字,所以这应当是某个在类型定义里没有出现的字段,decode时只能用其序号代替,由此可知,应该是双方的 proto 文件应该有些差异。
经过沟通,媒体确实在 ads_count 之前还加了另一个字段(可能是和其他合作方使用到的);双方对齐以后,问题顺利解决:
修正 ads_count 的序号:
message Impression {
...
int32 ads_count = 10;
}
用正确的 proto 来 decode:
protoc --decode=com.xxx.BidRequest xxx.proto < req.pb
id: "123456789"
ver: 1
imp {
id: 1
...
ads_count: 3
}
...
MISSION COMPLETED.
- encoding -
问题是解决了,但是只写这些就显得太应付了,就再介绍下 proto 文件是怎么编解码的吧。
官方有一篇很详细的文档介绍了编码的过程(详见文末“阅读原文”),这里摘一些重点。
以一个简单的类型为例:
message Test1 {
optional int32 a = 1;
}
如果给 a 赋值 150 并序列化,会得到3个字节(16进制):
08 96 01
其中第一个字节(08)是一个 varint(每个字节的最高位 = 1 表示该 int 还需要拼上后续字节的低 7 bits),其内容包含了第一个元素的序号(field number)和类型(wire type)。
将 08 的二进制 "0000 1000" 拆分成三部分来解释:
0
表示这个 varint 到这个字节就结束了
0001
表示其序号是1
000
表示其值类型也是个 varint
注意,不管这个 varint 有多大,其末3位总是用于表示类型(wire type),可能的取值有:
0: varint
1: 64-bit,如 fixed64, sfixed64, double
2: 指定长度类型,如 string, bytes, 内嵌类型
5: 32-bit,如 fixed32, sfixed32, float
第2、3个字节(96 01)是 a 的值,其二进制表示是
0000 0001
第 2 字节的最高位是 1 ,我们知道这个 varint 还没结束;而第 3 字节的最高位是 0 ,这个 varint 就到此结束了。
将两个最高位去掉,拼出一个完整的二进制数:
150 =
注意:varint 按字节序是小端存储,因此第 3 个字节的 0000001 放在高位。
- signed integers -
varint 看起来是个好东西,因为实践中经常会用到一些枚举值,可能的取值范围很小,使用 varint 只需要少量的空间。
不过如果我们需要用 -1 的时候怎么办呢?不管是用反码还是补码,都需要考虑符号位的问题 —— 对于 int32/int64,负数的编码总是要占用 10 个字节。
protobuf 的解决方案是为 sint32/sint64 引入 "ZigZag encoding",简单来说就是交替使用 0,1,2,3,... 来表示 0,-1,1,-2,...,从而将较小的负数编码为较小的无符号数,再使用 varint 编码。
- 没了 -
就这样吧,更多细节(string、内嵌类型以及数组的编码),请参考官方文档(文末“阅读原文”)。
最后一个小问题,下面这个编码后的消息,表示什么意思呢?
36 36
推荐阅读
站长 polarisxu
自己的原创文章
不限于 Go 技术
职场和创业经验
Go语言中文网
每天为你
分享 Go 知识
Go爱好者值得关注