一、FlatBuffers 是什么:一句话 + 一个类比
一句话:FlatBuffers 是 Google 开源的一个跨平台序列化库(Apache 2.0 协议),它把结构化数据按一种"自描述、可随机访问"的二进制布局直接平铺在内存里,读取时不做任何解析、不产生任何拷贝、不分配任何临时对象,直接通过偏移量"零拷贝"访问字段。
通俗类比:想象你要在一家餐厅点餐。普通序列化(比如 Protobuf/JSON)的流程是——菜单是一叠手写小纸条,服务员要先逐张读一遍,把菜名抄到点单系统里(这步就是"解析"),然后你才能看到自己点了什么。而 FlatBuffers 的做法是——菜单本身就是一个已经摆好的自助餐台:每个菜(字段)都固定摆在它自己的位置上,你走过去直接伸手拿就行,不需要任何人提前"翻译"。
翻译成人话:
| 传统序列化 | FlatBuffers |
|---|---|
| 收到字节流 → 先解析 → 生成内存对象 → 再访问 | 收到字节流 → 直接访问,字节流本身就是对象 |
| 解析时分配大量临时对象 | 零分配、零拷贝 |
| 想读其中一个字段,也得全量解析 | 按偏移量随机访问任意字段 |
二、为什么选 FlatBuffers:四款主流序列化方案对比
2.1 对比总表
| 维度 | FlatBuffers | Protobuf | JSON(nlohmann/json 等) | Cap'n Proto |
|---|---|---|---|---|
| 出品方 | 标准/社区 | Sandstorm.io | ||
| 反序列化方式 | 零拷贝偏移访问 | 解析到内存对象 | 解析为 DOM/节点树 | 零拷贝偏移访问 |
| 反序列化耗时 | 近似 0(无 parse) | 中(需逐字段解析) | 高 | 近似 0(无 parse) |
| 序列化耗时 | 中(Builder 构建) | 中 | 高 | 中 |
| 内存占用 | 较小(含 vtable 开销) | 最小(高度紧凑) | 大(DOM 对象) | 较大(对齐开销) |
| 随机访问单字段 | ✅ 支持 | ❌ 需全量解析 | ❌ 需全量解析 | ✅ 支持 |
| 可变(in-place 修改) | ❌ 不可变 | ✅ 可改对象 | ✅ 可改 DOM | ❌ 不可变 |
| 代码生成语言 |
C++/C#/Go/Java /Kotlin/Python /Rust/Swift/TS/Lua 等 | C++/C#/Go/Java/Python/Rust 等 | 无(运行时库) |
C++/C#/Go/Java /Python/Rust 等 |
| 适合场景 | 游戏、移动端、高频读、mmap | 通用 RPC、存储协议 | 配置、调试、Web API | 高性能 IPC、存储 |
| 可读性(二进制调试) | 可用 --json 转储 | 可用工具转储 | 天然可读 | 可用工具转储 |
2.2 与 Protobuf 的恩怨:明明是"同一家"出品
FlatBuffers 和 Protobuf 都是 Google 开源的,但定位完全不同:
- Protobuf 的哲学:把数据压缩到最小体积(Varint、ZigZag 等,详见本系列《Protobuf 底层编码机制深度解析》),换取网络带宽和存储空间的节省。代价是读取时必须先解析成内存对象,解析本身有 CPU 和内存分配开销。
- FlatBuffers 的哲学:放弃极致压缩,换取零拷贝读取。数据按对齐后的固定布局平铺,读取就是"指针 + 偏移量"的算术运算,读一个字段只需要几次内存访问。
一句话总结定位差异:追求传输体积最小选 Protobuf;追求读取速度最快、内存零分配选 FlatBuffers。 两者不是替代关系,而是"空间换时间"的两端。
三、底层原理:零拷贝到底是怎么做到的
3.1 核心思想:把"解析"变成"偏移量计算"
Protobuf 的二进制流是长度前置的紧凑编码,字段按 Varint/Tag 顺序排列,想读第 5 个字段必须从头扫到第 5 个。FlatBuffers 反其道而行:它给每个对象维护一张 VTable(虚表/偏移表),表里记录每个字段相对对象起点的偏移量。读取时:
地址 = 对象起点 + VTable 中记录的偏移量
一次加法和一次内存访问,字段就到手了,这就是"零拷贝"的本质——不是没有数据搬运,而是把"搬运"变成了"指针算术"。
3.2 Buffer 内存布局全景图
一个典型的 FlatBuffer(以 Monster 为例)在内存中的样子:
┌────────────────────────────────────────────────────────────┐ │ FlatBuffer 内存布局 │ ├────────────────────────────────────────────────────────────┤ │ 0x00: [root offset] ← uoffset_t(4字节),指向根 Table │ │ │ │ │ ▼ │ │ ┌────────────────┐ │ │ │ Monster Table │ ← 表对象本体 │ │ │ [soffset] │ ← 相对偏移(4字节),指向自己的 VTable │ │ │ pos │ ← 内联 struct Vec3(12字节) │ │ │ mana │ ← 内联 short(2字节) │ │ │ hp │ ← 内联 short(2字节) │ │ │ name │ ← uoffset_t,指向 string 数据 │ │ │ inventory │ ← uoffset_t,指向 vector<ubyte> │ │ └────────────────┘ │ │ │ │ │ ▼ │ │ ┌────────────────┐ │ │ │ VTable │ ← 存放字段偏移表 │ │ │ vtable size │ ← 2字节,VTable 自身大小 │ │ │ table size │ ← 2字节,Table 对象大小 │ │ │ field#0 offset│ ← 2字节,字段相对 Table 起点的偏移 │ │ │ field#1 offset│ ← 2字节 │ │ │ ... │ │ │ └────────────────┘ │ │ ┌────────────────┐ │ │ │ "Orc" 字符串 │ ← [uoffset 长度][UTF-8 字节] │ │ └────────────────┘ │ │ ┌────────────────┐ │ │ │ [ubyte] 向量 │ ← [uoffset 长度][元素字节...] │ │ └────────────────┘ │ └────────────────────────────────────────────────────────────┘
要点:
- root offset:整个 buffer 的第 4 个字节固定存一个 uoffset_t,指向根对象(根 Table 或根 Union),读取时先从这里出发。
- VTable:每个带 VTable 的对象(Table)头部有一个相对偏移指向自己的 VTable;VTable 里按字段序号记录每个字段相对 Table 起点的偏移,字段不存在时记 0,表示"用默认值"。
- String:uoffset_t(4 字节长度,不含 NUL)+ UTF-8 数据 + 1 字节 NUL 结尾。
- Vector:uoffset_t(4 字节元素个数)+ 元素连续排列。
- Struct:没有 VTable,字段直接内联、固定大小,访问最快;代价是 Schema 中 struct 的字段不能增删(否则破坏布局)。
3.3 VTable 与字段查找:一次读取的完整旅程
以读取 monster->hp() 为例,编译器生成的访问器大致等价于:
// 伪代码:FlatBuffers 访问器在底层做了什么
int16_t hp() const {
// 1. 从 Table 起点读 soffset,得到 VTable 地址
const uint8_t* vtable = table_ - ReadScalar<soffset_t>(table_);
// 2. 在 VTable 中按字段槽位取 voffset(字段相对 Table 的偏移)
uint16_t voffset = ReadScalar<uint16_t>(vtable + 4 + 2 * kHpFieldSlot);
// 3. 偏移为 0 表示字段不存在,返回默认值
if (voffset == 0) return 100; // Schema 中默认值
// 4. 否则从 Table 起点 + voffset 处取值
return ReadScalar<int16_t>(table_ + voffset);
}
整个过程只有 3~4 次标量读取和几次加减法,没有任何循环、没有内存分配、没有数据拷贝。
3.4 为什么所有偏移都是"相对"的
FlatBuffers 里所有的偏移量(uoffset/soffset/voffset)都是相对当前地址的差值,而不是绝对地址。这是刻意设计:
- Buffer 可以整体 memcpy 到任意新地址,甚至直接 mmap 文件到内存,所有偏移依然有效,不需要像指针那样"修正"。
- 因此 FlatBuffer 可以安全地放进共享内存、直接映射到文件、跨进程传递。
3.5 与 Protobuf 解析流程的本质差异
Protobuf 读取:字节流 ──parse──▶ 内存对象(分配/拷贝/校验)──▶ 访问字段 FlatBuffers 读取:字节流(本身就是对象)──偏移计算──▶ 访问字段
FlatBuffers 把 Protobuf 中"parse"阶段的 CPU 与内存开销直接消灭了,这是它性能神话的根本来源。
四、环境搭建:从零跑通第一个示例
4.1 第 1 步:安装 flatc 编译器
flatc 是 FlatBuffers 的官方编译器,用于把 .fbs Schema 编译成各语言代码。安装方式任选其一:
# 方式一:GitHub Release 下载预编译二进制(Windows/Linux/macOS)
# https://github.com/google/flatbuffers/releases
# 下载 flatc-<版本>-windows-x64.zip 后解压,把 flatc.exe 加入 PATH
# 方式二:包管理器(Linux/macOS)
sudo apt install flatbuffers-compiler # Ubuntu/Debian
brew install flatbuffers # macOS
# 方式三:vcpkg(Windows)
vcpkg install flatbuffers
验证安装:
flatc --version
# 输出类似:flatc version 24.3.25
4.2 第 2 步:CMake 集成(完整可运行工程)
工程目录结构:
flatbuffers_demo/ ├── CMakeLists.txt ├── monster.fbs # Schema 定义 └── main.cpp # 序列化 + 反序列化演示
CMakeLists.txt(推荐 FetchContent 方式,自动拉取并编译 flatc 与库):
cmake_minimum_required(VERSION 3.14)
project(flatbuffers_demo CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 拉取 FlatBuffers 源码(可换成 tag 版本,如 v24.3.25)
include(FetchContent)
FetchContent_Declare(
flatbuffers
GIT_REPOSITORY https://github.com/google/flatbuffers.git
GIT_TAG v24.3.25
)
FetchContent_MakeAvailable(flatbuffers)
# 由 flatc 根据 monster.fbs 生成 C++ 头文件
set(FLATBUFFERS_SCHEMA_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
set(FLATBUFFERS_GENERATE_HEADER ON) # 只生成 .h,不生成 .cpp(C++ 头文件足够)
flatbuffers_generate_headers(TARGET flatbuffers_demo
SCHEMA_FILES monster.fbs
SCHEMA_DIR ${FLATBUFFERS_SCHEMA_DIR})
add_executable(flatbuffers_demo main.cpp)
target_link_libraries(flatbuffers_demo PRIVATE flatbuffers::flatbuffers)
编译运行:
cmake -B build
cmake --build build -j
./build/flatbuffers_demo
五、具体使用方式:从 Schema 到可运行代码
5.1 第 1 步:编写 Schema(.fbs 文件)
monster.fbs(语法与 Protobuf 神似但更简洁):
// FlatBuffers Schema 文件,后缀 .fbs
namespace MyGame.Sample; // 命名空间:生成的 C++ 类会进 MyGame::Sample
// 枚举:底层类型必须是整数
enum Color : byte { Red = 0, Green, Blue = 2 }
// Struct:固定大小、内联存储、无 VTable,字段不能增删!
struct Vec3 {
x: float;
y: float;
z: float;
}
// Table:可变字段集,可增删字段(配合默认值实现兼容)
table Monster {
pos: Vec3; // 内联 struct
mana: short = 150; // 默认值 150
hp: short = 100; // 默认值 100
name: string; // 字符串
inventory: [ubyte]; // 字节向量(如装备 ID 列表)
color: Color = Blue; // 枚举字段,默认 Blue
}
// 根类型:反序列化入口 GetMonster() 使用
root_type Monster;
5.2 第 2 步:flatc 生成 C++ 代码
# 在 monster.fbs 所在目录执行,生成 monster_generated.h
flatc --cpp monster.fbs
生成的头文件里包含:CreateMonster()(构造器)、Monster 类(带访问器)、Vec3、Color 等。我们只需要 #include "monster_generated.h"。
5.3 第 3 步:序列化写入(FlatBufferBuilder)
FlatBufferBuilder 是"写入端"核心,所有字符串/向量/子对象都要先创建,再组装父对象,最后 Finish:
5.4 第 4 步:反序列化读取(零拷贝)
// 假设 buf 是收到的字节流(来自文件/网络),size 为长度
// 核心:不需要解析!直接拿到根对象指针
auto monster = MyGame::Sample::GetMonster(buf);
// 访问字段:全部走 VTable 偏移计算,零分配零拷贝
std::cout << "name = " << monster->name()->str() << "\n"; // 注意:name() 返回 String*,需要 str()
std::cout << "hp = " << monster->hp() << "\n"; // 标量直接读
std::cout << "mana = " << monster->mana() << "\n";
std::cout << "color= " << static_cast<int>(monster->color()) << "\n";
// 内联 struct:返回 const Vec3*,直接取成员
auto v = monster->pos();
std::cout << "pos = (" << v->x() << ", " << v->y() << ", " << v->z() << ")\n";
// 向量:返回 Vector<ubyte>*,可迭代、可随机访问
auto inv = monster->inventory();
for (size_t i = 0; i < inv->size(); ++i) {
std::cout << "inventory[" << i << "] = " << inv->Get(i) << "\n";
}
关键点:GetMonster(buf) 到访问字段之间,没有任何 parse、没有 malloc、没有字符串拷贝(str() 只是返回内部指针 + 长度,std::string 视图)。
5.5 第 5 步:向量、字符串、嵌套 Table、Union 完整实战
FlatBuffers 支持更复杂的结构:嵌套 Table、[string]、[Monster]、Union(多态)等。下面演示一个带嵌套与 Union 的 Schema 和读写:
weapon.fbs:
namespace Game;
// Union:类似 Protobuf 的 oneof,但读写都要先判断 type
union Equipment { Sword, Shield }
table Sword { damage: int; }
table Shield { defense: int; }
table Hero {
name: string;
weapons: [Sword]; // Table 向量(元素是偏移量)
tags: [string]; // 字符串向量
equipment: Equipment; // Union 字段
}
root_type Hero;
写入:
#include "weapon_generated.h"
namespace G = Game;
flatbuffers::FlatBufferBuilder builder(1024);
// 嵌套 Table:先创建 Sword
auto sword1 = G::CreateSword(builder, 42);
auto sword2 = G::CreateSword(builder, 17);
std::vector<flatbuffers::Offset<G::Sword>> swords = {sword1, sword2};
auto weapons_vec = builder.CreateVector(swords);
auto tags_vec = builder.CreateVectorOfStrings({"tank", "orc"});
auto name = builder.CreateString("Grom");
// Union 字段:三参数版——(union_type, union_value_offset, 默认 NONE)
auto shield = G::CreateShield(builder, 50);
auto hero = G::CreateHero(builder, name, weapons_vec, tags_vec,
G::Equipment_Shield, shield.Union()); // 注意 .Union() 包装
builder.Finish(hero);
uint8_t* buf = builder.GetBufferPointer();
读取:
auto hero = G::GetHero(buf);
// Union 读取:先看 type,再按类型取
switch (hero->equipment_type()) {
case G::Equipment_Sword: {
auto s = hero->equipment_as_Sword(); // 零拷贝拿到 Sword*
std::cout << "equipment: Sword damage=" << s->damage() << "\n";
break;
}
case G::Equipment_Shield: {
auto s = hero->equipment_as_Shield();
std::cout << "equipment: Shield defense=" << s->defense() << "\n";
break;
}
case G::Equipment_NONE:
std::cout << "equipment: none\n";
break;
}
// 向量遍历
for (auto it = hero->weapons()->begin(); it != hero->weapons()->end(); ++it) {
std::cout << "weapon damage=" << it->damage() << "\n";
}
5.6 第 6 步:与文件、网络、mmap 集成
写文件 / 读文件:
// 写入
std::ofstream out("monster.bin", std::ios::binary);
out.write(reinterpret_cast<const char*>(builder.GetBufferPointer()),
builder.GetSize());
out.close();
// 读取(注意:直接用文件内容访问,无需反序列化)
std::ifstream in("monster.bin", std::ios::binary | std::ios::ate);
size_t fsize = in.tellg();
std::vector<uint8_t> file_buf(fsize);
in.seekg(0);
in.read(reinterpret_cast<char*>(file_buf.data()), fsize);
auto monster = MyGame::Sample::GetMonster(file_buf.data()); // 零拷贝
mmap 直接映射(大文件/数据库场景):
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
int fd = open("monster.bin", O_RDONLY);
size_t len = /* 文件长度 */;
uint8_t* mapped = static_cast<uint8_t*>(
mmap(nullptr, len, PROT_READ, MAP_PRIVATE, fd, 0));
auto monster = MyGame::Sample::GetMonster(mapped); // 直接读,不占堆内存
munmap(mapped, len);
close(fd);
网络传输(带长度前缀):用 builder.Finish(monster, MyGame::Sample::MonsterIdentifier()) 加 4 字节 file identifier,或 FinishSizePrefixed() 加 4 字节长度前缀,接收端先读长度再读 buffer。
5.7 第 7 步:JSON 互转与调试
FlatBuffers 二进制不可直接阅读,但 flatc 提供双向 JSON 转换:
# 二进制 → JSON
flatc --json --raw-binary monster.fbs -- monster.bin
# JSON → 二进制
flatc -b monster.fbs -- monster.json
这让你在调试/日志阶段可以"人肉查看",线上再走二进制,两全其美。
六、Schema 演进:如何做到向前/向后兼容
FlatBuffers 的兼容规则与 Protobuf 类似但更宽松,因为 VTable 天然支持"字段缺失就用默认值":
| 操作 | 是否安全 | 说明 |
|---|---|---|
| 给 Table 新增字段 | ✅ 安全 | 旧数据读新代码:VTable 里没这个槽位,用默认值;新数据读旧代码:旧代码只读自己知道的槽位 |
| 给 Table 删除字段 | ✅ 一般安全 | 但必须保证该字段不再被使用 |
| 修改字段类型 | ❌ 危险 | 会破坏二进制布局,必须改字段名 |
| 重排字段 | ✅ 安全 | 读取按 VTable 槽位,与声明顺序解耦 |
| Struct 增删/修改字段 | ❌ 绝对禁止 | Struct 无 VTable,布局固定,改了就崩 |
| 修改枚举底层类型 | ❌ 危险 | 改变对齐与宽度 |
| Union 新增成员 | ✅ 安全 | 老客户端读到未知 type 时跳过即可 |
工程建议:
- 字段一旦发布,只增不改不删。
- 新字段必须给默认值,且默认值要"语义安全"(如 0、空串、NONE)。
- 需要"废弃"的字段留着别删,命名加 deprecated_ 前缀。
- 版本号放在文件/消息头里(如 schema 级 file_identifier + 业务字段 version),不要依赖隐式兼容。
七、常见坑与踩坑清单(预警)
- ⚠️ 忘记调用 Finish():不 Finish 的 buffer 不完整,GetBufferPointer() 拿到的不是有效根对象。Builder 使用顺序必须是"先建叶子、再建父对象、最后 Finish"。
- ⚠️ Finish() 之后继续 CreateXXX:Builder 内部状态已定格,继续创建会导致未定义行为。需要多个消息就新建 Builder 或用 Clear()。
- ⚠️ Builder 析构后 buffer 失效:GetBufferPointer() 返回的内存归 Builder 管理;Builder 销毁或 Clear() 后指针悬空。需要长期持有就 std::vector<uint8_t> copy(buf, buf + size) 拷走。
- ⚠️ 读取端用错根类型:GetMonster() 去读一个 Hero buffer 是未定义行为。自己设计的协议必须带标识(file identifier / 版本字段)来区分。
- ⚠️ name() 返回的是 const String*,不是 std::string:直接 monster->name() 打印会得到指针地址;必须 .str() 或 .c_str()。判空用 monster->name() != nullptr。
- ⚠️ Struct 传参必须取地址:CreateMonster(builder, &pos, ...) 里 pos 必须是左值并取 &,传临时对象是编译错误。
- ⚠️ Union 忘记处理 NONE:equipment_type() 可能是 Equipment_NONE,直接 equipment_as_Sword() 会返回空指针。
- ⚠️ 对齐陷阱:Schema 里 float/double/int64 字段会引入对齐填充,buffer 比"理论最小值"大是正常的。要极致紧凑就别滥用 8 字节标量。
- ⚠️ 共享内存/mmap 只读场景:FlatBuffer 不可变,想改数据必须重建整个 buffer,不要尝试原地改。
- ⚠️ 跨语言协作时字段名大小写敏感:flatc 生成的访问器名与 Schema 字段名强绑定,C++ 侧约定俗成用 lowerCamelCase,别和别的语言写岔。
八、性能对比:为什么说它"反序列化 0 开销"
以读一个 10 字段、含 1 个字符串和 1 个向量的对象为例,业界常见量级(不同机器有差异,仅供参考思路):
| 操作 | Protobuf | FlatBuffers | 说明 |
|---|---|---|---|
| 反序列化 | ~200-600 ns | ~0-20 ns | FlatBuffers 无 parse;Protobuf 要建对象树 |
| 反序列化内存分配 | 多次 malloc | 0 次 | 直接读 buffer |
| 读单个标量字段 | 需先 parse | 几次加减 + 解引用 | 差距可达 10~100 倍 |
| 序列化体积 | 更小(Varint) | 略大(对齐 + vtable) | Protobuf 一般省 10%~40% |
适用场景速查:
- ✅ 游戏客户端/服务器高频协议:每次读都要 parse 的 Protobuf 会成为瓶颈,FlatBuffers 把读开销压到极限。
- ✅ 移动端(减少 GC 停顿):反序列化零分配,避免频繁触发 GC。
- ✅ 配置表/数据表加载:mmap 后直接当对象用,启动速度极快。
- ✅ 需要随机访问大对象(如地图数据):只读用到的部分,不必全量加载。
- ❌ 传输带宽是瓶颈、且读频率低:Protobuf 体积更小更合适。
- ❌ 需要频繁增删改数据:FlatBuffers 不可变,重建成本高。
九、FAQ 速查表
| 问题 | 答案 |
|---|---|
| FlatBuffers 是免费的吗? | 是,Apache 2.0 开源协议,可商用 |
| 和 Protobuf 怎么选? | 读多写少、追求零拷贝/低延迟选 FlatBuffers;追求最小体积、需要修改对象选 Protobuf |
| 支持哪些语言? | C++、C#、Go、Java、Kotlin、Python、Rust、Swift、TypeScript、Lua、Dart、PHP、C 等 |
| 需要运行时库吗? | C++ 是 header-only(flatbuffers.h),无动态链接负担 |
| 读之前需要做什么? | 什么都不用做,GetRootXXX(buf) 直接访问 |
| 能热更新加字段吗? | 能,按第六节兼容规则"只增不删不改"即可 |
| 二进制怎么调试? | flatc --json --raw-binary schema.fbs -- data.bin |
| 线程安全吗? | Builder 不共享;已完成的 buffer 可多线程并发只读 |
| 能直接 mmap 文件读吗? | 能,这是它的招牌能力之一 |
| 有官方性能基准吗? | GitHub 仓库 docs 里有与 Protobuf 的对比数据和 benchmark 代码 |
附:本篇文章的核心记忆点
- 零拷贝 = 读取时不做 parse、不分配、不拷贝,只做"指针 + 偏移"算术。
- VTable 是 FlatBuffers 的灵魂:字段偏移表让"随机访问任意字段"成为可能。
- 相对偏移 让 buffer 可整体 memcpy / mmap,天然适合共享内存与文件映射。
- Schema 演进:Table 可增字段、Struct 完全不能动。
- 与 Protobuf 是互补关系:一个为"体积最小",一个为"读取最快"。

351

被折叠的 条评论
为什么被折叠?



