gitbook/系统性能调优必知必会/docs/247544.md
2022-09-03 22:05:03 +08:00

9.3 KiB
Raw Blame History

17 | Protobuf是如何进一步提高编码效率的

你好,我是陶辉。

上一讲介绍的HTTP/2协议在编码上拥有非常高的空间利用率这一讲我们看看相比其中的HPACK编码技术Protobuf又是通过哪些新招式进一步提升编码效率的。

Google在2008年推出的Protobuf是一个针对具体编程语言的编解码工具。它面向Windows、Linux等多种平台也支持Java、Python、Golang、C++、Javascript等多种面向对象编程语言。使用Protobuf编码消息速度很快消耗的CPU计算力也不多而且编码后的字符流体积远远小于JSON等格式能够大量节约昂贵的带宽因此gRPC也把Protobuf作为底层的编解码协议。

然而很多同学并不清楚Protobuf到底是怎样做到这一点的。这样当你希望通过更换通讯协议这个高成本手段提升整个分布式系统的性能时面对可供选择的众多通讯协议仅凭第三方的性能测试报告你仍将难以作出抉择。

而且面对分布式系统中的疑难杂症往往需要通过分析抓取到的网络报文确定到底是哪个组件出现了问题。可是由于Protobuf编码太过紧凑即使对照着Proto消息格式文件在不清楚编码逻辑时你也很难解析出消息内容。

下面我们将基于上一讲介绍过的HPACK编码技术看看Protobuf是怎样进一步缩减编码体积的。

怎样用最少的空间编码字段名?

消息由多个名、值对组成比如HTTP请求中头部Host: www.taohui.pub就是一个名值对其中Host是字段名称而www.taohui.pub是字段值。我们先来看Protobuf如何编码字段名。

对于多达几十字节的HTTP头部HTTP/2静态表仅用一个数字来表示其中映射数字与字符串对应关系的表格被写死在HTTP/2实现框架中。这样的编码效率非常高但通用的HTTP/2框架只能将61个最常用的HTTP头部映射为数字它能发挥出的作用很有限。

动态表可以让更多的HTTP头部编码为数字在上一讲的例子中动态表将Host头部减少了96%的体积效果惊人。但动态表生效得有一个前提必须在一个会话连接上反复传输完全相同的HTTP头部。如果消息字段在1个连接上只发送了1次或者反复传输时字段总是略有变动动态表就无能为力了。

有没有办法既使用静态表的预定义映射关系,又享受到动态表的灵活多变呢?**其实只要把由HTTP/2框架实现的字段名映射关系交由应用程序自行完成即可。**而Protobuf就是这么做的。比如下面这段39字节的JSON消息虽然一目了然但字段名name、id、sex其实都是多余的因为客户端与服务器的处理代码都清楚字段的含义。

{"name":"John","id":1234,"sex":"MALE"}

Protobuf将这3个字段名预分配了3个数字定义在proto文件中

message Person {
  string name = 1;
  uint32 id = 2;  

  enum SexType {
    MALE = 0;
    FEMALE = 1;
  }
  SexType sex = 3;
}

接着通过protoc程序便可以针对不同平台、编程语言将它生成编解码类最后通过类中自动生成的SerializeToString方法将消息序列化编码后的信息仅有11个字节。其中报文与字段的对应关系我放在下面这张图中。

从图中可以看出Protobuf是按照字段名、值类型、字段值的顺序来编码的由于编码极为紧凑所以分析时必须基于二进制比特位进行。比如红色的00001、00010、00011等前5个比特位就分别代表着name、id、sex字段。

图中字段值的编码方式我们后面再解释这里想必大家会有疑问如果只有5个比特位表示字段名的值那不是限制消息最多只有31个25 - 1字段吗当然不是字段名的序号可以从1到536870911即229 - 1可是多数消息不过只有几个字段这意味着可以用很小的序号表示它们。因此对于小于16的序号Protobuf仅有5个比特位表示这样加上3位值类型只需要1个字节表示字段名。对于大于16小于2027的序号也只需要2个字节表示。

Protobuf可以用1到5个字节来表示一个字段名因此每个字节的第1个比特位保留它为0时表示这是字段名的最后一个字节。下表列出了几种典型序号的编码值请把黑色的二进制位从右至左排列比如2049应为000100000000001即2048+1

说完字段名,我们再来看字段值是如何编码的。

怎样高效地编码字段值?

Protobuf对不同类型的值采用6种不同的编码方式如下表所示

字符串用Length-delimited方式编码顾名思义在值长度后顺序添加ASCII字节码即可。比如上文例子中的John对应的ASCII码如下表所示

这样,"John"需要5个字节进行编码如下图所示绿色表示长度紫色表示ASCII码

这里需要注意字符串长度的编码逻辑与字段名相同当长度小于128271个字节就可以表示长度。若长度从128到16384214则需要2个字节以此类推。

由于字符串编码时未做压缩,所以并不会节约空间,但胜在速度快。如果你的消息中含有大量字符串那么使用Huffman等算法压缩后再编码效果更好。

我们再来看id1234这个数字是如何编码的。其实Protobuf中所有数字的编码规则是一致的字节中第1个比特位仅用于指示由哪些字节编码1个数字。例如图中的1234将由14个比特位00010011010010表示1024+128+64+16+2正好是1234

**由于消息中的大量数字都很小,这种编码方式可以带来很高的空间利用率!**当然如果你确定数字很大这种编码方式不但不能节约空间而且会导致原先4个字节的大整数需要用5个字节来表示时你也可以使用fixed32、fixed64等类型定义数字。

Protobuf还可以通过enum枚举类型压缩空间。回到第1幅图sex: FEMALE仅用2个字节就编码完成正是枚举值FEMALE使用数字1表示所达到的效果。

而且由于Protobuf定义了每个字段的默认值因此当消息使用字段的默认值时Protobuf编码时会略过该字段。以sex: MALE为例由于MALE=0是sex的默认值因此在第2幅示例图中这2个字节都省去了。

另外当使用repeated语法将多个数字组成列表时还可以通过打包功能提升编码效率。比如下图中对numbers字段添加101、102、103、104这4个值后如果不使用打包功能共需要8个字节编码其中每个数字前都需要添加字段名。而使用打包功能后仅用6个字节就能完成编码显然列表越庞大节约的空间越多。

在Protobuf2版本中需要显式设置 [packed=True] 才能使用打包功能而在Protobuf3版本中这是默认功能。

最后,从这里可以查看Protobuf的编解码性能测试报告你能看到在保持高空间利用率的前提下Protobuf仍然拥有飞快的速度

小结

这一讲我们介绍了Protobuf的编码原理。

通过在proto文件中为每个字段预分配1个数字编码时就省去了完整字段名占用的空间。而且数字越小编码时用掉的空间也越小实际网络中大量传输的是小数字这带来了很高的空间利用率。Protobuf的枚举类型也通过类似的原理用数字代替字符串可以节约许多空间。

对于字符串Protobuf没有做压缩因此如果消息中的字符串比重很大时建议你先压缩后再使用Protobuf编码。对于拥有默认值的字段Protobuf编码时会略过它。对于repeated列表使用打包功能可以仅用1个字段前缀描述所有数值它在列表较大时能带来可观的空间收益。

思考题

下一讲我将介绍gRPC协议它结合了HTTP/2与Protobuf的优点在应用层提供方便而高效的RPC远程调用协议。你也可以提前思考下既然Protobuf的空间效率远甚过HPACK技术为什么gRPC还要使用HTTP/2协议呢

在Protobuf的性能测试报告中C++语言还拥有arenas功能你可以通过option cc_enable_arenas = true语句打开它。请结合[第2讲] 的内容谈谈arenas为什么能提升消息的解码性能欢迎你在留言区与我一起探讨。

感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。