vlambda博客
学习文章列表

「gRPC系列」高性能数据压缩编码技术:protobuf

大家好,我是阿星,今天继续「gRPC系列:全面剖析gRPC 的设计原则与工作原理」的分享。


续接前文:





今天是第三篇:「高性能数据压缩编码技术:Protocol Buffers」, 今天先给大家介绍一下 gRPC 中高性能的关键基础组件:Protocol Buffers 编码技术。


序0:Protocol Buffers 协议介绍

gRPC 能比常规的 REST 请求高出近 50 倍性能的一个关键原因就是其消息传输使用了更高性能的数据序列化编码协议:Google Protocol Buffers。

Google Protocol Buffers 是一种有效序列化数据的方法。它们是语言和平台中立的,并且被设计得易于扩展。其数据结构只需要定义一次,然后可以生成特定的序列化和反序列化代码以有效地处理您的数据格式。 


序列化使用高效编码技术使得序列化的数据尽可能节省空间,并且为每种数据格式自定义生成的代码允许快速序列化和反序列化。 


Universal Messaging 始终使用 Google Protocol Buffers 的服务器端过滤,再加上 Google Protocol Buffer 节省空间的序列化可以减少传递给客户端的数据量。


简单来说就是 Protocol Buffers 这种数据编码协议比 XML / JSON 等数据序列化技术的数据更节省空间即消息体积更小,在网络传输过程中,消息体积小就意味着传输更快。


另外基于 Protocol Buffers 这种方式序列化和反序列化的速度也更快,效率更高。


另外,Protocol Buffers 提供了非常了自动代码生成机制,代码生成机制能够极大解放开发者编写数据协议解析过程的时间,提高工作效率;其次,易于开发者维护和迭代,当需求发生变更时,开发者只需要修改对应的数据传输文件内容即可完成所有的修改。


只谈优点,不谈缺点岂不是耍牛氓,再来看看 Protocol Buffers 的缺点:

  1. 缺少自描述机制,不能通过 Protocol Buffers 自己的语法的描述自定义的语法,这点使用起来比起xml可能,没有那么灵活,但是也够用了。

  2. 可读性差,Protocol Buffers 相比于 JSON,XML 这种语法来说,可读性确实是要差一些。

序1:Protocol Buffers 编码性能分析

在计算机世界里衡量一个技术好不好其实就两个维度:


  • 时间维度:采用 Protocol Buffers 格式对数据进行序列化时,编码性能耗时更短,比 XML 要高 20 倍以上,比 JSON 也强一些。

  • 空间维度:XML 格式为了保持较好的可读性,引入了一些冗余的文本信息。所以在使用 XML 格式进行存储数据时,也会消耗空间。整体而言,Protocol Buffers 以高效的二进制方式存储,比 XML 小 3 到 10 倍。


下面分别使用 XML,Protobuf(Protocol Buffers),JSON 来描述这样一个数据模型:

{ Name: "hello", Title: "world", Age: 18, Count: 28,}

测试编码效率数据如下:

1. JSON

goos: darwingoarch: amd64pkg: demo/serializecpu: Intel(R) Core(TM) i5-8279U CPU @ 2.40GHzBenchmarkJsonSerializeBenchmarkJsonSerialize-8 3091018 379.9 ns/opPASS

2. Protobuf(Protocol Buffers)

goos: darwingoarch: amd64pkg: demo/serializecpu: Intel(R) Core(TM) i5-8279U CPU @ 2.40GHzBenchmarkProtoSerializeBenchmarkProtoSerialize-8 4494912 254.7 ns/opPASS

3. XML

goos: darwingoarch: amd64pkg: demo/serializecpu: Intel(R) Core(TM) i5-8279U CPU @ 2.40GHzBenchmarkXMLSerializeBenchmarkXMLSerialize-8 483742 2365 ns/opPASS


4. XML/Proto/JSON 空间占用对比

=== RUN   TestSpaceCoverageserialize_test.go:49: XML序列化消息体积:87serialize_test.go:56: Protobuf序列化消息体积:18serialize_test.go:63: JSON序列化消息体积:52--- PASS: TestSpaceCoverage (0.00s)PASS


根据上述数据,我画了一张图表出来了,具体的指标对比数据如下:

  • 编码耗时:

        

  • 编码空间占用:


「gRPC系列」高性能数据压缩编码技术:protobuf




很明显,不管是空间占用率上还是编码性能耗时上,Protobuf(Protocol Buffers)都比较占优。在编码时间消耗上来看,和 JSON 相差不多,但是编码后的数据大小还是非常有优势的。

序2:proto使用

Protobuf(Protocol Buffers)的使用,在第一篇文章中其实已经介绍过了,这里再来回顾一下。


创建一个 proto 文件,并定义如下内容:

 
 
// The request message containing the user's name.message HelloRequest { string name = 1; string title = 2; int32 age = 3; uint32 count = 4;}

然后使用对应的 proto 生成工具,生成指定语言的序列化和反序列化的代码,这里就不详细展开了。


在 proto 中,所有结构化的数据都被称为 message。即,在 proto 中,message 就是用来描述数据的。那么关于其他数据就暂时不做解析了,主要来看看 message 的定义:

1. 确定变量类型

其中 proto 支持的数据类型有如下几种:

「gRPC系列」高性能数据压缩编码技术:protobuf


2. 确定变量名和变量位置(field_number)


通过上面两步就可以确定一个message的数据结构了。

如下,我们定义了这样一个message:

// The request message containing the user's name.message HelloRequest { string name = 1; string title = 2; int32 age = 3; uint32 count = 4; int64 max = 5; fixed32 test = 6; double test2 = 7;
}

里面定义了7个变量,每个变量都有自己的属性和field_number,最终生成的go 结构的代码如下:

// The request message containing the user's name.type HelloRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` Age int32 `protobuf:"varint,3,opt,name=age,proto3" json:"age,omitempty"` Count uint32 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` Max int64 `protobuf:"varint,5,opt,name=max,proto3" json:"max,omitempty"` Test uint32 `protobuf:"fixed32,6,opt,name=test,proto3" json:"test,omitempty"` Test2 float64 `protobuf:"fixed64,7,opt,name=test2,proto3" json:"test2,omitempty"`}


另外,proto 除了支持上述列表的基础数据类型之外,还支持数组,map , enum 等结构化数据类型。


message HelloRequest1 {  repeate string name = 1; //数组  repeate HelloRequest HelooRequests = 2; //对应结构的数组  map<string, string> attrs = 3; //map}
enum TestType //枚举消息类型{    TestInit = 0; //proto3版本中,首成员必须为0,成员不应有相同的值    TestOpen = 1;    TestClose = 2;}


序3:proto编码

我们再来从根源上看一下,proto 协议序列化后的数据如何编排的,也就是解开它比其他编码协议更高效的谜题。


首先,我们定义了一个这样的一个简单的数据:


// The request message containing the user's name.message HelloRequest { string name = 1; //"hello" string title = 2; //"world" int32 age = 3; //111 uint32 count = 4; //222222222 int64 max = 5; //1232424 fixed32 test = 6; //1110000  double test2 = 7; //12.22}


使用 ProtoBuf 协议编码后得到的二进制数据如下:


「gRPC系列」高性能数据压缩编码技术:protobuf


看一下,ProtoBuf 的编码规则:


一个 message 中数据是由多个字段构成,其中每个字段的序列化则如下:

  • protobuf 将消息里的每个字段进行编码后,再利用T-L-V或者T-V的方式进行数据存储。

  • protobuf 对于不同类型的数据会使用不同的编码和存储方式。

  • protobuf 的编码和存储方式是其性能优越、数据体积小的原因。


「gRPC系列」高性能数据压缩编码技术:protobuf

其中,tag 编码规则如下:


tag := (field << 3) BIT_OR wire_type, encoded as varintvalue := (varint|zigzag) for wire_type==0 | fixed32bit for wire_type==5 | fixed64bit for wire_type==1 | delimited for wire_type==2 | group_start for wire_type==3 | This is like “open parenthesis” group_end for wire_type==4 This is like “close parenthesis”


即 tag 由字段编号 field_number 和 编码类型 wire_type 组成, tag 整体采用 Varints 编码。tag的值为(field_number << 3) | wire_type,即最后三位存储wire_type(编码类型),其他位用于存储 field_number(字段编号)。


varint 编码:

varint编码中的每个字节都设置了最高有效位(most significant bit - msb)–msb为1则表明后面的字节还是属于当前数据的,如果是0那么这是当前数据的最后一个字节数据。每个字节的低7位用于以7位为一组存储数字的二进制补码表示,最低有效组在前,或者叫最低有效字节在前。这表明varint编码后数据的字节是按照小端序排列的。


123456 对应的二进制表示为 1 1110 0010 0100 0000用 varint 编码:每次从低向高取7位再加上最高有效位变为了:1100 0000 11000100 00000111 所以经过varint编码后123456占用三个字节分别为192 196 7


另外,不同数据类型的字段对应的 wire_type 编码规则如下:



下面我们再来逐步解析一下前面的编码二进制数据:


 {   //wire_type=2,field_number=1得到字段属性编码数据为  //2<<3|0 = 00001010  //write_type=2时是需要额外的数据来存储其对应的数据长度的  //故length=5来存储hello的长度  //接下会拿出5个长度来存储hello  //整体下来数据就是  //00001010 00000101 01101000 01100101 01101100 01101100 01101111     Name:  "hello",        //和上面编码规则雷同,就不占用篇幅了,数据如下:   //00010010 00000101 01110111 01101111 01110010 01101100 01100100  Title: "world",     //wire_type=0,field_number=3    //使用varint编码,并且不需要length位    //结果为:00011000 01101111  Age: 111,    //wire_type=0    //使用varint编码,并且不需要length位 //00100000 10001110 10101111 11111011 01101001  Count:222222222,    //wire_type=0 //00101000 10101000 10011100 01001011     Max:   1232424,    //wire_type=5    //00110101 01101111 00000000 00000000 00000000  Test: 1110000, //wire_type=1 //00111001 01110001 00111101 00001010 11010111 10100011 01110000 00101000 01000000    Test2: 12.22,    }

序4:总结

总体来说,Protobuf(Protocol Buffers) 拥有如下优点:


  • Protocol Buffer 利用 varint 原理压缩数据以后,二进制数据非常紧凑,option 也算是压缩体积的一个举措。所以 pb 体积更小,如果选用它作为网络数据传输,势必相同数据,消耗的网络流量更少。但是并没有压缩到极限,float、double 浮点型都没有压缩。


  • Protocol Buffer 比 JSON 和 XML 少了 {、}、: 这些符号,体积也减少一些。再加上 varint 压缩,gzip 压缩以后体积更小。


  • Protocol Buffer 是 Tag - Value (Tag - Length - Value)的编码方式的实现,减少了分隔符的使用,数据存储更加紧凑。


  • Protocol Buffer 另外一个核心价值在于提供了一套工具,一个编译工具,自动化生成 get/set 代码。简化了多语言交互的复杂度,使得编码解码工作有了生产力。


  • Protocol Buffer 不是自我描述的,离开了数据描述 .proto 文件,就无法理解二进制数据流。这点即是优点,使数据具有一定的“加密性”,也是缺点,数据可读性极差。所以 Protocol Buffer 非常适合内部服务之间 RPC 调用和传递数据。


  • Protocol Buffer 具有向后兼容的特性,更新数据结构以后,老版本依旧可以兼容,这也是 Protocol Buffer 诞生之初被寄予解决的问题。因为编译器对不识别的新增字段会跳过不处理。