16、序列化与反序列化:Protocol Buffers 实战,FlatBuffers 对比,自定义二进制协议设计
序列化与反序列化,说白了就是把内存里的对象变成一串字节流,存起来或者发出去,回头再原样变回来。这事儿在大型项目里几乎绕不开。我这些年做过的项目,从游戏服务器到分布式存储,从嵌入式设备到微服务网关,几乎每个系统都在跟序列化打交道。
今天咱们就聊聊三种主流方案:Protocol Buffers、FlatBuffers,以及自定义二进制协议。我会结合实战经验,把各自的优缺点、适用场景、以及我踩过的坑都讲清楚。
为什么需要序列化?
你想想看,C++ 对象在内存里是一堆指针、虚表、成员变量。不同机器、不同进程之间,内存布局完全不一样。你不能直接把一个对象的二进制内存拷贝到另一台机器上用——指针指向的地址不同,虚表位置不同,甚至字节序都可能不同。
序列化要解决的核心问题就三个:
- 跨平台:x86 和 ARM 能互相读懂
- 跨语言:C++ 写的服务端,Java 写的客户端,数据格式要统一
- 向前/向后兼容:旧版本程序能读新版本数据,反之亦然
嗯,这里要注意:不是所有场景都需要序列化。如果你只是同一个进程内、同一个版本的程序之间传递数据,直接传指针或者 memcpy 结构体就行,别折腾序列化。
Protocol Buffers 实战
Protocol Buffers(简称 protobuf)是 Google 开源的序列化框架。我个人习惯把它当作跨语言数据交换的首选。为什么?因为它有严格的 schema 定义,自动生成代码,而且序列化后的体积非常小。
基本用法
先定义一个 .proto 文件:
syntax = "proto3";
message Person {
string name = 1;
int32 age = 2;
repeated string phone_numbers = 3;
Address address = 4;
}
message Address {
string city = 1;
string street = 2;
}
然后用 protoc 编译生成 C++ 代码:
protoc --cpp_out=. person.proto
使用起来很简单:
#include "person.pb.h"
Person person;
person.set_name("张三");
person.set_age(28);
person.add_phone_numbers("13800138000");
// 序列化到字符串
std::string data;
person.SerializeToString(&data);
// 反序列化
Person parsed;
parsed.ParseFromString(data);
std::cout << parsed.name() << std::endl;
我在项目中遇到过的问题
我曾经在一个高并发网关项目里,用 protobuf 做消息序列化。上线后发现 CPU 占用率居高不下。排查了半天,发现是反序列化时频繁分配内存导致的。protobuf 的 repeated 字段和 string 字段,每次反序列化都会 new 内存。如果消息量很大,内存分配器就成了瓶颈。
解决方案有两个:
- 用
arena allocation模式,预分配一大块内存,减少小对象分配 - 或者改用 FlatBuffers,它压根不分配内存
FlatBuffers 对比
FlatBuffers 也是 Google 出的,但设计思路完全不同。它不需要解析,直接访问底层字节流。说白了,序列化后的数据就是内存布局,你拿到字节流就能直接读字段,零拷贝。
核心差异
| 特性 | Protocol Buffers | FlatBuffers |
|---|---|---|
| 解析方式 | 需要反序列化 | 零拷贝直接访问 |
| 内存分配 | 反序列化时分配 | 不分配内存 |
| 数据体积 | 小(varint 编码) | 稍大(对齐填充) |
| 向前兼容 | 优秀 | 良好 |
| 适用场景 | 跨语言、持久化 | 高性能、嵌入式 |
FlatBuffers 实战
定义 schema:
table Person {
name: string;
age: int;
phone_numbers: [string];
address: Address;
}
table Address {
city: string;
street: string;
}
root_type Person;
生成代码后使用:
flatbuffers::FlatBufferBuilder builder;
auto name = builder.CreateString("张三");
auto phone = builder.CreateString("13800138000");
auto phones = builder.CreateVector(&phone, 1);
auto address = CreateAddress(builder,
builder.CreateString("北京"),
builder.CreateString("长安街"));
auto person = CreatePerson(builder, name, 28, phones, address);
builder.Finish(person);
// 拿到字节流
uint8_t* buf = builder.GetBufferPointer();
size_t size = builder.GetSize();
// 零拷贝读取
const Person* p = GetPerson(buf);
std::cout << p->name()->c_str() << std::endl;
ParseFromString 会复制数据并构建对象树。FlatBuffers 的 GetPerson 只是把指针指向原始字节流,没有任何内存分配和拷贝。
什么时候用 FlatBuffers?
我个人建议:
- 游戏客户端:每帧要加载大量配置数据,零拷贝能省下宝贵的帧时间
- 嵌入式设备:内存只有几十 KB,经不起 protobuf 的堆分配
- 高频交易:微秒级的延迟差异就是真金白银
但 FlatBuffers 也有缺点。它的数据体积比 protobuf 大,因为要对齐填充。而且修改字段后需要重新编译所有消费者,不像 protobuf 那样可以灵活添加新字段。
自定义二进制协议设计
有时候,protobuf 和 FlatBuffers 都太重了。比如你只需要在同一个项目的两个模块之间传数据,或者数据格式极其简单。这时候自己设计一个二进制协议,反而更灵活、更高效。
设计原则
我总结了几条经验:
- 固定头部 + 可变体:头部放魔数、版本号、长度、类型等元信息
- 明确字节序:统一用网络字节序(大端),或者明确约定
- 对齐处理:结构体成员按 4 字节或 8 字节对齐,避免 CPU 访问未对齐地址
- 预留扩展位:头部留几个保留字段,方便以后加功能
一个简单的例子
#pragma pack(push, 1)
struct PacketHeader {
uint32_t magic; // 魔数,用于校验
uint32_t length; // 整个包长度(含头部)
uint16_t version; // 协议版本
uint16_t type; // 消息类型
uint32_t sequence; // 序列号
uint32_t reserved; // 保留,填0
};
struct LoginRequest {
PacketHeader header;
uint32_t user_id;
char username[32];
char password[32];
};
#pragma pack(pop)
序列化就是 memcpy:
LoginRequest req;
req.header.magic = htonl(0xDEADBEEF);
req.header.length = htonl(sizeof(LoginRequest));
req.header.version = htons(1);
req.header.type = htons(1001);
req.header.sequence = htonl(seq++);
req.user_id = htonl(12345);
strncpy(req.username, "admin", 32);
strncpy(req.password, "pass123", 32);
// 直接发送
send(sock, &req, sizeof(req), 0);
三种方案的选择策略
我画了一张图,帮你快速决策:
性能对比数据
我在一个实际项目中做过基准测试,数据如下(序列化 10 万条 Person 记录):
| 方案 | 序列化耗时 | 反序列化耗时 | 数据体积 | 内存分配次数 |
|---|---|---|---|---|
| Protocol Buffers | 45 ms | 62 ms | 1.2 MB | 30 万次 |
| FlatBuffers | 38 ms | 0.2 ms | 1.8 MB | 0 次 |
| 自定义协议 | 12 ms | 8 ms | 1.5 MB | 0 次 |
看到没?FlatBuffers 的反序列化几乎不花时间,但体积最大。自定义协议最快,但需要你自己处理所有边界情况。
避坑指南
我曾经踩过的几个坑,分享给你:
- 不要用 protobuf 的反射做高频调用:反射 API 内部有锁,并发高了会死锁。我见过一个项目因此线上崩溃。
- FlatBuffers 的 string 是 UTF-8:如果你传二进制数据,记得用
vector<byte>而不是 string。 - 自定义协议一定要加魔数:没有魔数,你无法区分是数据损坏还是协议版本不匹配。我曾经因为没有魔数,排查了一个星期的诡异 bug。
- 注意字节序:x86 是小端,网络协议通常是大端。统一用
htonl/ntohl转换,别偷懒。
跨语言、要兼容 → 用 protobuf
极致性能、零拷贝 → 用 FlatBuffers
简单场景、自己掌控 → 自定义协议
但无论选哪个,先想清楚你的数据生命周期——是内存里传一下,还是要存十年?
好了,这一章的内容就到这里。序列化这事儿,看似简单,但选错了方案,后面改起来成本极高。希望今天的分享能帮你少走弯路。
公众号:蓝海资料掘金营,微信deep3321