为什么要用protobuf?

前言

之前做java开发,服务间相互调用用的最多的就是java原生序列化和json序列化协议,然而后面去了大厂做golang开发基本都是采用pb了,刚开始以为只是规范使然,然而通过后面的学习发现pb确实是全方位领先于其他各种序列化协议的,简单来说就是pb不仅更快而且更小,下面就来细细的探讨一番。

bp为什么这么屌

官方测试

那么pb究竟实战有多屌了,先看两幅官方测试报告图:

解包耗时

数据包压缩后大小

可以很明显的看到,一条消息数据,用protobuf序列化后的大小是json的10分之一,是xml格式的20分之一,但是性能却是它们的5~100倍。

为什么用pb对数据包压缩后更小

下面以json数据为基础出发,通过一步一步的对它进行优化,来理解protobuf的实现原理。

例如有一条信息,用json的表示方式如下:

1
{ "age": 32, "name": "xxx",  "height": 170, "weight": 106 }

很明显,整个json串种包含着很多可有可无的字符,例如”,” “:”等,为了把数据变的更小一点,我可能就会采取如下处理方式:

32 xxx 170 106

这里直接舍去了全部不必要的冗余字符,这里其实已经对数据进行了大幅缩减了,但是这时会出现一些新的问题,接收端接收到数据后咋知道32对应的是那个字段,xxx又是对应的哪个字段,也就是字段的对应问题这时是无法解决的。

那我们可以对字段都编个号,接收端接收到数据后就按照这个编号进行解析即可,如下:

字段1:age 字段2:name 字段3: height 字段4:weight
32 Xxx 170 106

这样看来就完美达成目的了

新的问题以及解决方案

虽然上述方案可以达到解决问题的目的,但是我们来假设一下下面的情况height这个字段为null,也就是没有值,那么传递的数据就会变成如下:

32 xxx 106

但是在接收端,解析数据并按照顺序进行字段匹配的时候就会出问题:

字段1:age 字段2:name 字段3: height 字段4:weight
32 xxx 106

很明显数据已经乱套了,原本weight的值解析到了height字段,那为了解决这个问题,pb引入了一个名为tag的技术:

tag|30 tag|zhangsan tag|175.33 tag|140

也就是说,每个字段我们都用tag|value的方式来存储的,在tag当中记录两种信息,一个是value对应的字段的编号,另一个是value的数据类型(比如是整形还是字符串等),因为tag中有字段编号信息,所以即使没有传递height字段的value值,根据编号也能正确的配对。细心的朋友可能已经发现了,乍一看这个方案跟json的key/value方案无异啊,绕了一圈又回到了原点?哈哈,莫急,让我慢慢道来。

Tag的开销

接着上面的问题我们继续……

这个问题其实问的相当好,json中的key其实是字符串,我们知道每个字符会占据一个字节,所以像name这个key就会占据4个字节,但在protobuf中,tag使用二进制进行存储,一般只会占据一个字节,它的核心代码为:

1
2
3
static int makeTag(final int fieldNumber, final int wireType) {
return (fieldNumber << 3) | wireType;
}

fieldNumber表示后面的value所对应的字段的编号是多少,比如fieldNumber为1,就表示age,如果为2,就表示name等;wireType表示value的数据类型,以此来计算value占用字节的大小。在protobuf当中,wireType可以支持的字段类型如下:

因为tag一般占用一个字节,开销还算是比较小的,所以protobuf整体的存储空间占用还是相对小了很多的。

看完上面的说辞是否还是不太理解,下面我来举个例子就很清晰了,例如上面的0-5种数据类型分别对应二进制位 000~101,排序第一的字段age生成的tag就是00001000,一个字节前5位表示序号后三位表示类型。

此时出现一个新问题,那么Tag分隔符为一个字节,如果传输的内容中出现相同的字节,会导致解析错误吗?这里就需要了解一下什么是Varint编码。

Varint编码

这里直接通过实例来进行说明更为直观,如图:

图中对数字123456进行varint编码,123456用二进制表示为 11110001001000000,每次从低向高取7位再加上最高有效位变成 11000000 11000100 00000111 所以经过varint编码后123456占用三个字节分别为 1921967,同样解码的时候就逆向操作即可,通过这样的方式我们就省掉一个字节的开销,其实通常在实际项目我们传输的int一般来说都是比较小的,所以这样的设计也是非常ok的。

Zigzag编码

上面的设计看似完美,但是其实我们仔细思考一下,如果是-1这种负数改如何是好?

-1 –> 11111111 11111111 11111111 11111111

如果继续采用上面的方式并不是一个好的选择,这里就不得不说zigzag编码方式了

直接看表格吧

原始的带符号数 zigzag编码后的表示
0 0
-1 1
1 2
-2 3
…… ……
2147483647 4294967294
-2147483647 4294967295

能看到编码方式就是 :

负数 2 *|x| - 1 正数 2 * |x| 是不是很简单又很神奇。这里提一句即使使用该编码方式后续也还是会使用varint再进行编码的。

Tag-Length-Value(TLV)

我们之前讲的varint又或者是zigtag都是以传输数字为基础的,那如果我们传输的是字符串了?那么引出了另一主角TLV,Tag为分隔符,Length为长度但是我们同样采用varint编码的方式。

接下来我们回到最开始的问题,会出现解析错误的问题吗?

我们来尝试推导一下解析过程 : 如果一开始是要传输一个数字,我们拿到了第一个Tag,解析出它的fieldNumber和wireType,因为采用的是varint的编码方式(zigzag后也是采用varint再次进行编码的),高位为1表示下一个字节还是数字,如果为0则表示下一个字节就是Tag了。如果一开始传输的是一个字符串,那么拿到Tag后就知道接下来的是一个字符串,那么下一个字节就开始解析Length,Length同样还是使用varint编码,遇到高位为0后表示该Length解析完毕,我们就能拿到value的长度了,接下来按照长度取完字符串后,下一个字节就是Tag了。以此类推,pb永远都清楚的知道哪一个字节是Tag,所以现在还疑惑吗?

问题回顾

纠正上面的一个问题,Tag其实并不是固定只占一个字节,而是采用varint的方式呈动态的!试想如果真的固定只占一个字节,那最高也就5位来表示字段序号,这显然是不行的!

以下是来自Google Protobuf Documents里的一句话:

As you can see, each field in the message definition has a unique numbered tag. These tags are used to identify your fields in the message binary format, and should not be changed once your message type is in use. Note that tags with values in the range 1 through 15 take one byte to encode. Tags in the range 16 through 2047 take two bytes. So you should reserve the tags 1 through 15 for very frequently occurring message elements. Remember to leave some room for frequently occurring elements that might be added in the future.

至于这里为什么是1到15 AND 16到2047呢?

  1. 1到15,仅使用1byte。每个byte包含两个部分,前5位是field_number后三位是tag,其中field-number就是protobuf中每个值后等号后的数字也叫字段序号。那么一个byte用来表达这个值就是00000000,其中首位的0表示是否有后续字节,如果为0表示没有也就是这是一个字节,第2到第5位表示field-number,后三位部分则是wire_type部分,表示数据类型。也就是(field_number << 3) | wire_type。其中wire_type只有3位,表示数据类型。那么能够表示field_number的就是第2到第5位的数字,4位数字能够表达的最大范围就是1-15(其中0是无效的)。
  2. 至于16到2047,则与上面的规则其实类似,需要用varint规则进行考虑,下面以2bytes为例子,那么就有10000000 00000000,其中首位的1依旧是符号位,因为varint规则中每个byte的第一位都用来表示下一byte是否和自己有关,那么对于>1byte的数据,第一位一定是1,因为这里假设是2byte,那么第二个byte的第一位是0,刨除这两位,再扣掉3个wire_type位,剩下11位,能够表达的最大数字就是2047,一般我们日常中接口传参数量超过这个数的微乎其微。

总结

Protobuf确实是目前最好的数据传输协议没有之一,当然我们不仅是要会用也要知道为啥要用~