首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >你还在用 JSON?Protobuf 才是高效通信的王者!

你还在用 JSON?Protobuf 才是高效通信的王者!

作者头像
IsLand1314
发布2025-07-21 08:46:23
发布2025-07-21 08:46:23
29100
代码可运行
举报
文章被收录于专栏:学习之路学习之路
运行总次数:0
代码可运行

一、基本特点

Protobuf 是一个 跨平台的协议,具有语言无关的特性。

  • 其核心设计目标是高效的数据传输,因此对数据类型的设计尤为关键。

ProtoBuf 官方文档

1. 序列化

定义:序列化是将 数据结构或对象 转换成 二进制字节流 的过程。

特点:Protobuf 针对 不同的字段类型 采用 不同的编码方式 和数据存储方式,以确保得到高效紧凑的数据压缩。

序列化过程

  1. 判断每个字段是否 有设置值,有值才进行编码。
  2. 根据字段标识号与数据类型,将字段值通过不同的编码方式进行编码。
  3. 将编码后的数据块按照字段类型采用不同的数据存储方式 封装成二进制数据流。
2. 反序列化

定义:反序列化是将在序列化过程中生成的 二进制字节流 转换成 数据结构 或者对象的过程。

反序列化过程

  1. 调用消息类的 parseFrom(input) 方法解析从输入流读入的二进制字节数据流。
  2. 将解析出来的 数据按照指定的格式读取 到 Java、C++、Python 等对应的结构类型中。

Protobuf 编解码流程图如下

二、ProtoBuf 数据类型

Protocol Buffers(简称 ProtoBuf)定义了丰富的数据类型 ,用于在 .proto 文件中定义结构化的数据模型。这些类型分为标量类型(Scalar Types)复合类型(Composite Types)

1. 标量类型

标量类型是基本的数据类型,用于定义字段的值。以下是 ProtoBuf 支持的标量类型:

.PROTO 类型

说明

对应不同语言类型

double

双精度浮点数

Python:float/ Java:double/ C++:double

float

单精度浮点数

Python:float/ Java:float/ C++:float

int32

32位整数,使用变长编码(Varint)

Python:int/ Java:int/ C++:int32

int64

64位整数,使用变长编码(Varint)

Python:int/ Java:long/ C++:int64

uint32

无符号32位整数

Python:int/ Java:int/ C++:uint32

uint64

无符号64位整数

Python:int/ Java:long/ C++:uint64

sint32

有符号32位整数(更高效编码负数)

Python:int/ Java:int/ C++:int32

sint64

有符号64位整数(更高效编码负数)

Python:int/ Java:long/ C++:int64

fixed32

32位无符号整数(固定4字节)

Python:int/ Java:int/ C++:uint32

fixed64

64位无符号整数(固定8字节)

Python:int/ Java:long/ C++:uint64

sfixed32

有符号32位整数(固定4字节)

Python:int/ Java:int/ C++:int32

sfixed64

有符号64位整数(固定8字节)

Python:int/ Java:long/ C++:int64

bool

布尔值

Python:bool/ Java:boolean/ C++:bool

string

UTF-8 编码字符串

Python:str/ Java:String/ C++:string

bytes

任意字节序列(类似 byte[])

Python:bytes/ Java:ByteString/ C++:string

String 和 bytes 区别:

  • string 存储的是编码后的字符串,如 "hello world""你好世界"
  • bytes 存储的是字节序列(存储的是原始字节而非字符串内容),解析时无法直接得到中文字符。
2. 复合类型

复合类型是由用户定义的结构化类型,包括:

message:定义结构化的数据类型

代码语言:javascript
代码运行次数:0
运行
复制
message Address {
  string city = 1;
  string street = 2;
  int32 zip_code = 3;
}

enum:枚举类型

代码语言:javascript
代码运行次数:0
运行
复制
enum PhoneType {
  MOBILE = 0;
  HOME = 1;
  WORK = 2;
}
  • ⚠️ 注意:枚举值必须以 0 开始,且不能重复。

嵌套 message:一个 message 中可以包含另一个 message

代码语言:javascript
代码运行次数:0
运行
复制
message Person {
  string name = 1;
  int32 age = 2;
  Address address = 3;  // 使用上面定义的 Address 消息
}
3. 字段规则(Field Ruls)

字段前可以加上以下关键字,表示该字段的行为:

关键字

说明

optional

可选字段(proto2)

required

必填字段(proto2)

repeated

可重复字段(列表),相当于数组

(无关键字)

proto3 中默认所有字段都是可选的

示例

代码语言:javascript
代码运行次数:0
运行
复制
message Person {
  string name = 1;              // 必须字段(proto3 默认可选)
  repeated string hobbies = 2;  // 可重复字段,表示一个字符串列表
  PhoneType phone_type = 3;     // 枚举字段
}
4. 其他高级类型

map<key_type, value_type>:键值对映射(proto3)

代码语言:javascript
代码运行次数:0
运行
复制
message Person {
  map<string, string> metadata = 4;  // 例如:{"nickname": "Bob", "role": "admin"}
}
  • 注意:key_type 必须是整数或字符串类型。

oneof:多个字段中只能设置一个(节省空间)

代码语言:javascript
代码运行次数:0
运行
复制
message SampleMessage {
  oneof test_oneof {
    string name = 1;
    int32 id = 2;
  }
}
  • 使用 oneof 后,nameid 不能同时设置。

Any:可以包含任意类型的 message(需要导入 google/protobuf/any.proto

代码语言:javascript
代码运行次数:0
运行
复制
import "google/protobuf/any.proto";

message Container {
  repeated google.protobuf.Any items = 1;
}

Timestamp, Duration, Struct 等(来自 well-known types

这些是 Google 提供的标准类型,用于处理常见数据结构:

代码语言:javascript
代码运行次数:0
运行
复制
import "google/protobuf/timestamp.proto";

message LogEntry {
  string message = 1;
  google.protobuf.Timestamp timestamp = 2;
}

三、关于 PB 的整型编码方式

Protobuf 的整型分为两类:**定长编码 **和 变长编码。这两类的区别在于 底层存储方式 的不同,分别适用于不同的场景。

1. 定长编码

特点:序列化后占用固定大小的空间。

类型如下

Protobuf 类型

解释

C++ 类型

fixed32

定长 4 字节无符号整型

uint32_t

fixed64

定长 8 字节无符号整型

uint64_t

sfixed32

定长 4 字节有符号整型

int32_t

sfixed64

定长 8 字节有符号整型

int64_t

示例定义

代码语言:javascript
代码运行次数:0
运行
复制
message base {
    fixed32 a = 1;
    fixed64 b = 2;
    sfixed32 c = 3;
    sfixed64 d = 4;
}

编译后的 C++ 实现:在生成的 C++ 文件中,这些字段会被统一管理在一个内部类 Impl_ 中,如下

代码语言:javascript
代码运行次数:0
运行
复制
struct Impl_ {
    inline explicit constexpr Impl_(...);
    ::uint64_t b_;
    ::uint32_t a_;
    ::int32_t c_;
    ::int64_t d_;
    mutable ::google::protobuf::internal::CachedSize _cached_size_;
    PROTOBUF_TSAN_DECLARE_MEMBER
};
  • 这里的变量 a_b_,分别对应之前表格的类型
2. 变长编码

特点:尽可能 压缩字节数 来提高效率,适合小数值的场景。

Protobuf 类型

解释

C++ 类型

uint32

变长无符号整型

uint32_t

uint64

变长无符号整型

uint64_t

int32

变长有符号整型,正数编码效率更高

int32_t

int64

变长有符号整型,正数编码效率更高

int64_t

sint32

变长有符号整型,负数编码效率更高

int32_t

sint64

变长有符号整型,负数编码效率更高

int64_t

3. 变长编码方式

Protobuf 在处理 整型字段 时,采用了非常高效的编码机制:VarInt 编码 和 ZigZag 编码

  • VarInt 编码:每个字节的最高位(MSB)用于指示是否还有更多字节,剩余 7 位用于存储实际数值,按小端字节序排列。
    • 示例:数字 1023 使用 VarInt 编码为 FF 07。
  • ZigZag 编码:ZigZag 编码将有符号整数映射到无符号整数,使得 绝对值小 的数字占用更少字节。
3.1 Varint 编码

Varint 是一种变长整数编码方式 ,它的核心思想是:小的整数用更少的字节表示,大的整数用更多的字节表示。 因此,可以通过数值大小 动态调整编码后的字节数 从而实现 空间压缩 ,非常适合用于网络传输和数据存储。

这样做的好处是:

  • 小数值(如 0、1、127)占用 1 个字节:对于 int32 类型的数字,一般需要 4 个字节表示。如果采用 Varint 编码,对于很小的 int32 数字,则可以用 1 个字节来表示。
  • 大数值(如 1000000)占用多个字节:虽然大的数字会需要 5 个字节来表示,但大多数情况下,消息都不会有很大的数字,所以采用 Varint 编码方式总是可以用更少的字节数来表示数字。
  • 节省存储空间,提高传输效率

编码原理

每个字节的 最高位(MSB) 是标志位 ,表示是否还有后续字节:

  • 1 :表示后续的字节也是数字的一部分
  • 0 :表示本字节是最后一个字节,且剩余 7 位都用来表示数字 用来存储实际数据

当使用 Varint 解码时,只要读取到最高位为 0 的字节时,表示本字节是一个值经 Varint 编码后得到的字节流的最后一个字节。

示例:编码数字 300

300 的二进制表示为:100101100

按照 7 位一组从低位到高位分组:

代码语言:javascript
代码运行次数:0
运行
复制
101100 (后7位)
10 (高位部分)

补齐 7 位:00000010 11001100

加上标志位(除了最后一个字节外,其他都设为 1),两字节分别为:

代码语言:javascript
代码运行次数:0
运行
复制
1 1001100  (MSB=1)
0 0000010  (MSB=0)

转换为十六进制:0xAC 0x02

因此 300 被编码为两个字节:AC 02(1010 1100 0000 0010)

代码实现

代码语言:javascript
代码运行次数:0
运行
复制
#include <iostream>  
#include <vector>  
#include <cstdint>  
#include <stdexcept>  
 
// Varint 编码函数  
std::vector<uint8_t> EncodeVarint(uint64_t value) {
    std::vector<uint8_t> buffer;
    while (true) {
        if ((value & ~0x7FUL) == 0) {
            // 最后一个字节,最高位为 0  
            buffer.push_back(static_cast<uint8_t>(value));
            break;
        } else {
            // 还有后续字节,最高位为 1,其余 7 位存储值  
            buffer.push_back(static_cast<uint8_t>((value & 0x7F) | 0x80));
            value >>= 7;
        }
    }
    return buffer;
}
 
// Varint 解码函数  
uint64_t DecodeVarint(const uint8_t*& data, size_t& size) {
    uint64_t result = 0;
    int shift = 0;
    while (true) {
        if (size == 0) {
            throw std::runtime_error("Varint decode error: truncated data");
        }
        uint8_t byte = *data++;
        size--;
        result |= (static_cast<uint64_t>(byte & 0x7F) << shift);
        if (!(byte & 0x80)) {
            // 最后一个字节,退出循环  
            break;
        }
        shift += 7;
        if (shift >= 63) {
            // 超过 64 位整数范围,抛出异常  
            throw std::runtime_error("Varint decode error: integer out of range");
        }
    }
    return result;
}
 
int main() {
    // 测试 Varint 编码和解码  
    uint64_t test_value = 300;
    std::vector<uint8_t> encoded = EncodeVarint(test_value);
 
    std::cout << "Encoded Varint: ";
    for (uint8_t byte : encoded) {
        std::cout << std::hex << "  " << static_cast<int>(byte) << " ";
    }
    std::cout << std::endl;
 
    // 准备解码  
    const uint8_t* data = encoded.data();
    size_t size = encoded.size();
    uint64_t decoded_value = DecodeVarint(data, size);
 
    std::cout << "Decoded Varint: " << decoded_value << std::endl;
 
    return 0;
}

此代码完整实现了Varint的核心编解码逻辑,输出结果验证了算法正确性。对于负数处理,需结合Zigzag编码(如(n << 1) ^ (n >> 31))实现更优压缩

关于 uint32 和 uint64

这两个是无符号整型,因此无需考虑负数的情况,直接存储原码;只使用VarInt编码,不使用ZigZag编码。

例如数字1023,其原码为11 1111 1111(即10位都是1),如果使用VarInt编码,则只占用2 byte:

  • 第一字节: 1 111 1111。第一个字节的最高有效位(MSB)= 1,说明这不是最后一个字节,后七位数值为111 1111。
  • 第二字节: 0000 0111。第二个字节的MSB = 0,说明这是最后一个字节,后七位数值为0000 0111
关于 int32 和 int64

这两个类型可以用于有符号整型,但此处仅使用VarInt编码,而不使用ZigZag编码。

  • 对于正数,它们直接存储其值,编码方式与uint32和uint64相同。
  • 但是负数以补码形式存储,导致其VarInt编码看起来像是一个很大的正数。例如 int32 的-1补码表示为 11111111 11111111 11111111 11111111。

因为 VarInt 每个字节的最高位表示是否还有更多字节,所以-1会编码成5个字节(因为每个字节的最高位都要设为1,除了最后一个):

  • 11111111 最高位是MSB = 1,表示未结束;
  • 11111111 最高位是MSB = 1,表示未结束;
  • 11111111 最高位是MSB = 1,表示未结束;
  • 11111111 最高位是MSB = 1,表示未结束;
  • 00000001 最高位是MSB = 0,表示结束。

因此,-1作为 int32 通过 VarInt编码后会是 11111111 11111111 11111111 11111111 00000001

  • 负数的编码长度变得非常长,因为它们被编码为其补码的形式,在VarInt中这看起来像一个非常大的数。

因此,虽然int32和int64能够存储负数,但是效率很低。

负数处理
  • 在计算机内,负数一般会被表示为很大的整数,因为计算机定义负数的符号位为数字的最高位。
  • 如果采用 Varint 编码方式表示一个负数,那么一定需要 5 个字节(因为负数的最高位是 1,会被当做很大的整数处理)。
  • Protobuf 定义了 sint32 和 sint64 类型表示负数,通过先采用 Zigzag 编码(将有符号数转换成无符号数),再采用 Varint 编码,从而用于减少编码后的字节数。

varint 应用场景

  • Protocol Buffer通信:用于减少网络传输数据量
  • LevelDB存储优化:键值对中的整数压缩存储
  • 数据库索引压缩:B+树节点中的指针偏移量编码
3.2 ZigZag 编码

定义:Zigzag 编码是一种变长的编码方式,其编码原理是使用无符号数来表示有符号数字,使得 绝对值小的数字都可以采用较少字节来表示\,特别适合表示负数。

  • 负数在 Varint 编码中会占用较多字节(因为补码高位全是 1),用 ZigZag 编码 可以优化负数的表示。

原理

  • 对于正整数,可以把无意义的 0 去掉,只存储从 1 开始的“有效”数据,从而压缩数据。
  • 对于负数,通过移位和取反操作,将符号位移动到最后,并完成数据位的取反操作,从而实现压缩。

实现

  • 对于 32 位整数,(n << 1) ^ (n >> 31) 即可实现 Zigzag 编码。
  • 对于 32 位整数,(n >> 1) ^ -(n & 1) 即可实现 Zigzag 解码。

数值映射表举例

原始值

ZIGZAG 编码

0

0

-1

1

1

2

-2

3

2

4

  • 映射规则:正数 n 变成 n * 2;负数 n 变成 (-n - 1) * 2 + 1
  • 编码值 -1:(11111111 11111111 11111111 11111111) → (00000000 00000000 00000000 00000001)。
  • 编码值 1:(00000000 00000000 00000000 00000001) → (00000000 00000000 00000000 00000010)

示例

代码语言:javascript
代码运行次数:0
运行
复制
// 编码公式(伪代码):
uint32_t zigzag_encode_32(int32_t val) {
    return (uint32_t)((val << 1) ^ (val >> 31));
}

// 解码公式
int32_t zigzag_decode_32(uint32_t val) {
    return (int32_t)((val >> 1) ^ -(val & 1));
}

sint32sint64 这两个类型也可以用于有符号整型,结合了 VarInt编码 和 ZigZag编码。

  • 这样编码后,数值的绝对值越小,编码后所需的字节数就越少。
  • 特别是对于负数,这样编码后即使是-1,也只需要一个字节,而不像 int32 那样需要5个字节
3.3 Protobuf 中不同整型的区别

类型

有符号

编码方式

说明

int32

Varint

可能需要 1~5 个字节

int64

Varint

可能需要 1~10 个字节

uint32

Varint

无符号整数,适合非负数

uint64

Varint

同上,适合大整数

sint32

ZigZag + Varint

更高效编码负数

sint64

ZigZag + Varint

同上

fixed32

是/否

固定 4 字节

适用于需要固定大小的场景

fixed64

是/否

固定 8 字节

同上

sfixed32

固定 4 字节

带符号固定大小

sfixed64

固定 8 字节

同上

选择策略:定长 vs 变长

  • 当数值普遍小于
2^{28}

时,选择变长编码。

  • 当数值大于
2^{28}

(32 位)或

2^{56}

(64 位)时,选择定长编码。

int vs sint:

  • 如果负数出现频率低,使用 int32 或 int64。
  • 如果负数出现频率高或正负数出现频率相近,使用 sint32 或 sint64。

所谓数据压缩,说白了就是将不重要的数据忽略或舍弃;由于计算机中只有0和1,本质上就是将无意义的0丢弃,而起到数据压缩的目的。数据压缩 可以借助 标记位 实现


三、Json vs PB

JSON 的数据表示方式键值对 的形式来标识数据与变量之间的映射关系。

例如,以下 JSON 数据:

代码语言:javascript
代码运行次数:0
运行
复制
{
    "id": 1,
    "name": "张三",
    "address": "北京"
}
  • 在 JSON 中,键(如 "id""name""address")是字符串形式。
  • 每个字符串需要占用多个字节存储,比如 "address" 至少需要 7 字节存储字符本身,再加上额外的格式字符。

ProtoBuf 的数据表示方式:其是高效的 二进制序列化协议,它通过 字段编号 来标识数据与变量之间的映射关系

字段编号的作用

  • 在 Protobuf 中,每个成员变量都需要一个类内唯一的字段编号。
  • 字段编号范围是 [1, 2^29 - 1],其中 [19000, 19999] 是保留编号,不允许使用。
  • 序列化后的数据中,字段编号以 VarInt (MSB 最高位标记) 编码形式存储,用于标识对应变量及其类型。

Protobuf 的序列化过程,假设有如下 message 定义:

代码语言:javascript
代码运行次数:0
运行
复制
message People {
    int32 id = 1;
    string name = 2;
    string address = 3;
};

序列化后,数据格式大致如下:

代码语言:javascript
代码运行次数:0
运行
复制
0000 0001 "id 的值的二进制编码"
0000 0002 "name 的值的二进制编码"
0000 0003 "address 的值的二进制编码"
  • 第一个字节 0000 0001 是字段编号,表示该数据属于 id 变量。
  • Protobuf 解析时,根据字段编号回到 message 定义中查找对应的变量和类型,然后用相应类型的解析规则解析后续数据。

流程:例如,读取第一个字节 0000 0001,解析出字段编号为 1,回到 message 定义发现其为 id 变量,类型为 int32,最后用 int32 的解析规则解析后面的值。

对比如下

  1. 效率
    • JSON:由于键是字符串形式,存储和传输效率较低。例如,“address” 至少需要 7 字节 存储字符本身,加上其他格式字符可能需要更多字节。
    • Protobuf:字段编号采用 VarInt 编码,仅需 1 字节即可完成数值与变量的对应关系(字段编号在 [1, 15] 范围内时)。这使得 Protobuf 的存储和传输效率远高于 JSON。
  2. 可读性
    • JSON:数据以文本形式存储,人类可直接阅读和理解。
    • Protobuf:数据以二进制形式存储,人类无法直接阅读,需要借助工具或解析代码查看内容。
  3. 扩展性
    • JSON:新增字段时无需修改已有结构,兼容性强。
    • Protobuf:新增字段需要为其分配一个新的字段编号,且字段编号不可重复使用。此外,字段编号 [19000, 19999] 是保留编号,不能使用。
  4. 压缩性
    • JSON:由于包含冗长的键名,压缩效果较差。
    • Protobuf:字段编号占用空间小,数据紧凑,适合高效压缩。

字段编号的设计建议

  • 字段编号范围优化
    • 字段编号采用 VarInt 编码,一个字节只有 7 位有效位,因此:字段编号在 [1, 15] 范围内时,占用 1 字节;字段编号超过 16 时,需要占用更多字节。
    • 开发时应将出现频率高的数据字段编号设置为较小值(如 [1, 15]),以提高数据压缩效率。
  • 字段编号的唯一性:每个字段编号在 message 内必须唯一,避免冲突;即使字段被废弃,其编号也不应被重新使用,以免影响版本兼容性。

小结:

  • JSON 是一种易于阅读和使用的文本格式,适合对可读性和兼容性要求较高的场景。
  • Protobuf 是一种高效的二进制格式,适合对性能和存储空间要求较高的场景。
  • Protobuf 通过字段编号实现数据与变量的映射关系,相比 JSON 的键值对方式,具有更高的存储和传输效率。

效率对比

数据类型

原始大小

Protobuf(MSB 标记位)

压缩率

int32(300)

4字节

2字节

50%

int32(-150)

4字节

3字节

25%

int64(1<<40)

8字节

6字节

25%

注意:

  1. 字段标识号应遵循 [1,
2^{29}-1

] 范围

  1. 保留字段号用于未来扩展
  2. 避免频繁创建/销毁Message对象
  3. 使用repeated字段替代数组嵌套
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-07-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、基本特点
    • 1. 序列化
    • 2. 反序列化
  • 二、ProtoBuf 数据类型
    • 1. 标量类型
    • 2. 复合类型
    • 3. 字段规则(Field Ruls)
    • 4. 其他高级类型
  • 三、关于 PB 的整型编码方式
    • 1. 定长编码
    • 2. 变长编码
    • 3. 变长编码方式
      • 3.1 Varint 编码
      • 3.2 ZigZag 编码
      • 3.3 Protobuf 中不同整型的区别
  • 三、Json vs PB
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档