《游戏服务端编程实践》3.2.1 Protobuf 与 FlatBuffers
By Leeting Yan
一、序列化的意义:从内存结构到可传输字节
在网络通信中,我们需要把“内存对象”转换为“字节流”:
graph LR
A[结构体 / 对象] -->|序列化| B[字节流]
B -->|网络传输| C[对象重建]
这个过程称为:
- 序列化(Serialization):内存 → 字节;
- 反序列化(Deserialization):字节 → 内存。
序列化的核心目标:
- 让不同语言、不同平台理解同一份数据结构;
- 在传输和存储时尽可能节省带宽与 CPU。
二、文本 vs 二进制序列化的对比
| 特征 | 文本类(JSON/XML) | 二进制类(Protobuf/FlatBuffers) |
|---|---|---|
| 可读性 | ✅ 高 | ❌ 低 |
| 体积 | ❌ 大 | ✅ 小(~1/3) |
| 序列化速度 | ❌ 慢 | ✅ 快 |
| 反序列化速度 | ❌ 慢 | ✅ 快 |
| 兼容性 | ✅ 好 | ✅ 好(需 schema) |
| 适用场景 | 配置、日志 | 游戏通信、RPC、嵌入式 |
游戏服务器要求“极限性能”,因此几乎都使用二进制序列化格式。
三、Protocol Buffers(Protobuf)
3.1 基本概念
Protobuf 是一种基于 schema 的二进制序列化协议。
由 Google 开发,特点是结构紧凑、跨语言、自动生成代码。
通过 .proto 文件定义消息结构:
syntax = "proto3";
message Player {
int64 id = 1;
string name = 2;
int32 level = 3;
repeated Item inventory = 4;
}
message Item {
int32 id = 1;
int32 count = 2;
}
编译后自动生成:
- Java 类;
- Go 结构;
- Python/TypeScript 文件;
可直接用于读写网络消息。
3.2 编码原理:Varint + Tag-Length-Value
Protobuf 的二进制格式极度紧凑,采用 Tag-Length-Value(TLV) 结构:
| 字段 | 编码方式 |
|---|---|
| 字段号(tag) | Varint 编码 |
| 类型 | wire type |
| 值 | 按类型存储 |
示例编码:
0a 07 50 6c 61 79 65 72 31
表示:
- 字段号 1;
- 类型 string;
- 值 “Player1”。
3.3 优势
| 优点 | 说明 |
|---|---|
| ✅ 压缩率高 | 平均比 JSON 小 70% |
| ✅ 序列化快 | 适合高频网络通信 |
| ✅ 跨语言支持 | Java、Go、C++、C#、Python 全兼容 |
| ✅ Schema 演化性好 | 新字段可向后兼容 |
| ✅ RPC 集成 | 可直接用于 gRPC |
3.4 缺点
| 缺点 | 说明 |
|---|---|
| ❌ 不支持随机访问 | 需完整反序列化 |
| ❌ 无零拷贝(zero-copy) | 需要解析重建对象 |
| ❌ 调试难 | 不可读 |
| ❌ 不适合频繁修改 schema | 编译成本高 |
3.5 Protobuf 编解码示例(Java)
Player p = Player.newBuilder()
.setId(1001)
.setName("Alice")
.setLevel(20)
.build();
// 序列化
byte[] data = p.toByteArray();
// 反序列化
Player copy = Player.parseFrom(data);
3.6 Protobuf 编解码示例(Go)
player := &pb.Player{
Id: 1001,
Name: "Alice",
Level: 20,
}
data, _ := proto.Marshal(player)
var copy pb.Player
proto.Unmarshal(data, ©)
pb包来自protoc --go_out=. player.proto自动生成的代码。
3.7 性能表现(JSON vs Protobuf)
| 格式 | 序列化(ms) | 反序列化(ms) | 数据大小(Bytes) |
|---|---|---|---|
| JSON | 4.2 | 5.6 | 880 |
| Protobuf | 0.9 | 1.1 | 320 |
约 4–5 倍性能差异,70% 空间节省。
四、FlatBuffers
4.1 设计初衷
FlatBuffers 是 Google 为游戏和嵌入式系统设计的“零拷贝序列化库”。
与 Protobuf 不同,它允许直接在序列化缓冲区中访问数据,无需解包。
目标:
- Zero-Copy;
- High-Performance;
- Low-Memory Overhead。
4.2 定义文件(.fbs)
table Player {
id: long;
name: string;
level: int;
inventory: [Item];
}
table Item {
id: int;
count: int;
}
root_type Player;
使用 flatc 编译生成语言绑定(Java/Go/C++ 等)。
4.3 访问方式:内存映射式
与 Protobuf 不同,FlatBuffers 不需要反序列化:
ByteBuffer bb = ByteBuffer.wrap(data);
Player player = Player.getRootAsPlayer(bb);
System.out.println(player.name()); // 直接读取,不反序列化
FlatBuffers = “序列化即数据结构”。
没有对象构建成本,所有字段直接通过偏移量访问。
4.4 优势
| 优点 | 说明 |
|---|---|
| ✅ 零拷贝(Zero Copy) | 无需反序列化,直接读取 |
| ✅ 访问极快 | 解析时间几乎为 0 |
| ✅ 适合游戏客户端 / 嵌入式 | 高性能资源加载 |
| ✅ 内存友好 | 不分配临时对象 |
| ✅ 向后兼容性好 | 可平滑添加字段 |
4.5 缺点
| 缺点 | 说明 |
|---|---|
| ❌ 写入复杂 | 构建表结构较麻烦 |
| ❌ 不适合频繁修改数据 | 适合一次写多次读 |
| ❌ 二进制难调试 | 不如 JSON / Protobuf 直观 |
| ❌ 工具生态较弱 | 相对 Protobuf 用户少 |
4.6 FlatBuffers 写入示例(Java)
FlatBufferBuilder builder = new FlatBufferBuilder(1024);
int name = builder.createString("Alice");
Player.startPlayer(builder);
Player.addId(builder, 1001);
Player.addName(builder, name);
Player.addLevel(builder, 20);
int playerOffset = Player.endPlayer(builder);
builder.finish(playerOffset);
byte[] data = builder.sizedByteArray();
4.7 FlatBuffers 读取示例(Go)
player := fb.GetRootAsPlayer(buf, 0)
fmt.Println(player.Name()) // 直接访问,无需解包
可在不构造对象的情况下读取字段,非常适合性能敏感的游戏客户端或服务器缓存层。
五、Protobuf vs FlatBuffers 对比总结
| 对比维度 | Protobuf | FlatBuffers |
|---|---|---|
| 主要用途 | 通信、RPC、数据交换 | 游戏资源加载、客户端缓存 |
| 解析方式 | 序列化/反序列化 | 直接访问(零拷贝) |
| 速度 | 较快 | 极快(少 1~2 层内存拷贝) |
| 内存占用 | 中 | 低 |
| 写入复杂度 | 简单 | 较高 |
| 兼容性 | 好 | 好 |
| 消息体积 | 紧凑 | 稍大(含偏移表) |
| 适用场景 | 网络通信(MMO、MOBA) | 本地数据(地图、配置、模型) |
| 生态支持 | 极强(gRPC) | 中等 |
| 代表项目 | gRPC、K8S、游戏服务端 | Unity、Cocos、Unreal |
六、游戏服务器中的典型使用模式
现代大型游戏项目通常混合使用两者:
graph TD
A[客户端] -->|网络通信| B[Protobuf 消息层]
B --> C[逻辑服务器]
C --> D[(数据库 / 缓存)]
E[游戏资源 / 地图 / NPC数据] -->|零拷贝加载| F[FlatBuffers 格式文件]
| 层级 | 序列化方案 | 说明 |
|---|---|---|
| 网络层(实时通信) | Protobuf | 高性能、跨语言 |
| 数据层(配置加载) | FlatBuffers | 快速读写、低内存 |
| 存储层(日志 / 数据镜像) | Protobuf + 压缩 | 压缩归档 |
| 资源层(场景数据) | FlatBuffers / JSON | 预加载 |
示例:
- 客户端加载地图配置:FlatBuffers(零拷贝);
- 客户端发技能消息:Protobuf;
- 服务端广播战斗帧:Protobuf;
- 本地缓存装备数据:FlatBuffers;
- 游戏编辑器导出场景:FlatBuffers。
七、性能测试数据(实际工程案例)
| 数据格式 | 数据大小 | 序列化(ms) | 反序列化(ms) |
|---|---|---|---|
| JSON | 970 bytes | 5.4 | 6.2 |
| Protobuf | 350 bytes | 0.9 | 1.1 |
| FlatBuffers | 420 bytes | 0.6 | 0.4 |
平均性能差异:
- Protobuf 比 JSON 快 4–6 倍;
- FlatBuffers 再快约 30%;
- 但写入复杂度也更高。
八、工程实践建议
| 场景 | 推荐格式 | 理由 |
|---|---|---|
| 实时战斗帧 / 命令包 | Protobuf | RPC 与网络层支持成熟 |
| 本地资源(地图、配置、模型) | FlatBuffers | 零拷贝、高速加载 |
| 日志归档 / 快照 | Protobuf + 压缩 | 紧凑且可演化 |
| 前后端通信(H5) | JSON / MsgPack | 可读性与兼容性好 |
| 嵌入式 / 控制器 | FlatBuffers / Cap’n Proto | 无 GC、低延迟 |
九、代码层架构推荐
游戏项目中建议抽象一个统一的“序列化接口”:
type Codec interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
}
type ProtoCodec struct{}
func (p ProtoCodec) Marshal(v any) ([]byte, error) { return proto.Marshal(v.(proto.Message)) }
func (p ProtoCodec) Unmarshal(data []byte, v any) error { return proto.Unmarshal(data, v.(proto.Message)) }
type FlatCodec struct{}
func (f FlatCodec) Marshal(v any) ([]byte, error) { return flatbuffers.Build(v) }
func (f FlatCodec) Unmarshal(data []byte, v any) error { return flatbuffers.Parse(data, v) }
这样你可以在不修改上层逻辑的情况下切换底层序列化方案。
十、总结与设计启示
| 思考维度 | 启示 |
|---|---|
| 性能与复杂度的平衡 | Protobuf 更通用;FlatBuffers 极致性能。 |
| 网络层 vs 数据层分工 | 网络传输用 Protobuf;资源与缓存用 FlatBuffers。 |
| 工程可维护性 | 统一 Codec 接口解耦协议与逻辑层。 |
| 跨语言通信 | 使用标准 schema 统一消息定义。 |
| 未来演化 | gRPC + Protobuf → QUIC + FlatBuffers 是趋势。 |
一句话总结:
- Protobuf:结构化通信协议的事实标准;
- FlatBuffers:高性能游戏与嵌入式的终极选择。
两者的区别,不是“谁更快”,
而是“你需要快在哪里”。