随着微服务的兴起,服务之间的远程调用越来越频繁,也受到更大的关注。服务之间的调用离不开数据的序列化,protocol buffers(后面简称 pb)就是一个广受欢迎的序列化数据方法。相比传统的序列化手段(json, xml 等),pb 拥有更快的编解码速度,序列化后的数据更小。这对于大流量的系统而言,明显比 json 等序列化方法更有吸引力。但是你知道 pb 是如何做到更快更小的吗?如何正确使用 pb 才能让编码后的数据更小呢?
本文将探索 pb 是如何编码数据的,从而明确哪些设置会影响到编码,最终帮助我们更加合理的使用 pb,充分发挥 pb 的特性,从而得到更小的字节数据。
官方文档给的定义如下
protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。
Protocol Buffers 是一种灵活,高效,自动化机制的结构数据序列化方法-可类比 XML,但是比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单。
更详细的内容可以阅读 Protocol Buffers Documentation。
本文不对如何使用 pb 做介绍,这里指介绍下和编码紧密相关的 proto 文件。proto 文件定义了数据的 schema,编解码的依据就是这个 schema. 一个典型的 proto 文件示例如下
syntax = "proto3";
package helloworld;
// HelloRequest 定义了数据结构
message HelloRequest {
string name = 1;
int64 integer_1 = 2;
repeated int64 integer_list = 3[packed=true]; // repeated 表示可重复,即定义了一个数组,packed=true 表示可以进行压缩,具体含义稍后会介绍
sint64 integer2 = 4;
map maps = 5; // 定义了一个 map
}
从示例可知,一个 schema 的定义(比如 HelloRequest 的定义),包含了数据类型,字段名称,以及字段编号 (field number)。
总体而言,pb 序列化后的字节数据,一个字段对应一个 key-value 对,整个二进制文件就是一连串紧密排列的 key-value 对,key也称为tag。采用这种key-value对的结构无需使用分隔符来分割不同的 field。
key 是由 wire_type 和 field_number 两部分编码而成,具体地说
key = (field_number << 3) | wire_type
wire_type 可以理解成数据类型的种类,类似 golang 中的 reflect.Kind。pb 的 wire_type 和数据类型的映射见下表
Type |
Meaning |
Used For |
0 |
Varint |
int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 |
64-bit |
fixed64, sfixed64, double |
2 |
Length-delimi |
string, bytes, embedded messages, packed repeated fields |
3 |
Start group |
Groups (deprecated) |
4 |
End group |
Groups (deprecated) |
5 |
32-bit |
fixed32, sfixed32, float |
对于 string 等变长的数据类型而言(即 wire_type = 2),单单只有 key-value 是不够的,还需要一个地方能指明数据的长度。因而,pb 使用 key-length-value 结构去编码这些变长类型,length 最多使用 4 个字节。
举个例子,对于第 3 节给的示例 proto 而言,name 字段编码后的 key 为
0x0a = (1 << 3 | 2)
varint 是一种可变长编码,使用 1 个或多个字节对整数进行编码,可编码任意大的整数,小整数占用的字节少,大整数占用的字节多,如果小整数更频繁出现,则通过 varint 可实现压缩存储。
varint 中每个字节的最高位 bit 称之为 most significant bit (MSB),如果该 bit 为 0 意味着这个字节为表示当前整数的最后一个字节,如果为 1 则表示后面还有至少 1 个字节。可见,varint 的终止位置其实是自解释的。
也就是说,每个字节的最高位表示后面还有没有字节,若为 0 表示后面没有字节,若为 1 表示后面有字节。而每个字节的低 7 位就是实际的值,并且使用小端的表示方法。
举个例子
1 的 varint 表示:0x01
300 (0x12c) 的 varint 表示:0xac02
-1 的 varint 表示 0xffffffffffffffffff
从例子中可以看出,对于正整数而言,通过 varint 编码可以减少占用的字节数,但是对于负数,即使是 -1,其也需要占用 10 个字节,明显使用了更多的字节数。这是因为,对于负数而言,其最高位为 1 表示负数,varint 编码无法进行压缩,而且由于 varint 每个字节的最高位是 MSB,每个字节可用的只有 7 位,因而反而用了更多的字节去表示负数。
为了解决这个问题,就引入了 zigzag 编码。zigzag 编码的理念很简单,就是将负数和正数都映射到正数,其对应关系如下表所示
原始数字 |
zigzag 编码后 |
0 |
0 |
-1 |
1 |
1 |
2 |
-2 |
3 |
2147483647 |
4294967294 |
-2147483648 |
4294967295 |
因此,对于负数,pb 就可以先进行 zigzag 编码,然后再进行 varint 编码。更准确点说,pb 是依据数据类型来确定是否要先进行 zigzag 编码,对于 int64, int32 类型而言,pb 会直接用 varint 编码,对于 sint32, sint64 类型而言,会先进行 zigzag 编码,然后进行 varint 编码
这个是用于编码变长字段,因此完整字段的编码格式为 key-length-value, pb 不会对 value 进行压缩。
这个类型的编码格式依然使用 key-value,只不过 value 会固定占用 8 个字节或者 4 个字节。float 类型的就是采用 32-bit 进行编码,而 double 类型则使用 64-bit 进行编码。
此外,Varints 编码在一定范围内是有高效的,超过某一个数字占用字节反而更多,效率更低。如果现在有场景是存在大量的大数字,那么使用 Varints 就不太合适了,此时使用 64-bit 和 32-bit 更为合适。具体的,如果数值比 256 大的话,64-bit 这个类型比 uint64 高效,如果数值比 228 大的话,32-bit 这个类型比 uint32 高效。
packed
pb 还支持对数组类型的数据做进一步的压缩,前提是这个数组的每个元素都是固定长度的,如 int32, int64 等。
在不进行 packed 压缩时,字段编码后的格式为
......
使用 packed 压缩后的格式为
......
注意:使用 packed 后,编码类型就变成了 Length-delimi,另外,在 pb3 中,packed 默认为 true
proto 文件
syntax = "proto3";
package helloworld;
// HelloRequest 定义了数据结构
message HelloRequest {
string name = 1;
int64 integer_1 = 2;
repeated int64 integer_list = 3[packed=true]; // repeated 表示可重复,即定义了一个数组,packed=true 表示可以进行压缩,具体含义稍后会介绍
sint64 integer2 = 4;
map maps = 5; // 定义了一个 map
}
代码
package main
import (
"fmt"
pb "google.golang.org/grpc/examples/helloworld/helloworld"
pbProto "google.golang.org/protobuf/proto"
)
const maxVarintBytes = 10 // maximum length of a varint
func main() {
name := "jason"
message := &pb.HelloRequest{
Name: name,
Integer_1: 1,
IntegerList: []int64{-1, 1},
Integer2: -1,
Maps: map[string]int64{
name: 1,
},
}
b, err := pbProto.Marshal(message)
if err != nil {
fmt.Printf("error: %+v\n", err)
return
}
fmt.Printf("name: %x\n", []byte(name))
fmt.Printf("result: %x\n", b)
}
func zigzag(n int64) int64 {
return (n << 1) ^ (n >> 63)
}
// 返回Varint类型编码后的字节流
func EncodeVarint(x uint64) []byte {
var buf [maxVarintBytes]byte
var n int
// 下面的编码规则需要详细理解:
// 1.每个字节的最高位是保留位, 如果是1说明后面的字节还是属于当前数据的,如果是0,那么这是当前数据的最后一个字节数据
// 看下面代码,因为一个字节最高位是保留位,那么这个字节中只有下面7bits可以保存数据
// 所以,如果x>127,那么说明这个数据还需大于一个字节保存,所以当前字节最高位是1,看下面的buf[n] = 0x80 | ...
// 0x80说明将这个字节最高位置为1, 后面的x&0x7F是取得x的低7位数据, 那么0x80 | uint8(x&0x7F)整体的意思就是
// 这个字节最高位是1表示这不是最后一个字节,后面7为是正式数据! 注意操作下一个字节之前需要将x>>=7
// 2.看如果x<=127那么说明x现在使用7bits可以表示了,那么最高位没有必要是1,直接是0就ok!所以最后直接是buf[n] = uint8(x)
//
// 如果数据大于一个字节(127是一个字节最大数据), 那么继续, 即: 需要在最高位加上1
for n = 0; x > 127; n++ {
// x&0x7F表示取出下7bit数据, 0x80表示在最高位加上1
buf[n] = 0x80 | uint8(x&0x7F)
// 右移7位, 继续后面的数据处理
x >>= 7
}
// 最后一个字节数据
buf[n] = uint8(x)
n++
return buf[0:n]
}
输出的字节流如下(16进制表示)
0a056a61736f6e10011a0bffffffffffffffffff010120012a090a056a61736f6e1001
进行拆解后为
0a // 第一个字段,即 name 字段的 key
05 // value 的长度
6a61736f6e // value, 即 "jason"
10 // 第二个字段,即 Integer_1 的 key
01 // value, 即 1
1a // 第三个字段,即 IntegerList 的 key
0b // 由于 packed = true, 这个字节表示 value 的长度为 11 个字节
ffffffffffffffffff01 // 第一个元素,即 -1
01 // 第二个元素,即 1
20 // 第 4 个字段,Integer2 的 key
01 // 由于 第 4 个字段时 sint64,因而会先进行 zigzag 编码,然后进行 varint 编码
2a // 第 5 个字段的 key,它是个 map 类型
09 // value 的长度,表示9个字节
0a056a61736f6e // 表示 jason
1001 // 表示 1
proto 中的字段名称对于实际的编码毫无作用,字段名称只是方便我们的开发而已
字段编号很重要,若想保持协议的兼容性,字段编号应该慎重修改
pb 之所以能做到更小,是因为它是紧凑的编码,将 json 或者 xml 中的冗余信息都丢弃了
pb 对 integer 有两种编码方式,若字段取值为正,建议采用 int64 等数据类型,若存在负数,建议采用 sint64 等数据类型。
若是枚举值,有两个建议:
尽量不要取负数作为枚举值,枚举值也应该尽可能的小
不要将字符串作为枚举值,相比整形,字符串明显占用更多的空间
pb 是紧凑型的编码,这也就意味着 pb 牺牲了可读性。在选择序列化方式时,应当在可读性和效率之间做个权衡,我觉得主要有几个问题需要想清楚
可读性是否很重要。
pb 节省下来的序列化时间和空间是否有很大的价值。比如在一个流量很小的系统里,采用 pb 还是采用 json,可能并不重要。