16、序列化与反序列化:Protocol Buffers 实战,FlatBuffers 对比,自定义二进制协议设计

序列化与反序列化,说白了就是把内存里的对象变成一串字节流,存起来或者发出去,回头再原样变回来。这事儿在大型项目里几乎绕不开。我这些年做过的项目,从游戏服务器到分布式存储,从嵌入式设备到微服务网关,几乎每个系统都在跟序列化打交道。

今天咱们就聊聊三种主流方案:Protocol BuffersFlatBuffers,以及自定义二进制协议。我会结合实战经验,把各自的优缺点、适用场景、以及我踩过的坑都讲清楚。

为什么需要序列化?

你想想看,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 的字段编号(1、2、3...)不是随便写的。1-15 号字段只占 1 个字节的 tag,16-2047 号字段占 2 个字节。所以高频字段尽量用 1-15 号,能省不少空间。

我在项目中遇到过的问题

我曾经在一个高并发网关项目里,用 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;
关键区别:protobuf 的 ParseFromString 会复制数据并构建对象树。FlatBuffers 的 GetPerson 只是把指针指向原始字节流,没有任何内存分配和拷贝。

什么时候用 FlatBuffers?

我个人建议:

  • 游戏客户端:每帧要加载大量配置数据,零拷贝能省下宝贵的帧时间
  • 嵌入式设备:内存只有几十 KB,经不起 protobuf 的堆分配
  • 高频交易:微秒级的延迟差异就是真金白银

但 FlatBuffers 也有缺点。它的数据体积比 protobuf 大,因为要对齐填充。而且修改字段后需要重新编译所有消费者,不像 protobuf 那样可以灵活添加新字段。

自定义二进制协议设计

有时候,protobuf 和 FlatBuffers 都太重了。比如你只需要在同一个项目的两个模块之间传数据,或者数据格式极其简单。这时候自己设计一个二进制协议,反而更灵活、更高效。

设计原则

我总结了几条经验:

  1. 固定头部 + 可变体:头部放魔数、版本号、长度、类型等元信息
  2. 明确字节序:统一用网络字节序(大端),或者明确约定
  3. 对齐处理:结构体成员按 4 字节或 8 字节对齐,避免 CPU 访问未对齐地址
  4. 预留扩展位:头部留几个保留字段,方便以后加功能

一个简单的例子

#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);
警告:自定义协议最大的坑是兼容性。我曾经在一个项目里,上线后要加一个新字段,结果发现所有旧客户端都得升级,否则解析会错位。从那以后,我学乖了——要么用 protobuf,要么在头部加一个字段偏移表,让解析器能跳过未知字段。

三种方案的选择策略

我画了一张图,帮你快速决策:

序列化方案选择决策树 需要序列化? 是 → 继续 否 → 直接 memcpy 跨语言/持久化? 极致性能/零拷贝? Protocol Buffers 兼容性好,体积小 FlatBuffers 零拷贝,低延迟 格式简单、不跨语言、不持久化? 自定义二进制协议 灵活,无依赖,但需自行维护兼容性

性能对比数据

我在一个实际项目中做过基准测试,数据如下(序列化 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