Protobuf深入理解

本文带你深入理解和使用protobuf

简介

Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。目前提供了 C++、Java、Python、Go 等语言的 API

使用protobuf需要这样几个步骤:

  • .proto文件中定义消息的格式
  • 通过protoBuffer compiler编译生成相应的java类
  • 通过Java protocol buffer Api来write和read相关的对象

关于PB的操作方式见: https://developers.google.com/protocol-buffers/docs/javatutorial


protobuf的序列化快的原因主要在于其编码实现和封解包的速度

Protobuf编码

Base 128 Varints 编码

数据传输中出于IO的考虑,我们会希望尽可能的对数据进行压缩。
Varint就是一种对数字进行编码的方法,编码后二进制数据是不定长的,数值越小的数字使用的字节数越少。例如对于int32_t,采用Varint编码后需要1~5个bytes,小的数字使用1个byte,大的数字使用5个bytes。基于实际场景中小数字的使用远远多于大数字,因此通过Varint编码对于大部分场景都可以起到一个压缩的效果。Varint的主要想法就是以标志位替换掉高字节的若干个0

下图是数字131415的variant编码,通过3个字节来表示131415

其中第一个字节的高位msb(Most Significant Bit )为1表示下一个字节还有有效数据,msb为0表示该字节中的后7为是最后一组有效数字。踢掉最高位后的有效位组成真正的数字。注意到最终计算前将两个 byte 的位置相互交换过一次,这是因为 Google Protocol Buffer 字节序采用 little-endian(即低位字节排放在内存的低地址端) 的方式

从上面可以看出,variant编码存储比较小的整数时很节省空间,小于等于127的数字可以用一个字节存储。但缺点是对于大于

268,435,455(0xfffffff)的整数需要5个字节来存储。但是一般情况下(尤其在tag编码中)不会存储这么大的整数。

关于int32的varint编码代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
char* EncodeVarint32(char* dst, uint32_t v) {
// Operate on characters as unsigneds
unsigned char* ptr = reinterpret_cast<unsigned char*>(dst);
static const int B = 128;
if (v < (1<<7)) {
*(ptr++) = v;
} else if (v < (1<<14)) {
*(ptr++) = v | B;
*(ptr++) = v>>7;
} else if (v < (1<<21)) {
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = v>>14;
} else if (v < (1<<28)) {
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = (v>>14) | B;
*(ptr++) = v>>21;
} else {
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = (v>>14) | B;
*(ptr++) = (v>>21) | B;
*(ptr++) = v>>28;
}
return reinterpret_cast<char*>(ptr);
}

Message Structure编码

protocol buffer 中 message 是一系列键值对。message 的二进制版本只是使用字段号(field’s number 和 wire_type)作为 key。每个字段的名称和声明类型只能在解码端通过引用消息类型的定义(即 .proto 文件)来确定。这一点也是人们常常说的 protocol buffer 比 JSON,XML 安全一点的原因,如果没有数据结构描述 .proto 文件,拿到数据以后是无法解释成正常的数据的。

当消息编码时,键和值被连接成一个字节流。当消息被解码时,解析器需要能够跳过它无法识别的字段。这样,可以将新字段添加到消息中,而不会破坏不知道它们的旧程序。这就是所谓的 “向后”兼容性。

为此,线性的格式消息中每对的“key”实际上是两个值,其中一个是来自.proto文件的字段编号,加上提供正好足够的信息来查找下一个值的长度。在大多数语言实现中,这个 key 被称为 tag

wireType

key 的计算方法是 (field_number << 3) | wire_type,换句话说,key 的最后 3 位表示的就是 wire_type。因此这里也涉及到前面proto文件定义的时候的宗旨,尽量将频繁使用的字段的字段号设置成1-15之间的数值,避免位数开销。这里的key的存储也是用了varint的方式

举例,一般 message 的字段号都是 1 开始的,所以对应的 tag 可能是这样的:

000 1000

末尾3位表示的是value的类型,这里是000,即0,代表的是varint值。右移3位,即0001,这代表的就是字段号(field number)。tag的例子就举这么多,接下来举一个 value的例子,还是用varint来举例:

1
2
3
4
96 01 = 1001 0110  0000 0001
→ 000 0001 ++ 001 0110 (drop the msb and reverse the groups of 7 bits)
→ 10010110
→ 128 + 16 + 4 + 2 = 150

所以 96 01 代表的数据就是 150 。

1
2
3
message Test1 {
required int32 a = 1;
}

如果存在上面这样的一个 message 的结构,如果存入 150,在 Protocol Buffer 中显示的二进制应该为 08 96 01 varint(1 << 3 | 0) = 0x08.

注意到varint编码也应用在了key的计算上,使用非常频繁,或许是基于这个原因,pb里实现了一种性能更高的方案(coded_stream.cc)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
inline uint8* CodedOutputStream::WriteVarint32FallbackToArrayInline(
uint32 value, uint8* target) {
target[0] = static_cast<uint8>(value | 0x80);
if (value >= (1 << 7)) {
target[1] = static_cast<uint8>((value >> 7) | 0x80);
if (value >= (1 << 14)) {
target[2] = static_cast<uint8>((value >> 14) | 0x80);
if (value >= (1 << 21)) {
target[3] = static_cast<uint8>((value >> 21) | 0x80);
if (value >= (1 << 28)) {
target[4] = static_cast<uint8>(value >> 28);
return target + 5;
} else {
target[3] &= 0x7F;
return target + 4;
}
} else {
target[2] &= 0x7F;
return target + 3;
}
} else {
target[1] &= 0x7F;
return target + 2;
}
} else {
target[0] &= 0x7F;
return target + 1;
}
}

测试了1kw条数据,两种方案的时间对比为 196742us vs 269806us,在pb序列化反序列化大量使用varint的前提下,这个性能提升就很有必要了(这是原作者做的测试)

type 需要注意的是 type = 2 的情况,tag 里面除了包含 field number 和 wire_type ,还需要再包含一个 length,决定 value 从那一段取出来

负数使用varint编码的问题

varint编码希望以标志位能够节省掉高字节的0,但是负数的最高位一定是1, 所以varint在处理32位负数时会固定的占用5个字节。比如我们修改下之前的程序test.set_a(-1),序列化之后的数据为

1
08ff ffff ffff ffff ffff 01

有11个字节之多!除了key=0x08占用的1个字节,value=-1占用了10个字节。

对应的代码(coded_stream.h)

1
2
3
4
5
6
7
inline void CodedOutputStream::WriteVarint32SignExtended(int32 value) {
if (value < 0) {
WriteVarint64(static_cast<uint64>(value));
} else {
WriteVarint32(static_cast<uint32>(value));
}
}

int32被转换成了uint64(为什么?)原作者这里问为什么== 原因在文档中有提及

If you use int32 or int64 as the type for a negative number, the resulting varint is always ten bytes long – it is, effectively, treated like a very large unsigned integer,即uint64,也就是上面代码写的那样。但是为什么生成是10个字节呢? 因为uint64是10个?

再经过varint编码。这就是10个字节的原因了。当然如果你使用了signed types那么产出的varint编码结果使用了Zigzag编码就会相当的高效。

Zigzag编码

ZigZag是将有符号数统一映射到无符号数的一种编码方案,对于无符号数0 1 2 3 4,映射前的有符号数分别为0 -1 1 -2 2,负数以及对应的正数来回映射到从0变大的数字序列里,这也是”zig-zag”的名字来源。将所有整数映射成无符号整数,然后再采用 varint 编码方式编码,这样,绝对值小的整数,编码后也会有一个较小的 varint 编码值。

Zigzag 映射函数为:

1
2
Zigzag(n) = (n << 1) ^ (n >> 31), n 为 sint32 时
Zigzag(n) = (n << 1) ^ (n >> 63), n 为 sint64 时

按照这种方法,-1 将会被编码成 1,1 将会被编码成 2,-2 会被编码成 3,如下表所示:

存疑?

目前仍有一个地方不大清楚,就是对于int32类型的负数,protobuf强制编码成10个字节,理论上5个字节就够了。 (来自别人的问题,我也没懂,确实想了下int32的负数5个就够了,int64的负数才需要10个?)

负数及大整数的解决方案

protobuf里提供了一种sint32/sint64来使用ZigZag编码。

修改proto:optional sint32 a = 1,这样在test.set_a(-1)并序列化后只有两个字节08 01

同理对于大整数,optional int32 a = 1;,test.set_a(1 << 28)序列化后可以看到占用了6个字节0880 8080 8001,解决方案也是使用不同的类型定义optional fixed32 a = 1来解决,使用这种方案后int32固定的占用4个字节。这种其实就是官网中的Non-varint Numbers

字符串

wire_type 类型为 2 的数据,是一种指定长度的编码方式:key + length + content,key 的编码方式是统一的,length 采用 varints 编码方式,content 就是由 length 指定长度的 Bytes

举例,假设定义如下的 message 格式:

1
2
3
message Test2 {
optional string b = 2;
}

设置该值为”testing”,二进制格式查看:12 07 74 65 73 74 69 6e 67, 74 65 73 74 69 6e 67 是“testing”的 UTF8 代码。

12 -> 0001 0010,后三位 010 为 wire type = 2,0001 0010 右移三位为 0000 0010,即 tag = 2。

length 此处为 7,后边跟着 7 个bytes,即我们的字符串”testing”。

所以 wire_type 类型为 2 的数据,编码的时候会默认转换为 T-L-V (Tag - Length - Value)的形式. TLV的模式减少了分隔符的使用,数据存储更加紧凑。需要转变为 T - L - V 形式的还有 string, bytes, embedded messages, packed repeated fields。

小结

  • 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 诞生之初被寄予解决的问题。因为编译器对不识别的新增字段会跳过不处理

Protobuf反序列化

https://halfrost.com/protobuf_encode/

整个解析过程需要 Protobuf 本身的框架代码和由 Protobuf 编译器生成的代码共同完成。Protobuf 提供了基类 Message 以及 Message_lite 作为通用的 Framework,,CodedInputStream 类,WireFormatLite 类等提供了对二进制数据的 decode 功能,从 5.1 节的分析来看,Protobuf 的解码可以通过几个简单的数学运算完成,无需复杂的词法语法分析,因此 ReadTag() 等方法都非常快。 在这个调用路径上的其他类和方法都非常简单,感兴趣的读者可以自行阅读。 相对于 XML 的解析过程,以上的流程图实在是非常简单吧?这也就是 Protobuf 效率高的第二个原因了

与json thift的性能比较

Protobuf有没有比JSON快5倍?用代码来击破pb性能神话

总结

protobuf的性能来源于对存储的压缩,避免一切不必要的字节开销。flink中如过将大多数需要存储到state中的对象先转成PB格式会得到很大的性能提升。

和protobuf通常被同时提及的有Apache Thrift / Avro 本文已经没有空间介绍,待后续深入了解。

这篇文章介绍了3中数据结构如何做到对消息体格式演变的透明
https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html

参考:

https://developers.google.com/protocol-buffers/docs/javatutorial
https://developers.google.com/protocol-buffers/docs/encoding#structure
https://halfrost.com/protobuf_encode/
https://izualzhy.cn/protobuf-encode-varint-and-zigzag
https://izualzhy.cn/protobuf-encoding
https://www.ibm.com/developerworks/cn/linux/l-cn-gpb/index.html
https://segmentfault.com/a/1190000004891020 protobufstuff

谢谢支持