一、序列化选型:没有银弹,只有取舍
做存储中间件,序列化是绕不开的第一道坎。说白了,就是内存里的对象怎么变成字节流,再怎么变回来。我这些年折腾过不少序列化方案,从最早的 Java 原生序列化,到后来用 Protobuf,再到为了极致性能手写序列化……踩过的坑能写满一页纸。
今天咱们就聊聊三种主流方案:FlatBuffers、Protobuf,以及手写序列化。嗯,还有零拷贝、内存对齐这些底层原理。你想想看,Tick 级数据每秒要处理几万甚至几十万笔,序列化慢个几微秒,累积起来就是灾难。
1.1 Protobuf:成熟稳定,但别迷信
Protobuf 是 Google 开源的,业界用得最多。我个人习惯用它做 RPC 通信的序列化,因为它有现成的代码生成器,定义好 .proto 文件,一键生成各种语言的代码,省心。
但 Protobuf 有个问题——它需要反序列化才能读取字段。什么意思呢?就是你必须把整个二进制流解析成对象,才能拿到某个字段的值。对于 Tick 级数据,这往往意味着不必要的内存分配和拷贝。
我曾经在一个量化交易项目里,用 Protobuf 做行情数据序列化。结果发现,光是反序列化就占了 30% 的 CPU 时间。后来换成 FlatBuffers,CPU 占用直接降到 5% 以下。
1.2 FlatBuffers:零反序列化,直接读内存
FlatBuffers 是 Google 另一个作品,专门为游戏和实时系统设计的。它的核心思想是:数据在内存里怎么存,在字节流里就怎么存。你不需要反序列化,直接拿字节流的指针去读字段就行。
举个例子,你用 FlatBuffers 存一个结构体,它不会像 Protobuf 那样做二次编码。你拿到字节流,直接 cast 成结构体指针,字段就在那里等着你。这就是零拷贝的雏形。
但 FlatBuffers 也有缺点。它的写入比 Protobuf 复杂,需要先构建一个 Builder,再序列化。而且,它不支持变长数组的随机访问——你想想看,要遍历一个数组,你得一个个跳过去。
1.3 手写序列化:极致性能,代价是工作量
什么时候需要手写?当你的数据结构极其固定,而且对性能有变态要求的时候。比如,一个 Tick 数据就几个字段:时间戳、价格、数量、方向。这种结构,手写序列化比任何通用方案都快。
手写序列化的核心就两条:
- 固定长度编码:每个字段占多少字节,写死。比如时间戳用 8 字节 int64,价格用 8 字节 double。
- 内存对齐:字段按 4 字节或 8 字节对齐,CPU 读取效率最高。
// 一个 Tick 数据的二进制布局(手写)
// 偏移 0: 时间戳 (int64, 8字节)
// 偏移 8: 价格 (double, 8字节)
// 偏移 16: 数量 (int64, 8字节)
// 偏移 24: 方向 (int8, 1字节) + 7字节填充
// 总大小: 32字节
struct Tick {
int64_t timestamp;
double price;
int64_t volume;
int8_t direction; // 0=买, 1=卖
// 7 bytes padding
} __attribute__((packed));
二、零拷贝技术原理:别让数据在内存里“旅游”
零拷贝,听起来高大上,其实原理很简单:减少数据在内存中的拷贝次数。传统的数据读取,数据要从磁盘到内核缓冲区,再到用户缓冲区,再到 Socket 缓冲区,最后到网卡。这一路下来,拷贝了四次。
零拷贝技术,比如 mmap 和 sendfile,可以让数据直接从内核缓冲区到网卡,跳过用户态。对于 Tick 级数据,这意味着延迟从微秒级降到纳秒级。
我记得有一次优化行情分发系统,用 mmap 映射文件,然后直接通过共享内存传给消费者。原来用 read/write 要 5 微秒,换成 mmap 后降到 0.5 微秒。嗯,这就是零拷贝的威力。
2.1 序列化中的零拷贝
在序列化场景里,零拷贝指的是:反序列化时不分配新内存,直接复用原始字节流。FlatBuffers 就是典型代表。你拿到一个 FlatBuffer 对象,它内部其实就是一个指针,指向原始的字节数组。读字段时,直接从这个指针偏移读取。
而 Protobuf 做不到这一点。它必须把字节流解析成一个个独立的 Java/C++ 对象,每个字段都 new 出来。这不仅仅是拷贝,还有 GC 开销。
三、内存对齐与字节序:细节是魔鬼
3.1 内存对齐:CPU 的“强迫症”
CPU 读取内存,不是按字节读的,而是按“字”读的。在 64 位系统上,一个字是 8 字节。如果数据没对齐,CPU 就要读两次,再拼起来。你说慢不慢?
举个例子,一个 int64 的变量,如果地址是 8 的倍数,CPU 一次就读完。如果地址是 4 的倍数但不是 8 的倍数,CPU 要读两次,再移位拼接。性能差一倍。
我手写序列化时,一定会用 alignas(8) 或者编译器指令强制对齐。Protobuf 和 FlatBuffers 内部已经处理好了,但手写时你得自己操心。
// 错误示范:未对齐
struct BadTick {
int8_t direction; // 1字节
int64_t timestamp; // 8字节,但偏移是1,未对齐!
};
// 正确示范:按大小排序,大字段在前
struct GoodTick {
int64_t timestamp; // 偏移0,对齐
double price; // 偏移8,对齐
int64_t volume; // 偏移16,对齐
int8_t direction; // 偏移24,对齐
// 7 bytes padding
};
3.2 字节序:大端 vs 小端
字节序,说白了就是多字节整数在内存里怎么排。x86 用的小端(低位在前),网络协议用的大端(高位在前)。
如果你只在单机内用,小端没问题。但一旦涉及网络传输,就必须统一成网络字节序(大端)。我见过一个事故:两个系统用 Protobuf 通信,但一个是大端机器,一个是小端机器,结果解析出来的价格全是错的。嗯,后来加了 htonl 和 ntohl 才搞定。
四、性能基准测试:用数据说话
光说不练假把式。咱们直接上测试数据。我模拟了一个 Tick 数据流,每条 32 字节,连续发送 100 万条,分别用三种方案测试序列化和反序列化耗时。
| 方案 | 序列化耗时 (ms) | 反序列化耗时 (ms) | 内存分配次数 | GC 暂停 (ms) |
|---|---|---|---|---|
| Protobuf | 45 | 62 | 200 万 | 15 |
| FlatBuffers | 38 | 2 | 100 万 | 3 |
| 手写序列化 | 12 | 1 | 0 | 0 |
看到没?手写序列化在反序列化上几乎是零开销。FlatBuffers 也很快,因为它不需要解析。Protobuf 最慢,但胜在通用和易用。
五、知识体系总览
下面这张图,是我梳理的本章知识结构。你可以看到,序列化选型、零拷贝、内存对齐、字节序、性能测试,这五块内容环环相扣。理解了它们,你就能设计出适合 Tick 级数据的存储方案。
好了,这一章的内容就到这儿。序列化看似基础,但里面的门道不少。选对方案,理解底层原理,你的中间件才能扛住 Tick 级数据的压力。