小编遇到一个json序列化非常消耗CPU性能的问题。情况大概是这样的,接口查询的是某对象的属性,该对象的属性有上千个,采用的是JSON存储的,在用Go反序列化到内存结构体的时候,非常消耗CPU。也就是说采用JSON编解码有大量字段对象的场景,往往会出现性能瓶颈。而与之对应的protobuf在编解码时性能要优于json,下面主要对protobuf编码原理做个分析,弄懂protobuf编码效率很高的原因。
下面是官方文档对protobuf的定义,大体意思是protocol buffers是一种语言无关、平台无关、可扩展的序列化结构数据的方法,可用于数据通信协议和数据存储等。它是一种灵活、高效和自动化机制的结构数据序列化方法,相比XML,有编码后体积更小,编解码速度更快的优势。
❝Protocol buffers are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages. ❞
假设我们有一个person对象,现在来看在JSON、XML和protobuf表示下它们各是什么样的。用XML格式表示如下
<person>
<name>ivy</name>
<age>24</age>
</person>
用JSON格式表示如下
{
"name":"ivy",
"age":24
}
用protobuf表示如下, 它直接用二进制来表示数据,不像上面XML和JSON格式那么直观的看到表示的内容。
[10 6 69 108 108 105 111 116 16 24]
proto消息类型文件一般以.proto结尾,可以在一个.proto文件中定义一个或多个消息类型。下面是一个搜索查询的消息类型定义,在最开头的syntax描述的是版本信息,proto目前有两个版本proto2和proto3。syntax="proto3"明确的设置了语法格式为proto3,如果不设置syntax即默认为proto2. query为要查询的内容,page_number表示查询有多少页,每页的数量为result_per_page个。syntax="proto3"必须位于.proto文中除去注释和空行的第一行。
下面的消息包含3个字段(query,page_number,result_per_page),每个字段有一个类型,字段名称和字段编号。字段类型可以是string,int32、enum或者复合类型。
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
消息类型中的每个字段都需要定义唯一的编号,该编号会用来识别二进制数据中字段。编号在[1,15]范围内可以用一个字节编码表示。在[16,2047]范围可以用两个字节编码表示。所以将15以内的编号留给频繁出现的字段可以节省空间。编号的最小值为1,最大值为2^29-1=536870911. 不能使用[19000,19999]范围内的数字,因为该范围内的数字被proto编译器内部使用。同理,其他预先已经被保留的数字也不能使用。
每个字段可以被singular或者repeated修饰。在proto3语法中,如果不指定修饰类型,默认值为singular. singular: 表示被修饰的字段最多出现1次,即出现0次或1次。repeated: 表示被修饰的字段可以出现任意次,包括0次。在proto3语法中,repeated修饰的字段默认采用packed编码
可以给.proto文件添加注释,注释语法与C/C++风格相同,使用//或者/* ... */。
/* SearchRequest represents a search query, with pagination options to
* indicate which results to include in the response. */
message SearchRequest {
string query = 1;
int32 page_number = 2; // Which page number do we want?
int32 result_per_page = 3; // Number of results to return per page.
}
当删掉或者注释掉message中的一个字段时,将来其他开发人员在更新message定义时可以重用之前的字段编号。如果他们意外载入了旧版本的.proto文件将会导致严重的问题,例如数据损坏。一种避免问题产生的方式是指定保留的字段编号和字段名称。如果将来有人用了这些字段编号将在编译proto的时候产生错误,显示提醒proto有问题。NOTE,不要对同一个字段混合使用字段名称和字段编号。
message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}
定义好的.proto文件可以通过生成器产生Go语言代码,a.proto文件生产的go文件为a.pb.go文件。proto中基本类型与Go语言类型映射如下表。这里只列举了与Go和C/C++之间类型的映射,其他语言参考https://developers.google.com/protocol-buffers/docs/proto3
.proto Type | Go Type | C++ Type |
|---|---|---|
double | float64 | double |
float | float32 | float |
int32 | int32 | int32 |
int64 | int64 | int64 |
uint32 | uint32 | uint32 |
uint64 | uint64 | uint64 |
sint32 | int32 | int32 |
sint64 | int64 | int64 |
fixed32 | uint32 | uint32 |
fixed64 | uint64 | uint64 |
sfixed32 | int32 | int32 |
sfixed64 | int64 | int64 |
bool | bool | bool |
string | string | string |
bytes | []byte | string |
.proto Type | default value |
|---|---|
string | "" |
bytes | []byte |
bool | false |
numeric types | 0 |
enums | first defined enum value |
在定义消息的时候,希望字段的值只能是预期某些值中的一个。例如,现在为SearchRequest添加corpus字段,它的值只能是UNIVERSAL、WEB、IMAGES、LOCAL、NEWS、PRODUCTS和VIDEO中的一个。可以非常简单的通过向消息定义中添加枚举,并为每个可能的枚举值添加常量来实现。
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
enum Corpus {
UNIVERSAL = 0;
WEB = 1;
IMAGES = 2;
LOCAL = 3;
NEWS = 4;
PRODUCTS = 5;
VIDEO = 6;
}
Corpus corpus = 4;
}
Corpus枚举的第一个常量必须映射到0,所有枚举定义都需要包含一个常量映射到0,并且该值为枚举定义的第一行内容。因为这样我们可以将0作为枚举的默认值,proto2语法中首行的枚举值总是默认值,为了兼容0值必须作为定义的首行。
在一个.proto文件中可以导入其他.proto文件,这样就可以使用它导入的.proto中定义的消息类型了。
import "myproject/other_protos.proto";
默认情况下,只能使用直接导入的.proto文件中定义的消息。但是,有时候可能需要将.proto文件移动到新位置,有一种巧妙的做法是在旧位置放一个虚拟的.proto文件。在文件中使用import public语法将所有导入转发到新位置,而不是直接移动.proto文件并在一次更改中更新所有调用点。任何导入包含import public语句中的proto文件的地方都可以传递依赖导入的公共依赖项。下面同一个例子来理解这里的内容。
在当前的文件夹下有a.proto和b.proto文件,现在在a.proto文件中import了b.proto文件。即在a.proto文件中有下面的内容
import "b.proto";
假设现在b.proto中的消息要放入到一个common/com.proto文件中,可以方便其他地方也使用,这时可以修改b.proto在里面import com.proto即可.注意要「import public」, 因为单独的import只能使用b.proto中定义的消息,并不能使用b.proto中import的proto文件中的消息类型。
// b.proto文件, 将里面的消息定义移动了common/com.proto文件,
// 在里面添加下面的import语句
import public "common/com.proto"
在使用protoc编译时,需要使用选项-I或--proto_path通知protoc去什么地方查找import的文件,如果不指定搜索路径,protoc将会在当前目录下(调用protoc的路径)下查找。
可以导入proto2版本中的消息类型到proto3文件中使用,也可以在proto2文件中导入proto3版本的消息类型。但是在proto2的枚举类型不能直接应用到proto3的语法中。
消息类型可以定义在消息类型的内部,即嵌套定义,里面下面的Result类型定义在SearchResponse的内部。不单单是一层嵌套,也可以多层嵌套。
message SearchResponse {
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
repeated Result results = 1;
}
外面的消息类型使用其他消息内部的消息,下面的SomeOtherMessage类型使用到了Result,可以使用SearchResponse.Result。
message SomeOtherMessage {
SearchResponse.Result result = 1;
}
未知字段是proto编译器无法识别的字段,例如当旧二进制文件解析具有新字段的新二进制文件发送的数据时,这些新字段将成为旧二进制文件中的未知的字段。在初版的proto3中消息解析时会丢掉未知的字段,但在3.5版本时,重新引入了未知字段的保留,未知字段在解析期间会保留,并包含在序列化输出中。
protobuf高效的秘密在于它的编码格式,它采用了TLV(tag-length-value)编码格式。每个字段都有唯一的tag值,它是字段的唯一标识。length表示value数据的长度,length不是必须的,对于固定长度的value,是没有length的。value是数据本身的内容。

对于tag值,它有field_number和wire_type两部分组成。field_number就是在前面的message中我们给每个字段的编号,wire_type表示类型,是固定长度还是变长的。wire_type当前有0到5一共6个值,所以用3个bit就可以表示这6个值。tag结构如下图。

wire_type值如下表, 其中3和4已经废弃,我们只需要关心剩下的4种。对于Varint编码数据,不需要存储字节长度length.这种情况下,TLV编码格式退化成TV编码。对于64-bit和32-bit也不需要length,因为type值已经表明了长度是8字节还是4字节。

Varint顾名思义就可变的int,是一种变长的编码方式。值越小的数字,使用越少的字节表示,通过减少表示数字的字节数从而进行数据压缩。对于int32类型的数字,一般需要4个字节表示,但是采用Varint编码,对于小于128的int32类型的数字,用1个字节来表示。对于很大的数字可能需要5个字节来表示,但是在大多数情况下,消息中一般不会有很大的数字,所以采用Varint编码可以用更少的字节数来表示数字。Varint是变长编码,那它是怎么区分出各个字段的呢?也就是怎么识别出这个数字是1个字节还是2个字节,Varint通过每个字节的最高位来识别,如果字节的最高位是1,表示后续的字节也是该数字的一部分,如果是0,表示这是最后一个字节,且剩余7位都用来表示数字。虽然这样每个字节会浪费掉1bit空间,也就是1/8=12.5%的浪费,但是如果有很多数字不用固定的4字节,还是能节省不少空间。
下面通过一个例子来详细学习编码方法,现在有一个int32类型的数字65,它的Varint编码过程如下,可以看到占用4字节的65编码后只占用1个字节。

int32类型的数字128编码过程如下,4字节的128编码后只占用2个字节。

对于Varint解码是上面过程的一个逆过程,也比较简单,这里就不在举例说明了。
我们知道,负数的符号位为数字的最高位,它的最高位是1,所以对于负数用Varint编码一定为占用5个字节。这是不划算的,明明是4字节可以搞定的,现在统统都需要5个字节。所以protobuf定义了sint32和sint64类型来表示负数,先采用Zigzag编码,将有符号的数转成无符号的数,在采用Varint编码,从而减少编码后字节数。
Zigzag采用无符号数来表示有符号数,使得绝对值小的数字可以采用比较少的字节来表示。在理解Zigzag编码之前,我们先来看几个概念。
原码:最高位为符号位,剩余位表示绝对值 反码:除符号位外,对原码剩余位依次取反 补码:对于正数,补码为其本身,对于负数,除符号位外对原码剩余位依次取反然后+1
下面以int32类型的数-2为例,分析它的编码过程。如下图所示。

总结起来,对于负数对其补码做运算操作,对于数n,如果是sint32类型,则执行(n<<1)^(n>>31)操作,如果是sint64则执行(n<<1)^(n>>63), 通过前面的操作将一个负数变成了正数。这个过程就是Zigzag编码,最后在采用Varint编码。
因为Varint和Zigzag编码可以自解析内容的长度,所以可以省略长度项。TLV存储简化为了TV存储,不需length项。

前面讲解了每个字段有tag和value构成,对于string类型,还有length字段。下面来看tag和value值的计算方法。
tag中存储了字段的标识信息和数据类型信息,也就是说tag=wire_type(字段数据类型)+field_number(标识号)。通过tag可以获取它的字段编号,对应上定义的消息字段。计算公式为tag=field_number<<3 | wire_type, 然后在对其采用Varint编码。
value是采用Varint和Zigzag编码后的消息字段的值。下面是各个wire_type对应的存储类型一个总结。

wire_type | 编码方法 | 编码长度 | 存储方式 | 数据类型 |
|---|---|---|---|---|
0 | Varint | 变长 | T-V | int32 int64 uint32 uint64 bool enum |
0 | Zigzag+Varint | 变长 | T-V | sint32 sint64 |
1 | 64-bit | 固定8字节 | T-V | fixed64 sfixed64 double |
2 | length-delimi | 变长 | T-L-V | string bytes packed repeated fields embedded |
3 | start group | 已废弃 | 已废弃 | |
4 | end group | 已废弃 | 已废弃 | |
5 | 32-bit | 固定4字节 | T-V | fixed32 sfixed32 float |
字段类型为string类型,字段值采用UTF-8编码,下面是一个字符串编码的示例,字段序列号为1,编码的字符串内容是“China中国人”, proto编码之后的内容见下面的输出。
message stringEncodeTest {
string test = 1;
}
func stringEncodeTest(){
vs:=&api.StringEncodeTest{
Test:"China中国人",
}
data,err:=proto.Marshal(vs)
if err!=nil{
fmt.Println(err)
return
}
fmt.Printf("%v\n",data)
}
编码之后的二进制内容如下,第一个字节内容tag值,第二个字节内容14是length,表示后面的字符串有14个字节。为啥是14个字节呢?“China中国人”不是8个字节吗?因为字符串采用的是UTF-8编码,每个中文字用3个字节编码,所以"中国人"编码之后占9个字节,在加上前面的China,一共是14个字节。
[10 14 67 104 105 110 97 228 184 173 229 155 189 228 186 186]


嵌套消息就是value又是一个字段消息,外层消息存储采用TLV存储,它的value又是一个TLV存储。整个编码结构如下图所示。

repeaded修饰的字段可以带packed或者不带。对于同一个repeated字段,多个字段值来说,它们的tag都是相同的,即数据类型和字段序号都相同。如果采用多个TV存储,则存在tag的冗余。如果设置packed=true的repeated字段存储方式,即相同的tag只存储一次,添加repeated字段下所有值的长度length,构成TLVVV...存储结构,可以压缩序列化后数据长度,节省传输开销。
message repeatedEncodeTest{
// 方式1,不带packed
repeated int32 cat = 1;
// 方式2,带packed
repeated int32 dog = 2 [packed=true];
}
Protocol Buffers[1]
[1]
Protocol Buffers: https://developers.google.com/protocol-buffers/