编码细节

到目前为止,我们谈了很多关于 “算法” 的内容,但并没有深入到具体细节。在本节中,我们将探讨 Protobuf 中序列化和反序列化过程中使用的主要算法。我们首先会了解我们可以为字段使用的所有类型,然后基于这些类型将它们分为三类,最后我们将解释每一类使用的算法。

Protobuf 中,被认为是简单类型并且由 Protobuf 提供的类型称为 标量类型scalar types)。我们可以使用 15 种这样的类型,如下所示:

  • int32

  • int64

  • uint32

  • uint64

  • sint32

  • sint64

  • fixed32

  • fixed64

  • sfixed32

  • sfixed64

  • double

  • float

  • string

  • bytes

  • bool

在这 15 种类型中,有 10 种是用于整数类型(前 10 个)。这些类型一开始可能让人觉得有点复杂,但目前不必太担心如何选择它们;我们会在本节中讨论这一点。现在最重要的是理解,这其中的三分之二是整数类型,这也表明 Protobuf 擅长编码整数。

现在我们知道了标量类型,让我们将这些类型分为三类。不过,我们不是简单地将它们分类为数字、数组等,而是根据 Protobuf 序列化算法来划分。总共有三类:固定大小的数字、可变大小的整数(varints)和长度限定类型。下面是每个类别的详细分类:

固定大小的数字 可变大小的整数 长度限定类型

fixed32

int32

string

fixed64

int64

bytes

sfixed32

uint32

sfixed64

uint64

double

bool

float

接下来,我们将详细讨论每个类别。

定长数字

最容易理解的类型之一是固定大小的数字,对于习惯于使用静态类型语言的开发者来说,这非常直观。如果你曾在低级语言中工作过,并且尝试优化存储空间,你会知道,在大多数硬件上,我们可以将整数存储为 32 位(4 字节)或 64 位(8 字节)。fixed32fixed64 只是你在具有整数存储大小控制的语言中(例如 GoC++Rust 等)使用的正常数字的二进制表示。如果我们将数字 42 序列化为 fixed32 类型,输出将如下所示:

# protoc 期望从标准输入流(也就是通过管道传递过来的数据)中接收一个 Fixed32Value 消息,并将其编码为二进制格式
# hexdump -C 用于将 protoc 编码的二进制数据转换为十六进制格式,并打印出来
$ cat fixed.txt | protoc --encode=Fixed32Value wrappers.proto | hexdump -C
00000000 0d 2a 00 00 00 |.*...|
00000005

在这里,2a 是 42,而 0d 是字段标签和字段类型的组合(稍后会详细介绍)。同样,如果我们将 42 序列化为 fixed64 类型,输出将是:

$ cat fixed.txt | protoc --encode=Fixed64Value wrappers.proto | hexdump -C
00000000 09 2a 00 00 00 00 00 00 00 |.*.......|
00000009

唯一的变化是字段类型和字段标签的组合(09)。这主要是因为我们将类型改为了 64 位数字。

另外两个易于理解的标量类型是 floatdouble。同样,Protobuf 会生成这些类型的二进制表示。如果我们将 42.42 编码为 float,输出将如下所示:

$ cat floating_point.txt | protoc --encode=FloatValue wrappers.proto | hexdump -C
00000000 0d 14 ae 29 42 |...)B|
00000005

在这种情况下,解码稍微复杂一些,但这仅仅是因为浮动数字的编码方式不同。如果你对这种数据存储感兴趣,可以参考《IEEE 浮动点算术标准(IEEE 754)》,它解释了浮动点数是如何在内存中构成的。这里需要注意的是,浮动点数使用 4 字节进行编码,前面是标签 + 类型。如果我们将 42.42 编码为 double 类型,输出将是:

$ cat floating_point.txt | protoc --encode=DoubleValue wrappers.proto | hexdump -C
00000000 09 f6 28 5c 8f c2 35 45 40 |..(\..5E@|
00000009

这使用 8 字节进行编码,并带有标签 + 类型。注意,这里的标签 + 类型也发生了变化,因为我们现在处理的是 64 位数字。

最后,我们剩下的是 sfixed32sfixed64。之前我们没有提到,fixed32fixed64 是无符号数字。这意味着我们不能在这些类型的字段中存储负数。sfixed32sfixed64 解决了这个问题。因此,如果我们将 -42 编码为 sfixed32 类型,输出将是:

$ cat sfixed.txt | protoc --encode=SFixed32Value wrappers.proto | hexdump -C
00000000 0d d6 ff ff ff |.....|
00000005

这是通过将 42 的二进制取反(1 补码)并加 1(2 补码)得到的。否则,如果你序列化一个正数,你将得到与 fixed32 类型相同的二进制值。然后,如果我们将 -42 编码为 sfixed64 类型,输出将是:

$ cat sfixed.txt | protoc --encode=SFixed64Value wrappers.proto | hexdump -C
00000000 09 d6 ff ff ff ff ff ff ff |.........|
00000009

这就像 sfixed32 类型,只是标签 + 类型发生了变化。

总结来说,固定整数是整数的简单二进制表示,类似于它们在大多数计算机内存中的存储方式。顾名思义,它们的序列化数据始终会被序列化为相同数量的字节。对于某些用例,使用这种表示是没问题的;然而,在大多数情况下,我们希望减少那些仅用于填充的位。在这些情况下,我们将使用称为 varints 的编码方式。

可变整数(Varints)

现在我们已经了解了固定整数,让我们来看看另一种数字序列化类型:可变长度整数。顾名思义,在序列化一个整数时,我们不会得到固定数量的字节。

更准确地说,整数越小,序列化时占用的字节数就越少;而整数越大,占用的字节数就越多。让我们来看一下算法是如何工作的。

在这个例子中,我们将序列化数字 300。首先,我们将得到该数字的二进制表示:

100101100

有了这个二进制表示后,我们可以将其分割成 7 位一组,并在需要时用零进行填充:

0000010
0101100

现在,由于我们缺少 2 个比特来创建 2 字节,我们将为所有组(除了第一组)添加 1 作为最重要位(MSB),并为第一组添加 0 作为 MSB:

00000010
10101100

这些 MSB 是续位(continuation bits)。这意味着,当我们看到 1 时,表示后面还有 7 个比特需要读取;而当我们看到 0 时,表示这是最后一组需要读取的字节。最后,我们将这些字节按小端顺序排列,得到如下序列:

10101100 00000010

或者,在十六进制中就是:

AC 02

现在我们已经将数字 300 序列化为 AC 02,记住反序列化是序列化的反过程,我们可以进行反序列化操作。我们取出 AC 02 的二进制表示,去掉续位(MSB),并反转字节的顺序。最终我们得到以下二进制:

100101100

这与我们开始时的二进制相同,等于 300。

现在,在现实中,你可能会遇到更大的数字。为了快速参考以下是正整数字节数增加的阈值列表:

阈值(十进制) 字节大小

0

0

1

1

128

2

16,384

3

2,097,152

4

268,435,456

5

34,359,738,368

6

4,398,046,511,104

7

562,949,953,421,312

8

72,057,594,037,927,936

9

9,223,372,036,854,775,807

9

一个敏锐的读者可能已经注意到,使用可变长度整数(varint)通常是有利的,但在某些情况下,我们可能会将值编码为超过实际需要的字节数。例如,如果我们将 72,057,594,037,927,936 编码为一个 int64 类型,它将被序列化为 9 个字节,而使用 fixed64 类型则只需要 8 个字节。此外,来自我们刚才看到的编码方法的问题是,负数将被编码为一个大的正数,因此也会被编码为 9 个字节。

这引出了以下问题:我们如何有效地在不同的整数类型之间进行选择呢?

如何选择?

答案是,像往常一样,这取决于情况。然而,我们可以通过系统地选择来避免许多错误。我们主要有三个选择需要根据我们想要序列化的数据做出:

  • 所需的数字范围

  • 是否需要负数

  • 数据分布

数字范围

到目前为止,你可能已经注意到,我们类型的 32 和 64 后缀并不总是与我们的数据序列化所使用的位数有关。对于可变长度整数(varints),它们更多的是关于可以序列化的数字范围。这些范围取决于序列化时使用的算法。

对于固定、带符号和可变长度整数,数字的范围与开发人员习惯的 32 位和 64 位数字范围相同。这意味着我们得到以下范围:

  • 对于带符号的整数(如 int32, int64),范围是: [-2^(位数 - 1), 2^(位数 - 1) - 1]

  • 对于无符号整数(如 uint32, uint64),范围是: [0, 2 * 2^(位数 - 1) - 1]

是否需要负数

如果你根本不需要负数(例如,用于 ID 的字段),理想的类型是无符号整数(uint32, uint64)。这种类型可以防止你编码负数,它在正数范围内的大小是带符号整数的两倍,并且会使用 varint 算法进行序列化。

另一个你可能会使用的类型是带符号整数(sint32, sint64)。我们不深入讲解它们是如何序列化的,但算法会将任何负数转换为正数(ZigZag 编码),并使用 varint 算法序列化该正数。对于负数,这种方式更有效,因为它不会像大正数一样占用 9 个字节,而是利用了 varint 编码的优势。然而,对于正数来说,这种方法则效率较低,因为我们将原本的负数与正数交错在一起。这意味着,相同的正数可能会使用不同的字节数进行编码。

数据分布

最后,值得一提的是,编码效率高度依赖于你的数据分布。你可能基于某些假设选择了某些类型,但你的实际数据可能有所不同。两个常见的例子是:

  • 选择 int32int64 类型,因为我们预计负数很少

  • 选择 int64 类型,因为我们预计极大数字的数量较少

但这两种情况可能会导致显著的低效,因为在这两种情况下,我们可能会有很多值被序列化为 9 个字节。

不幸的是,没有一种方式可以总是完美地适应数据类型。在这种情况下,最好的办法就是对真实数据进行实验,确保这些数据能够代表整个数据集。这样,你就能了解自己在做对了什么,以及在哪些方面做得不够好。

长度限定类型

现在我们已经看过了所有的数字类型,接下来我们要讨论的是长度限定类型(length-delimited types)。这些类型,比如字符串和字节数组,是我们无法在编译时知道其长度的类型,可以将它们看作是动态数组。

为了序列化这样的动态结构,我们只需在原始数据前面加上该数据的长度。这意味着,如果我们有一个长度为 10 的字符串,内容为 “0123456789”,则其序列化后的字节序列将如下所示:

$ cat length-delimited.txt | protoc --encode=StringValue
wrappers.proto | hexdump -C
00000000 0a 0a 30 31 32 33 34 35 36 37 38 39 |..0123456789|
0000000c

这里,第一个 0a 是字段标签 + 类型,第二个 0a 是表示长度 10 的十六进制数,然后我们跟随的是每个字符的 ASCII 值。要理解为什么 0 转换成 30,你可以查看 ASCII 手册,在终端输入 man ascii 查找对应的十六进制值。你应该能看到类似如下的输出:

30 0 31 1 32 2 33 3 34 4
35 5 36 6 37 7 38 8 39 9

这里,每一对数字中的第一个数字是第二个数字的十六进制值。

另一种会序列化为长度限定类型的字段是 repeated 字段。repeated 字段相当于一个列表。要序列化这样的字段,我们只需在字段类型前加上 repeated 关键字。如果我们想要序列化一个 ID 列表,可以写如下代码:

repeated uint64 ids = 1;

这样,我们就可以存储 0 或多个 ID。

类似地,这些字段将会使用长度作为前缀进行序列化。如果我们以 ids 字段并序列化数字 1 到 9,我们将得到如下结果:

$ cat repeated.txt | protoc --encode=RepeatedUInt64Values
wrappers.proto | hexdump -C
00000000 0a 09 01 02 03 04 05 06 07 08 09 |...........|
0000000b

这是一个包含 9 个元素的列表,值为 1, 2, 3,…​ 等等。

repeated 字段存储的是标量类型(除了字符串和字节数组)时,它们会被序列化为长度限定类型,这种方式称为 “打包”(packed)。而对于复杂类型或用户定义类型(消息),这些值会以一种较不优化的方式进行编码。每个值会单独编码,并以字段标签和类型字节为前缀,而不是仅仅在一次序列化时就添加类型和标签。

字段标签与数据类型

到目前为止,你可能多次看到 “标签 + 类型” 这个术语,但我们还没有真正理解它的意思。正如之前提到的,每个序列化字段的前几个字节将是字段类型和字段标签的组合。我们先从字段标签开始,看看它是什么。

你一定注意到,在定义字段时,每次都会加上一个等号,并且后面跟着一个递增的数字。例如:

uint64 id = 1;

虽然看起来像是为字段赋值,但它们其实只是为字段提供一个唯一的标识符。这些标识符被称为标签(tags),它们看起来可能不那么显眼,但却是序列化过程中最重要的信息。它们的作用是告诉 Protobuf,应该将哪个数据反序列化到哪个字段中。正如我们在介绍不同序列化算法时看到的那样,字段名称并不会被序列化——只有类型和标签会被序列化。因此,当反序列化发生时,Protobuf 会看到一个数字,并知道接下来的数据应该存入哪个字段。

现在我们知道这些标签只是标识符,让我们来看一下这些值是如何编码的。标签本身就是以 varint 格式序列化的,但它们是与“线型”(wire type)一起序列化的。“线型” 是 Protobuf 中用于给一组类型分配数字的方式。以下是 Protobuf 中所有线型的列表:

类型 (Type) 含义 (Meaning) 用途 (Used for)

0

Varint

int32, int64, uint32, uint64, sint32, sint64, bool, enum

1

64-bit

fixed64, sfixed64, double

2

Length-delimited

string, bytes, packed repeated fields

5

32-bit

fixed32, sfixed32, float

在这里,0 表示 varints 类型,1 表示 64 位类型,依此类推。

为了将标签和线型(wire type)结合起来,Protobuf 使用了一种称为 位打包(bit packing)的技术。这是一种旨在减少数据序列化时所需位数的技术。在我们的案例中,数据是字段的元数据(即标签 + 类型)。

工作原理如下:序列化元数据的最后 3 位保留给线型,剩下的用于标签。

以我们在 固定大小数字 部分提到的第一个例子为例,我们将 42 序列化到一个具有标签 1 的 fixed32 字段时,得到以下字节序列:

0d 2a 00 00 00

这次我们只关心 0d 这一部分,它是字段的元数据。为了查看它是如何序列化的,我们可以将 0d 转换为二进制(以 0 填充):

00001101

在这里,最后 3 位 101(即 5)表示线型(wire type)——这是 32 位的线型;而前 5 位 00001(即 1)表示标签(tag)为 1。

由于标签是作为 varint 序列化的,这意味着该元数据可能会占用不止 1 个字节。 以下是参考表,展示了标签所需字节数增加的阈值:

字段标签 位大小

1

5

16

13

2,048

21

262,144

29

33,554,432

37

536,870,911

37

这意味着,由于没有设置值的字段不会被序列化,我们需要将最低的标签分配给最常被填充的字段。这将减少存储元数据所需的开销。通常,15 个标签就足够了,但如果你遇到需要更多标签的情况,你可以考虑将一组数据移到一个新的消息中,并使用更低的标签。