protobuf 编码探究

1. 背景

随着微服务的兴起,服务之间的远程调用越来越频繁,也受到更大的关注。服务之间的调用离不开数据的序列化,protocol buffers(后面简称 pb)就是一个广受欢迎的序列化数据方法。相比传统的序列化手段(json, xml 等),pb 拥有更快的编解码速度,序列化后的数据更小。这对于大流量的系统而言,明显比 json 等序列化方法更有吸引力。但是你知道 pb 是如何做到更快更小的吗?如何正确使用 pb 才能让编码后的数据更小呢?

2. 目标

本文将探索 pb 是如何编码数据的,从而明确哪些设置会影响到编码,最终帮助我们更加合理的使用 pb,充分发挥 pb 的特性,从而得到更小的字节数据。

3. 何为 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)。

4. pb 的编码

4.1 整体介绍

总体而言,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)

4.2 Varint

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 编码

4.3 Length-delimi

这个是用于编码变长字段,因此完整字段的编码格式为 key-length-value, pb 不会对 value 进行压缩。

4.4 64-bit 和 32-bit

这个类型的编码格式依然使用 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 高效。

4.5 其他

packed

pb 还支持对数组类型的数据做进一步的压缩,前提是这个数组的每个元素都是固定长度的,如 int32, int64 等。

在不进行 packed 压缩时,字段编码后的格式为

......

使用 packed 压缩后的格式为

......

注意:使用 packed 后,编码类型就变成了 Length-delimi,另外,在 pb3 中,packed 默认为 true

5. 举个例子

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

6. 对我们的启发

  1. proto 中的字段名称对于实际的编码毫无作用,字段名称只是方便我们的开发而已

  2. 字段编号很重要,若想保持协议的兼容性,字段编号应该慎重修改

  3. pb 之所以能做到更小,是因为它是紧凑的编码,将 json 或者 xml 中的冗余信息都丢弃了

  4. pb 对 integer 有两种编码方式,若字段取值为正,建议采用 int64 等数据类型,若存在负数,建议采用 sint64 等数据类型。

  5. 若是枚举值,有两个建议:

    1.  尽量不要取负数作为枚举值,枚举值也应该尽可能的小

    2. 不要将字符串作为枚举值,相比整形,字符串明显占用更多的空间

  6. pb 是紧凑型的编码,这也就意味着 pb 牺牲了可读性。在选择序列化方式时,应当在可读性和效率之间做个权衡,我觉得主要有几个问题需要想清楚

    1. 可读性是否很重要。

    2. pb 节省下来的序列化时间和空间是否有很大的价值。比如在一个流量很小的系统里,采用 pb 还是采用 json,可能并不重要。

你可能感兴趣的:(rpc,后端)