1. 转换格式

.proto Type Notes C++ Type Java Type Python Type[2] Go Type
double double double float *float64
float float float float *float32
int32 使用可变长度编码。编码负数的效率低
- 如果你的字段可能有负值,请改用 sint32
int32 int int *int32
int64 使用可变长度编码。编码负数的效率低
- 如果你的字段可能有负值,请改用 sint64
int64 long int/long[3] *int64
uint32 使用可变长度编码 uint32 int[1] int/long[3] *uint32
uint64 使用可变长度编码 uint64 long[1] int/long[3] *uint64
sint32 使用可变长度编码。有符号的 int 值。这些比常规 int32 对负数能更有效地编码 int32 int int *int32
sint64 使用可变长度编码。有符号的 int 值。这些比常规 int64 对负数能更有效地编码 int64 long int/long[3] *int64
fixed32 总是四个字节。如果值通常大于 228,则比 uint32 更有效。 uint32 int[1] int/long[3] *uint32
fixed64 总是八个字节。如果值通常大于 256,则比 uint64 更有效。 uint64 long[1] int/long[3] *uint64
sfixed32 总是四个字节 int32 int int *int32
sfixed64 总是八个字节 int64 long int/long[3] *int64
bool bool boolean bool *bool
string 字符串必须始终包含 UTF-8 编码或 7 位 ASCII 文本 string String str/unicode[4] *string
bytes 可以包含任意字节序列 string ByteString str []byte

2. 原理分析

Protobuf采用了T-L-V的存储格式存储数据,其中的T代表tag,即key;L则是length,代表当前存储的类型的数据长度,当是数值类型的时候L被忽略;V代表value,即存入的值。
protobuf会将每一个key根据不同的类型对应的序列化算法进行序列化,然后按照keyvaluekeyvalue的格式存储,其中key的type类型与对应的压缩算法关系如下:

wire_type 编码方式 type 存储方式
0 Varint(负数使用Zigzag辅助) int32、int64、uint32、uint64、sint32、sint64、bool、enum T-V
1 64-bit fixed、sfixed64、double T-V
2 Length-delimi string、bytes、embedded、messages、packed repeated fields T-L-V
3(弃用) Start group Groups(deprecated) 弃用
4(弃用) End group Groups(deprecated) 弃用
5 32-bit fixed32、sfixed32、float T-V

需要注意的是protobuf的key计算按照 (field_number << 3) | wire_type 方式计算,而这里的field_number是指定义的时候该字段的域号,如:required string name=1;这里的name字段的域号为1,在protobuf中规定:

  • 如果域号在[1,15]范围内,会使用一个字节表示Key;
  • 如果域号大于等于16,会使用两个字节表示Key;

key编码完成后,该字节的第一个比特位表示后一个字节是否与当前字节有关系,即:

  • 如果第一个比特位为1,表示有关,即连续两个字节都是Key的编码;
  • 如果第一个比特位为0,表示Key的编码只有当前一个字节,后面的字节是Length或者Value;

    注意:protobuf中的域号定义要小于2048 ,原因为,最大的域号即2个字节16个比特位表示key,去掉位移的三位,还剩下13位,再去掉两个字节开头的第一个用来表示是否存在关系的比特位,即16-3-2=11,最后只有11位参与计算,二进制计算后2^11== 2048 ,所以域号不得超过2048

2.1 varint编码

protoc-c序列化 - 图1

protoc-c序列化 - 图2

而我们都知道计算机中,高位为1代表负数,计算机中对负数的计算为先将结果取反后,再去补码操作,
而负数的补码则是在反码的基础上+1,那么我们现在将结果反过来,先去-1,得到反码,则为10101011,
再去取反,得到原码,则为01010100,
现在我们将这个值转换为十进制,则可以知道结果为84,由于高位为1,则代表是负数,最终结果为-84,
而00000010由于高位是0,代表本身为正数,正数的原码反码补码都是自身,所以直接转换为十进制结果为2,
现在我们把这两个结果和上述打印的结果比较一下,是不是发现是一样的?
当然,我们也从这个过程中发现了一些问题,比如小于128的值,我们甚至只需要1个字节就能存储完毕,
但是如果我们需要存储的值很大,超过了268435455以后的数值,甚至需要五个字节来存储(超过28个有效比特位),
但是绝大多数情况下,我们都不会使用这么大的数值,
所以一般情况下,我们都能比之前使用更小的字节存储,达到压缩的目的。

2.2 字符串压缩

protoc-c序列化 - 图3

  • 负数存储- write_type为0

在计算机中,负数会被表示为很大的整数,因为计算机定义负数符号位为数字的最高位,
所以如果采用 varint 编码表示一个负数,那么一定需要 5 个比特位。
在 protobuf 中通过sint32/sint64 类型来表示负数,负数的处理形式是先采用 zigzag 编码
( 把符号数转化为无符号数 ),再采用 varint 编码。
sint32:(n << 1) ^ (n >> 31)
sint64:(n << 1) ^ (n >> 63)

比如存储一个(-300)的值
-300
原码:0001 0010 1100
取反:1110 1101 0011
加 1 :1110 1101 0100
n<<1: 整体左移一位,右边补 0 -> 1101 1010 1000
n>>31: 整体右移 31 位,左边补 1 -> 1111 1111 1111
n<<1 ^ n >>31
1101 1010 1000 ^ 1111 1111 1111 = 0010 0101 0111
十进制: 0010 0101 0111 = 599
然后再使用varint 算法得到两个字节
1101 0111(-41),0000 0100(4)

3. 总结

基于Protobuf序列化原理分析,为了有效降低序列化后数据量的大小,可以采用以下措施:

  1. 字段标识号(Field_Number)尽量只使用1-15,且不要跳动使用 Tag是需要占字节空间的。如果Field_Number>16时,Field_Number的编码就会占用2个字节,那么Tag在编码时就会占用更多的字节;如果将字段标识号定义为连续递增的数值,将获得更好的编码和解码性能
  2. 若需要使用的字段值出现负数,请使用sint32/sint64,不要使用int32/int64。 采用sint32/sint64数据类型表示负数时,会先采用Zigzag编码再采用Varint编码,从而更加有效压缩数据
  3. 对于repeated字段,尽量增加packed=true修饰 增加packed=true修饰,repeated字段会采用连续数据存储方式,即T - L - V - V -V方式

    4. 参考文档