C++ 序列化库选型实战:从 JSON 到 FlatBuffers

序列化是分布式系统与持久化存储的基础设施,选错库会让延迟、体积与可维护性同时受损。本文横向对比 C++ 生态中最常用的序列化方案:文本侧的 nlohmann/json 与 RapidJSON、二进制侧的 Protocol Buffers 与 FlatBuffers/Cap'n Proto,剖析各自的编码格式与零拷贝原理,给出 Schema 演进与向后兼容规则,附上吞吐量、延迟与序列化体积的实测数据,并总结一套可落地的选型决策表。

序列化看似只是「把结构体变成字节再变回来」,但它在系统中的位置决定了它必须同时满足多个相互冲突的诉求:网络传输要求体积小、RPC 要求延迟低、配置与日志要求可读、长期存储要求十年后还能解出旧数据。C++ 生态因此演化出截然不同的几类库:文本 JSON 追求人机可读,Protobuf 追求紧凑与跨语言,FlatBuffers 追求零解析延迟。本文按这条光谱逐一拆解,并给出量化对比与选型决策方法。

一、序列化的核心权衡

1.1 三种范式

范式代表编码访问方式典型延迟
文本自描述JSON、YAML、XML人类可读字符解析成对象树微秒级
二进制紧凑Protobuf、Thrift、MessagePackTag-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/jsonRapidJSON
集成方式单头文件头文件 + 可选源码
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。

维度ProtobufFlatBuffersCap’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/json12.518.3512 B需先反序列化
RapidJSON2.84.1512 B需先反序列化
Protobuf1.11.6168 B需先反序列化
FlatBuffers1.40.02244 B0.01
Cap’n Proto0.60.01232 B0.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 APInlohmann/json 或 RapidJSON生态兼容,调试友好
高频内部 RPCProtobuf + gRPC跨语言、生态成熟、体积小
极低延迟 RPCCap’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;
}

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ 移动语义与完美转发:从右值引用到引用折叠
  2. C++ 模糊测试与覆盖率:libFuzzer、AFL++ 与 Sanitizer
  3. C++ 无锁数据结构:栈、队列与安全内存回收