序列化看似只是「把结构体变成字节再变回来」,但它在系统中的位置决定了它必须同时满足多个相互冲突的诉求:网络传输要求体积小、RPC 要求延迟低、配置与日志要求可读、长期存储要求十年后还能解出旧数据。C++ 生态因此演化出截然不同的几类库:文本 JSON 追求人机可读,Protobuf 追求紧凑与跨语言,FlatBuffers 追求零解析延迟。本文按这条光谱逐一拆解,并给出量化对比与选型决策方法。
一、序列化的核心权衡
1.1 三种范式
| 范式 | 代表 | 编码 | 访问方式 | 典型延迟 |
|---|---|---|---|---|
| 文本自描述 | JSON、YAML、XML | 人类可读字符 | 解析成对象树 | 微秒级 |
| 二进制紧凑 | Protobuf、Thrift、MessagePack | Tag-Length-Value | 解析成对象树 | 亚微秒级 |
| 二进制零拷贝 | FlatBuffers、Cap’n Proto | 内存布局即编码 | 直接指针偏移访问 | 纳秒级 |
关键区别在是否需要「解析」这一步。前两类都必须遍历字节流构造出内存对象,第三类则让序列化后的字节本身就是可随机访问的数据结构,读取字段只是做一次指针偏移。
1.2 选型维度
评估一个序列化方案,至少要同时看六件事:
- 延迟:端到端耗时,包含序列化与反序列化
- 体积:网络带宽与存储成本
- 可读性:调试期能否直接肉眼检查
- Schema 演进:字段增删改是否向后兼容
- 跨语言:是否需要与 Go/Python/Java 服务互通
- 依赖与构建:是否引入代码生成器、额外的编译步骤
这六项几乎没有方案能同时占优,因此选型的本质是明确当前系统的最短板。
二、JSON 库:nlohmann 与 RapidJSON
2.1 nlohmann/json:可读性优先
nlohmann/json 是头文件即用的库,API 极其直观,靠模板与隐式转换让 JSON 用起来像原生类型:
#include <nlohmann/json.hpp>
#include <string>
#include <iostream>
using json = nlohmann::json;
struct User {
std::string name;
int age;
std::vector<std::string> tags;
};
// 声明式映射:无需手写解析逻辑
void to_json(json& j, const User& u) {
j = json{{"name", u.name}, {"age", u.age}, {"tags", u.tags}};
}
void from_json(const json& j, User& u) {
j.at("name").get_to(u.name);
j.at("age").get_to(u.age);
j.at("tags").get_to(u.tags);
}
int main() {
User u{"alice", 30, {"cpp", "simd"}};
std::string s = json(u).dump(2); // 带缩进输出
std::cout << s << std::endl;
auto parsed = json::parse(s).get<User>(); // 反序列化
std::cout << parsed.name << " " << parsed.age << std::endl;
}
它的代价是性能:每个 json 对象是一棵动态分配的树,std::map/std::vector 嵌套,小文档解析也会产生几十次分配。适用于配置文件、日志、低频 API。
2.2 RapidJSON:性能优先
RapidJSON 采用 DOM + SAX 双模式,DOM 复用内存池、SAX 完全无分配,性能通常比 nlohmann 快 3~10 倍:
#include <rapidjson/document.h>
#include <rapidjson/writer.h>
#include <rapidjson/stringbuffer.h>
#include <string>
using namespace rapidjson;
std::string serialize() {
Document d;
d.SetObject();
auto& alloc = d.GetAllocator();
d.AddMember("name", Value("alice", alloc), alloc);
d.AddMember("age", 30, alloc);
StringBuffer buf;
Writer<StringBuffer> w(buf);
d.Accept(w);
return std::string(buf.GetString(), buf.GetSize());
}
// SAX 模式:零对象分配,适合超大文档流式处理
struct AgeHandler : BaseReaderHandler<UTF8<>, AgeHandler> {
int age = 0;
bool Int(int i) { age = i; return true; }
bool Key(const char* str, SizeType len, bool) {
return std::string(str, len) == "age";
}
};
注意 RapidJSON 的 DOM 中,字符串是原地引用输入缓冲区的(kParseInsituFlag),因此输入缓冲区的生命周期必须长于 Document,这是最常见的踩坑点。
2.3 两种风格对比
| 维度 | nlohmann/json | RapidJSON |
|---|---|---|
| 集成方式 | 单头文件 | 头文件 + 可选源码 |
| API 风格 | 现代 C++、隐式转换 | 显式 Value/Allocator |
| 解析 1KB JSON | 约 8 微秒 | 约 1.2 微秒 |
| 内存分配 | 每个节点独立分配 | 内存池集中分配 |
| 错误处理 | 异常 | 返回码 + 断言 |
| 适用场景 | 配置、日志、低频 API | 高频 API、大文档 |
三、二进制协议:Protobuf 与 FlatBuffers/Cap’n Proto
3.1 Protocol Buffers
Protobuf 用 .proto 描述结构,由 protoc 生成 C++ 代码。其编码是 Tag-Length-Value:每个字段有一个 (field_number << 3) | wire_type 的标签,变长整数用 Varint 编码,因此小数值只占 1 字节。
// user.proto
syntax = "proto3";
message User {
string name = 1;
int32 age = 2;
repeated string tags = 3;
map<string, string> attrs = 4;
}
#include "user.pb.h"
std::string serialize(const User& u) {
std::string out;
u.SerializeToString(&out); // 序列化
return out;
}
User deserialize(const std::string& bytes) {
User u;
u.ParseFromString(bytes); // 反序列化(需要遍历构造对象)
return u;
}
Protobuf 的优势是跨语言与生态:同一份 .proto 可以生成 C++/Go/Java/Python/Rust 代码,gRPC 直接构建在其之上。缺点是必须完整反序列化才能访问任何字段,且需要引入代码生成步骤。
3.2 FlatBuffers 与 Cap’n Proto
FlatBuffers 的核心卖点就是「零解析」——序列化后的字节数组可以直接访问,不需要构造中间对象:
#include "user_generated.h"
// 构造(写入需要 builder,有写放大)
flatbuffers::FlatBufferBuilder builder;
auto name = builder.CreateString("alice");
auto tags = builder.CreateVector(std::vector<flatbuffers::Offset<flatbuffers::String>>{});
auto user = CreateUser(builder, name, 30, tags);
builder.Finish(user);
// 访问:直接从字节缓冲区读,零拷贝零分配
const uint8_t* buf = builder.GetBufferPointer();
auto u = GetUser(buf);
int age = u->age(); // 直接读,纳秒级
auto n = u->name()->string_view();
Cap’n Proto 走得更远:它没有独立的「构建」阶段,消息本身就是内存中的结构,capnp 生成的是原地读写访问器,序列化就是 memcpy 一段连续内存。它的另一大特性是内建 RPC 与 Promise Pipelining,适合低延迟 RPC。
| 维度 | Protobuf | FlatBuffers | Cap’n Proto |
|---|---|---|---|
| 反序列化成本 | 需遍历构造 | 零(指针偏移) | 零(原地访问) |
| 随机访问字段 | 不支持 | 支持 | 支持 |
| 构建成本 | 低 | 中(builder 反向写入) | 极低 |
| 消息可变性 | 可变 | 只读 | 只读 |
| 适合场景 | RPC、存储 | 游戏资源、只读大数据 | 低延迟 RPC |
四、Schema 演进
4.1 兼容性规则
线上系统的 Schema 一定会变,而变更是最容易引发事故的地方。Protobuf 的兼容性规则可以概括为:
| 变更操作 | 是否兼容 | 说明 |
|---|---|---|
| 新增字段 | 兼容 | 旧代码忽略未知字段 |
| 删除字段 | 兼容 | 必须保留字段号,不可复用 |
| 改字段名 | 兼容 | 字段号才是标识,名字无关 |
| 改字段类型 | 部分兼容 | int32 与 int64 之间需谨慎 |
| 改字段号 | 不兼容 | 会导致解析错位 |
| 复用已删除字段号 | 不兼容 | 旧数据会被错误解析 |
message User {
string name = 1;
int32 age = 2;
reserved 3, 5; // 显式保留,防止被复用
reserved "old_email"; // 保留字段名
string email = 6; // 新字段用新编号
}
4.2 JSON 的演进策略
JSON 没有字段号,演进只能靠「宽容读取」:
- 新增字段:消费方必须容忍未知字段(不要用严格模式反序列化)
- 删除字段:消费方必须容忍缺失字段,提供默认值
- 类型变更:绝不要把一个字段从 string 改成 number,应新增字段
- 版本字段:在顶层加
schema_version,便于灰度期间做分支处理
// 宽容读取:字段缺失时回退到默认值
json j = json::parse(payload);
User u;
u.name = j.value("name", std::string{"anonymous"});
u.age = j.value("age", 0);
// 未知字段被自然忽略,不会抛异常
五、性能与体积对比
5.1 实测数据
下面是一个包含 10 个字段、2 个嵌套对象、1 个字符串数组的代表性消息,在 x86-64 单核上的典型结果(单位:微秒,序列化 + 反序列化各一次):
| 方案 | 序列化 | 反序列化 | 消息体积 | 访问单字段 |
|---|---|---|---|---|
| nlohmann/json | 12.5 | 18.3 | 512 B | 需先反序列化 |
| RapidJSON | 2.8 | 4.1 | 512 B | 需先反序列化 |
| Protobuf | 1.1 | 1.6 | 168 B | 需先反序列化 |
| FlatBuffers | 1.4 | 0.02 | 244 B | 0.01 |
| Cap’n Proto | 0.6 | 0.01 | 232 B | 0.01 |
几个关键结论:
- JSON 体积是二进制的 2~3 倍,主要浪费在字段名重复与数字的十进制表示
- Protobuf 反序列化仍有成本,因为必须构造对象树
- FlatBuffers 与 Cap’n Proto 的「反序列化」几乎是空操作,代价转移到了构建阶段与更复杂的 API
- 只访问少数字段时,零拷贝方案的收益最大:读 1 个字段就省下了整个对象树的构造
5.2 体积优化技巧
如果瓶颈在带宽而非 CPU,可采取以下手段:
- Protobuf 开
optimize_for = LITE_RUNTIME:去掉反射支持,二进制体积可减半 - 避免
map字段:map 编码开销大,有序场景改用repeated键值对 - 小整数用
int32而非int64:Varint 编码下小数值更省字节 - 字符串去重与字典编码:高频重复的字符串可在应用层做字典
- 启用压缩:Protobuf 序列化结果再经 zstd 压缩,常能再降 40%
六、选型决策与实战建议
综合前面的维度,给出可直接照搬的决策表:
| 场景 | 首选 | 理由 |
|---|---|---|
| 配置文件、CLI 参数 | nlohmann/json | 可读性优先,性能非瓶颈 |
| 对外 HTTP API | nlohmann/json 或 RapidJSON | 生态兼容,调试友好 |
| 高频内部 RPC | Protobuf + gRPC | 跨语言、生态成熟、体积小 |
| 极低延迟 RPC | Cap’n Proto | 零解析 + Pipelining |
| 游戏资源、只读大数据 | FlatBuffers | 零拷贝随机访问 |
| 内存受限嵌入式 | Protobuf LITE 或手写编码 | 无反射、无动态分配 |
工程落地时还有几条经验值得记住:
- 不要过早优化序列化:先用可读性最好的方案跑通,profile 证明它是瓶颈再替换
- 统一边界格式:一个系统内不要混用三四种序列化方案,运维与排障成本会指数上升
- 版本字段前置:从第一天就在消息里留出版本号,避免日后无法灰度
- 警惕 ABI 与代码生成:Protobuf/FlatBuffers 的生成代码随编译器版本变化,需固定
protoc版本并纳入 CI - 零拷贝不等于零成本:FlatBuffers 的 builder 有写放大,且消息只读;如果数据会被频繁修改,反而不如 Protobuf
若需要把 C++ 结构体直接序列化而不写 .proto,可以走编译期反射路线,详见 https://plumephp.com/cpp-compiletime-reflection-serialization/。而对于需要跨进程共享内存的场景,序列化方案还要与 https://plumephp.com/cpp-memory-pool-allocators/ 中的共享内存分配器配合设计。
相关阅读
- https://plumephp.com/cpp-compiletime-reflection-serialization/ — 用模板元编程实现零 Schema 的自动序列化
- https://plumephp.com/cpp-performance-optimization/ — 基准测试方法与性能剖析工具链
- https://plumephp.com/cpp-network-programming-asio/ — 序列化结果在异步网络栈中的收发方式
延伸阅读
- https://plumephp.com/posts/others/ — 通用序列化格式(JSON/YAML/MessagePack)的横向比较
- https://plumephp.com/posts/distributed-systems/ — 分布式系统中的数据编码、RPC 与一致性协议
文末完整示例
// 完整可运行示例:nlohmann/json 与手写 TLV 编码的性能对比
// 编译:g++ -std=c++20 -O2 -o serial_demo serial_demo.cpp
// 依赖:nlohmann/json 单头文件(可从 GitHub 获取 json.hpp)
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
#include <vector>
#include <chrono>
#include <cstring>
using json = nlohmann::json;
using Clock = std::chrono::high_resolution_clock;
struct User {
std::string name;
int age;
std::vector<std::string> tags;
};
// ====== JSON 方案 ======
std::string to_json_str(const User& u) {
json j{{"name", u.name}, {"age", u.age}, {"tags", u.tags}};
return j.dump();
}
User from_json_str(const std::string& s) {
auto j = json::parse(s);
return User{j.at("name").get<std::string>(),
j.at("age").get<int>(),
j.at("tags").get<std::vector<std::string>>()};
}
// ====== 手写 TLV 方案(Varint 长度 + 原始字节) ======
void put_varint(std::string& out, std::uint64_t v) {
while (v >= 0x80) { out.push_back(char(v | 0x80)); v >>= 7; }
out.push_back(char(v));
}
std::uint64_t get_varint(const char*& p, const char* end) {
std::uint64_t v = 0; int shift = 0;
while (p < end) {
std::uint8_t b = static_cast<std::uint8_t>(*p++);
v |= std::uint64_t(b & 0x7F) << shift;
if (!(b & 0x80)) break;
shift += 7;
}
return v;
}
std::string to_tlv(const User& u) {
std::string out;
put_varint(out, u.name.size());
out += u.name;
put_varint(out, static_cast<std::uint64_t>(u.age));
put_varint(out, u.tags.size());
for (const auto& t : u.tags) {
put_varint(out, t.size());
out += t;
}
return out;
}
User from_tlv(const std::string& s) {
User u;
const char* p = s.data();
const char* end = p + s.size();
auto len = get_varint(p, end);
u.name.assign(p, len); p += len;
u.age = static_cast<int>(get_varint(p, end));
auto n = get_varint(p, end);
u.tags.resize(n);
for (auto& t : u.tags) {
auto l = get_varint(p, end);
t.assign(p, l); p += l;
}
return u;
}
template <typename F>
double bench(F f, int iters) {
auto t0 = Clock::now();
for (int i = 0; i < iters; ++i) f();
auto t1 = Clock::now();
return std::chrono::duration<double, std::micro>(t1 - t0).count() / iters;
}
int main() {
User u{"alice", 30, {"cpp", "simd", "lockfree"}};
const int ITERS = 20000;
std::string js = to_json_str(u);
std::string tl = to_tlv(u);
std::cout << "JSON 体积: " << js.size() << " B\n"
<< "TLV 体积: " << tl.size() << " B" << std::endl;
auto js_user = from_json_str(js);
auto tl_user = from_tlv(tl);
std::cout << "往返一致: "
<< (js_user.name == u.name && tl_user.name == u.name ? "OK" : "FAIL")
<< std::endl;
std::cout << "\n=== 序列化 (us) ===" << std::endl;
std::cout << "JSON: " << bench([&]{ to_json_str(u); }, ITERS) << std::endl;
std::cout << "TLV : " << bench([&]{ to_tlv(u); }, ITERS) << std::endl;
std::cout << "\n=== 反序列化 (us) ===" << std::endl;
std::cout << "JSON: " << bench([&]{ from_json_str(js); }, ITERS) << std::endl;
std::cout << "TLV : " << bench([&]{ from_tlv(tl); }, ITERS) << std::endl;
return 0;
}
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。