图解protobuf的编码varints,zigzag与数据组织形式

646 阅读7分钟

什么是protobuf?

Protocol Buffers(protobuf)是由 Google 开发的一种高效的二进制数据序列化格式,用于在不同平台和语言之间传输和存储结构化数据。protobuf 允许你定义数据结构和消息格式,然后使用生成的代码进行数据的序列化和反序列化。

与 json、xml 相比, Protobuf 的编码长度更短、传输效率更高, 其实严格意义上讲, json、xml 并非是一种「编码」, 而只能称之为「格式」, json、xml 的内容本身都是字符形式, 它们的编码采用的是 ASCII 编码。

protobuf 的基本概念和用法:

  1. 消息定义: 你首先需要使用 protobuf 语言定义你的消息格式,这些定义通常存储在以 .proto 结尾的文件中。在消息定义中,你可以指定消息的字段、类型、标签以及其他元信息。
  2. 消息字段类型: protobuf 支持多种基本数据类型(如整数、浮点数、布尔值、字符串等),以及自定义消息类型。每个字段都有一个唯一的字段编号,用于在序列化和反序列化时标识字段。
  3. 编译器: 一旦你定义了消息格式,你需要使用 protobuf 编译器将 .proto 文件编译成对应编程语言的源代码。编译器会生成用于序列化和反序列化的类或结构体。
  4. 序列化和反序列化: 生成的代码提供了序列化和反序列化的方法,使你可以将结构化数据转换为 protobuf 格式的二进制数据,或将二进制数据还原为结构化数据。
  5. 跨平台和语言: 生成的代码可以在不同的编程语言和平台上使用,从而实现了数据在不同系统之间的互操作性。
  6. 版本兼容性: protobuf 支持对消息进行扩展,允许你在不破坏现有代码的情况下添加新的字段。已有字段的顺序和编号不能变更,以保持向后兼容性。
  7. 性能: 由于 protobuf 采用紧凑的二进制编码,它通常比文本格式如 JSON 或 XML 具有更好的性能,包括更小的数据传输和更快的序列化/反序列化速度。

protobuf 在许多场景下都非常有用,特别是在需要高性能数据传输和存储的情况下。它广泛用于网络通信、分布式系统、数据存储等领域。虽然 protobuf 需要一些初始定义和编译步骤,但它的高效性能和跨平台特性使得它成为许多项目的首选序列化格式。

Protocol Buffers(protobuf)是一种高效的二进制序列化格式,用于在不同平台和语言之间传输和存储结构化数据。protobuf 的编码格式是其核心特点之一,它以紧凑的二进制形式表示数据,从而实现高效的序列化和反序列化。

Varints 编码

Protocol Buffers(protobuf)是一种高效的二进制序列化格式,用于在不同平台和语言之间传输和存储结构化数据。protobuf 的编码格式是其核心特点之一,它以紧凑的二进制形式表示数据,从而实现高效的序列化和反序列化。

image.png 通常来说, 普通的 int 数据类型, 无论其值的大小, 所占用的存储空间都是相等的, 这点可以引起人们的思考, 是否可以根据数值的大小来动态地占用存储空间?

Varints 编码使用每个字节的最高有效位作为标志位, 而剩余的 7 位以二进制补码的形式来存储数字值本身, 当最高有效位为 1 时, 代表其后还跟有字节, 当最高有效位为 0 时, 代表已经是该数字的最后的一个字节。 在 Protobuf 中, 使用的是 Base128 Varints 编码, 之所以叫这个名字原因即是在这种方式中, 使用 7 bit 来存储数字, 在 Protobuf 中, Base128 Varints 采用的是小端序, 即数字的低位存放在高地址。

举例

对于数字 1, 我们假设 int 类型占 4 个字节, 以标准的整型存储, 其二进制表示应为

image.png 可见, 只有最后一个字节存储了有效数值, 前 3 个字节都是 0, 若采用 Varints 编码, 其二进制形式为

只需要1个字节

image.png

因为其没有后续字节, 因此其最高有效位为 0, 其余的 7 位以补码形式存放 1, 再比如数字 666, 其以标准的整型存储, 其二进制表示与采用 Varints 编码的形式为:

image.png

这样编码的一些缺点

1.无法随机寻数。因为每个数字的长度不固定,所以无法通过提前计算offset的方式直接寻找要找的第n个数字。

2.对于负数的编码可能是负优化。 毫无优势甚至比普通的固定 32 位存储还要多占 4 个字节。

因为采用 Varints 编码会恒定占用 10 个字节, 原因在于负数的符号位为 1, 对于负数其从符号位开始的高位均为 1, 在 Protobuf 的具体实现中, 会将此视为一个很大的无符号数。

image.png

在这个小demo中我们使用proto.marshal来查看protobuf的varints对负数的编码,并使用%08b格式打印。

type UnsignedNum struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Age int32 `protobuf:"varint,1,opt,name=age,proto3" json:"age,omitempty"`
}


func TestProto2(t *testing.T) {
    unsignedNum := UnsignedNum{Age: -10}
    nBytes, _ := proto.Marshal(&unsignedNum)
    fmt.Printf("%08b\n", nBytes)
}

结果: [00001000 11110110 11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111 00000001]

对于这个问题有一个优化方案,就是zigzag编码。

zigzag编码

Varints 编码在负数场景下低效, Zigzag 编码便是为了解决这个问题, Zigzag 编码的大致思想是首先对负数做一次变换, 将其映射为一个正数, 变换以后便可以使用 Varints 编码进行压缩。

对于有符号32位整数: image.png

对于有符号64位整数:

image.png

“^”号左边是逻辑移位, 右边是算术移位, 右边的含义实际是得到一个全 1 (对于负数) 或全 0 (对于正数)的比特序列,然后对两边按位异或便得到 Zigzag 编码。

假设现有数字为 -5, 其在内存中的形式为

image.png

image.png

可以看到, 对负数使用 Zigzag 编码以后, 其高位的 1 全部变成了 0, 这样一来我们便可以使用 Varints 编码进行进一步地压缩, 再来看正数的情形, 对于 16 位的正数 5, 其在内存中的存储形式为

image.png 我们按照与负数相同的处理方法, 可以得到其 Zigzag 编码为

image.png

sintN使用 "ZigZag "编码而不是2的补码来编码负整数。正整数p被编码为2 * p(偶数),而负整数n被编码为2 * |n| - 1(奇数)。因此,编码在正负数之间 "之 "字形变化。

image.png

从上面的结果来看, 无论是正数还是负数, 经过 Zigzag 编码以后, 数字高位都是 0, 这样一来, 便可以进一步使用 Varints 编码进行数据压缩, 即 Zigzag 编码在 Protobuf 中并不单独使用, 而是配合 Varints 编码共同来进行数据压缩, Google 在 Protobuf 的官方文档中写道:

image.png

代码验证

message SignedNum{
  sint32 age=1;
}

message UnsignedNum{
  int32 age=1;
}
type SignedNum struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Age int32 `protobuf:"zigzag32,1,opt,name=age,proto3" json:"age,omitempty"`
}

type UnsignedNum struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Age int32 `protobuf:"varint,1,opt,name=age,proto3" json:"age,omitempty"`
}

func main() {
    signedNum := pb.SignedNum{Age: -10}
    unsignedNum := pb.UnsignedNum{Age: -10}
    sBytes, _ := proto.Marshal(&signedNum)
    nBytes, _ := proto.Marshal(&unsignedNum)
    fmt.Printf("%08b\n", sBytes)
    fmt.Printf("%08b\n", nBytes)
}

结果:

image.png

可以看到使用有符号数的情况下比无符号数编码长度足足少了9个字节,优化效果还是十分明显的。

protobuf数据的组织形式

protobuf是以标志位和数据位组成的:

标志位:

image.png

image.png

有了字段编号和 wire type, 其后所跟的数据的长度便是确定的,其余的数据类型对于标识符大都是相同的, 因此 Protobuf 是一种非常紧密的数据组织格式, 其不需要特别地加入额外的分隔符来分割一个消息字段, 这可大大提升通信的效率, 规避冗余的数据传输。