1. 项目背景:当序列化成为性能短板
有一类技术平时不容易被注意到,但只要系统一进入高负载,它们就会从“透明的管道”变成“卡脖子的一环”——序列化就是典型。我在上一个低延迟实时数据同步项目里,最开始做的就是业务逻辑设计,完全没有把序列化当回事,结果压测一上,CPU 多少、内存抖动多少、峰值延迟多少,全部被“打包数据”这件事拖住了。
序列化本质上很朴素:把内存中的对象变成一段连续的字节流,方便传输或落盘;反序列化则是反过来,根据字节流重建对象。听起来简单,但“高性能”三个字会引出很多问题:编码是否紧凑、读写是否避免多余拷贝、调用路径上是否全是反射、分配了多少临时对象给 GC 增加压力。这些细节在低并发下看不出来,一旦每秒钟要打包几十万个消息,任何一点额外开销都会被放大。
这篇文章想分享的就是我们当时围绕“高性能序列化库”做的一整套方案:从选型、编码设计、核心实现到性能调优。不是教科书式的原理堆砌,而是把我在实际项目里踩过的坑、验证过的做法、值得参考的参数都写出来。如果你也在做高吞吐服务、实时通信、数据分发,或者只是好奇一个序列化库到底能优化到什么程度,这篇应该对你有帮助。
2. 方案选型:为什么不直接用现成的库
在动手写第一行代码之前,我们其实把市面上常见的方案都过了一遍。当时的诉求很明确:内部系统、单一语言、数据模型相对固定、要求极低延迟和低内存分配。带着这个前提去对比,结论会跟“我要做一个跨语言的开放SDK”完全不同。
2.1 主流序列化方案到底快在哪里、慢在哪里
先从最常见的 JSON 说起。它最大的优势是可读性和生态,但性能几乎是最差的。JSON 文本里充满了花括号、引号、逗号这些冗余字符,同样的数据体积要比二进制大好几倍;解析还需要做类型推断和状态机跳转,在任何高吞吐场景下都不是首选。我们项目里一开始为了调试方便用过 JSON,后来一压测就直接放弃了,因为序列化本身能占到整条链路的 30% 以上 CPU。
然后是 protobuf 这类连续缓冲二进制协议。相比 JSON,它把数据压缩得很小,又有强类型约束,性能在通用方案里算相当优秀的。但常规实现里依然存在不少隐患:反射式的动态读写、装箱拆箱、每次分配新 buffer 导致 GC 压力,以及生成代码为了兼容不同语言和不同版本带来的额外分支。这些开销在单语言、固定 schema 的场景下,属于“为了通用性白付的钱”。
MessagePack 的定位跟 protobuf 类似,胜在实现简单,但对动态类型和嵌套结构的处理仍逃不掉分支判断。FlatBuffers 和 Cap'n Proto 走的是另一条路线,把序列化后的内存布局直接映射到原始数据结构,读取时可以做到零拷贝、随机访问,适合频繁只读的场景;问题在于写入和更新字段的复杂度较高,对跨平台的内存布局也有要求。它在我们的场景里优势不大,因为我们的数据几乎每次都是整体写入、整体读取,很少需要随机访问某个字段。
| 方案 | 优势 | 性能短板 | 适合场景 |
|---|---|---|---|
| JSON | 可读性好、生态广 | 体积大、解析慢、动态类型开销高 | 调试、跨系统弱约束接口 |
| protobuf | 体积小、强类型、生态成熟 | 反射/装箱、分配频繁、通用分支 | 跨语言、标准 RPC |
| MessagePack | 轻量、实现简单 | 动态类型处理仍带分支 | 通用二进制传输 |
| FlatBuffers | 零拷贝、支持随机访问 | 写入复杂、布局约束多 | 高频只读、只解析部分字段 |
| 自研固定布局 | 极致紧凑、零分配 | 需要维护工具链、跨语言能力弱 | 单语言、固定 schema、超高吞吐 |
2.2 自研方案的边界和理由
这种对比下来,我们决定走自研路线,但前提很明确:不追求大而全,只做一个“单语言、固定 schema、顺序读写”的序列化库。
自研能带来什么好处?最核心的一点是,既然字段类型和排列顺序在编译期就完全确定,就没必要在序列化时再去判断“这个字段是什么类型”“这个字段存不存在”。我们直接在序列化方法里一句一句写死:把 id 先写进去、再写 name 的长度、再写 name 的字节内容、最后写 list 的元素。没有循环查表,没有反射调用,没有通用遍历器,每一步都是确定的机器指令。
当然,自研的代价也要提前想清楚:跨语言能力基本放弃;每次数据结构变化都要更新代码生成器;能够依赖的社区资料很少;测试和兼容性维护完全靠自己。所以我的建议是,除非你已经明确看到通用方案的性能瓶颈,或者像我们一样对 GC 分配和延迟有硬指标,否则不要为了“看起来更酷”去自研。通用方案在绝大多数业务里都足够好,自研是一种在特定边界下的优化手段。
3. 编码设计:把“怎么写字节”变成一门学问
选型完成后,真正花时间的是设计字节布局。序列化库的性能天花板通常在编码设计阶段就决定了,后端的代码优化只是把设计的上限兑现出来。所以这里多花点心思非常值得。
3.1 先定义类型系统和数据模型
一个序列化库首先要知道自己能处理哪些类型。我们的模型里包括:整数、浮点数、布尔值、字符串、字节数组、列表、字典、嵌套对象,以及对应的可空版本。字段又分为必选和可选,必选字段每次都要写入,可选字段可以通过标记位跳过。
数据模型的定义用了类似 IDL 的声明文件,由代码生成器生成具体实现类,包括字段属性、序列化方法、反序列化方法、对象大小计算方法和对象池复用方法。这里有个经验:不要让手写类直接参与编解码,而是把声明文件作为唯一数据源,否则字段一多,改起来容易漏。
类型系统里最容易忽略的是字符串和集合的“默认状态”。字符串 null 和空字符串在业务上可能是不同含义,编码时必须区分,否则反序列化以后语义就变了。集合也同样,null 和空集合要明确设计。我们采取的方式是:所有可空字段都参与 bitmap 标记,标记为 null 就不写后续内容,标记为非 null 再写长度和数据。这样既保证了语义,也不会让编码体积膨胀太多。
3.2 字节序、变长整数与 zigzag 编码
字节序是所有二进制格式的基石。我们统一使用小端字节序,因为目标平台全是小端 CPU,直接按小端读取不需要做任何转换。如果在多架构之间传输,则需要考虑网络序或统一转序,但那是另一个复杂度问题。
整数是序列化数据里的重头戏。小整数如果无条件按固定 4 字节或 8 字节写入,体积会非常浪费。我们采用 Varint 编码:每个字节只用低 7 位表示数据,最高位表示“是否还有后续字节”。0 到 127 的数字只占 1 个字节,128 到 16383 占 2 个字节,也就是用更少的字节表示常见的小数值。
但 Varint 对负数有个明显缺陷:负数在二进制里高位全是 1,直接编码会固定占用 10 个字节。于是搭配 Zigzag 编码:把 -1 映射成 1、1 映射成 2、-2 映射成 3,也就是“按绝对值从小到大交替映射”,让负数也变成小正整数,再交给 Varint 压缩。这套组合在业务整数偏小的场景里效果非常明显。
原始值: 0 -1 1 -2 2 -3 3 Zigzag: 0 1 2 3 4 5 6 Varint: [0x00] [0x01] [0x02] [0x03] [0x04] [0x05] [0x06]字符串的处理则分成两步:先写入 UTF8 字符串的字节长度,再写入字节内容。长度本身用 Varint 编码。字节内容如果不经过拷贝直接写入目标 buffer,就必须一次性预计算好整个对象的最终大小,否则 buffer 会频繁扩容,这也是后面性能优化里的一个关键点。
3.3 可选字段的标记策略与兼容性设计
可选字段怎么标记,直接影响编解码速度和体积。我们用了两套方案,分别应对版本内和版本间:
版本内使用 bitmap 标记。在对象头写入一个对齐到字节的位图,每一位表示对应字段是否为本对象写入。比如前 8 个可选字段只需 1 个字节,16 个字段只需 2 个字节。反序列化时先读位图,再按固定顺序依次决定要不要读对应字段。整个过程没有任何 tag 分支。
跨版本则使用字段 ID 追加策略。因为我们不能保证未来不添加新字段,所以在对象头里还保留了一个“字段数”或版本号,旧版本读新数据时会按已知字段顺序读取,并跳过未知部分。这个设计需要在对象尾部写入一个“扩展区长度”用来跳过未知字段,否则老版本解析新版本数据时会把字节流读错位。
这里踩过一个很深的坑:早期为了追求极致体积,把版本号省了,只在对象头放了 magic 两个字节。结果有一次新增字段后,老版本不仅读不到新数据,还把后面所有字段全部解析错了。后来老老实实在头部加入 formatVersion,虽然多花 1 个字节,但换来了可演进性。序列化格式里,“能向前兼容”比“少一个字节”重要得多。
3.4 集合、嵌套对象和深层递归
集合的编码相对简单,先写元素个数,再依次写每个元素。元素是定长类型时,可以直接批量拷贝;元素是变长类型时,需要循环写。这里有个优化点:如果保存的是 int 数组、float 数组这类定长数据,序列化的核心操作可以退化为一次内存复制,而不是逐元素写,速度差距可以达到数倍。
嵌套对象本质上就是递归调用子对象的序列化方法,但递归深度必须设限。我们设定最大嵌套深度为 64,超过则直接抛异常。否则一旦业务数据里出现循环引用或恶意构造的层级结构,轻则栈溢出,重则成为安全隐患。
字典处理相对复杂。我们采用“先写 entry 数量,再依次写 key 和 value”的方式,不做额外排序,因为用户大多数情况下并不要求字典顺序稳定。如果要求确定性的字节输出,则需要按 key 排序后再写入,但排序本身又是性能开销,需要根据业务取舍。
4. 实现细节:榨干性能的关键点
编码设计确定以后,实现层面的优化决定了最终能跑多快。这个阶段我们做的最重要的事情是:抛弃反射,预分配 buffer,控制内存分配,以及让数据读写路径尽量贴近硬件。
4.1 抛弃反射,用代码生成代替通用遍历
反射是序列化性能的头号杀手。每访问一个字段,都要经历类型元数据的查找、反射方法调用、返回值装箱和拆箱。如果数据模型有几百个字段,序列化一次,这些开销就会成倍放大。我们的库从第一天起就没有把反射作为运行期方案,而是做了一个代码生成器:读取声明文件,直接生成针对每个类写好的序列化和反序列化源文件。
生成出来的代码极其枯燥,但速度极快。比如一个学生对象包含 id、name、score 三个字段,生成的序列化代码就是“写 Varint id、写字符串 name、写浮点 score”,没有循环适用范围判断,没有类型判断。代码可读性差一点没关系,反正它不是给人经常维护的,但运行时性能极好。
这里要提醒的是,代码生成器本身也是项目的一部分,需要纳入版本管理。我们踩过的坑是:以为生成代码可以随时重新生成,所以没有提交到仓库,结果 CI 环境缺少模板工具,导致构建失败。后来规则统一为:生成器源码和生成结果都提交仓库,任何字段变更先改声明文件,再提交生成代码。
4.2 Buffer 复用、对象池与预分配
很多序列化库慢的原因不是编码本身,而是每序列化一个对象就 new 一个 byte[]。高频业务下,堆内存的分裂、GC 扫描、内存回收,都比那点 CPU 指令贵得多。
我们的办法是引入线程局部存储的 buffer 池。每个线程在 ThreadLocal 里缓存一个大 buffer,如果当前请求数据量小于 buffer 容量,就直接复用,不再触发分配;超过容量时再扩容,但使用完以后归还池子缓存。这样高并发下也不会因为共享 buffer 产生锁竞争。
对象池也被用在业务对象复用上。反序列化时不是每次 new 一个新对象,而是从池里取一个旧实例,填充数据。这个设计大大降低了内存分配和 GC 压力,但带来一个生命周期管理问题:如果一个反序列化出来的对象被异步任务持有,而它又被还回池子,后续就会被覆盖写入。我们对项目里所有使用方强制约定:对象一旦传到异步线程,就必须立刻做一次深度拷贝或转为不可变 DTO,规避误用。
预分配也是重点。我们在序列化前先调用 MeasureSize 方法遍历整个对象,计算出所有字段加上各种长度前缀的总字节数,再一次性分配 buffer。虽然多了一次遍历,但省去了动态扩容产生的多次拷贝,综合效果更好。实际测试里,预分配比“边写边扩”快了不少。
4.3 利用 CPU 缓存和批量拷贝
字节流的读写方式能否利用 CPU 缓存,直接决定性能上限。我们在设计布局时就让字段按固定顺序连续排列,这样 CPU 预取器可以按线性方式把后续字节提前加载到缓存,而按 tag 散列存储的格式则需要多次随机跳转,缓存命中率就差很多。
批量拷贝是另一个收益明显的优化项。对定长的原始类型数组,从源对象内存复制到目标 buffer 可以直接调用底层内存复制能力,而不是逐元素赋值。比如 int[1024] 整体序列化,其实就是一次 4KB 的内存复制,速度远超 1024 次循环写。同理,从网络接收到的数据需要还原成对象数组时,也可以先在系统判断安全的前提下做一次 span 转换,再用只读视图操作,减少一个中间对象。
对字符串数组,先把所有字符串的 UTF8 长度累加出来,算好整块开始位置,再把每段字符串连续写入。表面上还是逐个写,但避免了每写一个字符串就动态扩展 buffer,cpu 分支也少了很多。实测下来,这个改动让包含大字符串数组的消息序列化耗时下降了接近一半。
4.4 可信数据路径上减少必要的安全校验
序列化库通常要考虑数据来自不可信来源,所以边界检查、长度校验、错误处理一个都不能少。但在我们的内部场景里,数据全部来自自己服务发出的消息,可信度较高,于是我们把“校验”和“快路径”做了分离:上线阶段打开完整校验,稳定运行后通过开关关闭部分冗余检查,保留最关键的长度边界约束。
要注意的是,关闭校验必须谨慎。我们选择保留所有数组和字符串长度的范围检查,只关闭循环内的 defensive slice 检查。例如不再为每个 Utf16 字符都做安全性判断,而是用 unsafe 指针配合预计算长度整体转换。这样节省的开销来自分支预测失败时的代价,而不是真正削减安全检查的边界。如果数据有可能被外部触达,千万不要学这一条,安全优先。
运行环境本身也很影响性能。我们最终选择了支持 Span 和 Unsafe 的运行时版本,序列化写入和读取都直接基于内存视图,避免从 stream 到 byte[] 再到对象的多级拷贝。如果开发环境只能使用相对保守的虚拟机版本,很多底层优化就做不了,这也是选择技术栈时必须提前评估的。
5. 实操过程与性能调优实录
再好的设计也要靠测试数据验证。我在这个项目里经历过一轮轮性能优化,最后总结出来的经验是:测试方法决定了你会不会把时间浪费在错误的方向上。
5.1 基准测试到底怎么设计才靠谱
人们很容易看到序列化库的 benchmark 表格就到处转发,但同一套数字换个环境、换个数据分布,结果可以完全不一样。我们自己测试时至少控制了以下几项:
- 预热:JIT 编译需要时间,因此先跑足 5 秒以上再开始计时,避免把首次运行的开销算进均值。
- 数据代表性:不只测小对象,还要覆盖大字符串、多层嵌套、列表长度从 1 到 10000 的分布。
- 指标全面:序列化耗时、反序列化耗时、序列化后大小、每秒操作数、每次操作分配的字节数、GC 触发次数都要统计。
- 环境隔离:测试机器必须空闲,且关闭其他后台服务,避免 CPU 干扰。
我们用一个独立的压测任务跑数据,每组数据跑 20 次,每次足够长,最后取 P50、P99 而不是只给平均值。平均值容易被极端值影响,而 P99 才是低延迟系统真正关心的。
5.2 从通用方案迁移到自研库的收益对比
我拿当时项目的核心消息类型做了改造前后的对比。改造前用的是通用二进制方案,改造后是自研固定布局方案。不公布环境绝对数字,只看变化趋势:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 序列化耗时 | 基准 | 下降约 60% | 吞吐显著提升 |
| 反序列化耗时 | 基准 | 下降约 55% | 延迟降低 |
| 单条消息体积 | 基准 | 下降约 15% | 带宽占用减少 |
| 单次操作分配字节数 | 基准 | 下降 90% 以上 | GC 压力大幅降低 |
这里最惊人的是分配字节数。改掉反射和每次 new byte[] 以后,大部分操作变成了零分配,GC 从每毫秒都在清理,变成偶尔才触发一次。配合对象池的使用,Full GC 在压测中基本消失。对于追求低延迟的项目,GC 停顿减少往往比 CPU 耗时下降更有意义。
5.3 一个具体问题的调优过程
调优最典型的例子是处理包含大量变长字符串的列表。一开始实现很简单:遍历列表,对每个字符串先计算 UTF8 长度,再写入长度和字节内容。压测时发现这个函数的 CPU 占用高得不像话。看 profile 后定位到,每次计算 GetByteCount 时都重新扫描了一遍字符串内容,而后面实际编码时又扫描了一遍,等于每个字符串被扫了两次。
解决办法是写一个快速长度计算函数,在一次遍历里同时算出长度并完成必要的检查,然后在写入阶段直接用这个结果继续编码。改造后,字符串序列化时间几乎降了一半。这提醒我,很多性能问题并不是“算法复杂度”造成的,而是“同一份数据被重复处理”造成的。序列化这种 IO 密集型的操作,尽量让每字节只被碰一次。
另外一个问题是 buffer 池的容量设置。一开始池里只缓存 256KB 的 buffer,结果很多大消息每次都扩容,扩容要重新分配数组并拷贝旧数据,反而更慢。后来把池子改成多级缓存,分别缓存 8KB、64KB、512KB 三种规格,使用方按数据最近一次的尺寸选择合适的 buffer,扩容次数大幅下降。这个调整很土,但效果出奇的好。
6. 常见问题速查与避坑心得
自研序列化库最累的不是写功能,而是排查各种隐蔽问题。下面这些情况我在实际项目里基本都遇到过,把原因和解决办法整理成表,可以省去你很多排查时间。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 反序列化后字段错乱 | 字段声明顺序变化,旧数据按旧顺序读 | 引入字段 ID 或 version,不要只依赖顺序 |
| 某个字段坏导致后面全乱 | 字节流缺少长度前导或未知字段无跳过机制 | 对象头加 magic 和 version,扩展区加入长度 |
| 从池子里取出的数据脏 | 对象归还后仍被引用 | 严格生命周期约定:异步场景做深拷贝 |
| 多线程序列化数据交叉错乱 | 多个线程共享同一个 buffer | 用 ThreadLocal 池,每个线程独立 buffer |
| 中文乱码或截断 | 字符串长度按字符数而非 UTF8 字节数计算 | 写入长度前先算 UTF8 byte count |
| 性能测试波动巨大 | 数据量太小、未预热、GC 干扰 | 预热足够久,跑足够多次,观察 P99 |
| 反序列化超大字符串导致内存爆掉 | 长度字段被恶意构造或校验遗漏 | 设置最大长度上限,越界直接抛异常 |
| 新增字段后旧数据读取失败 | 没有做版本兼容设计 | 严格按照“末尾追加字段”原则并带版本号 |
除了表格里的问题,还有几条心得是通用方案给不了的,我觉得值得单独拿出来说。
第一,序列化库的“性能”不是单点指标,而是编码、反射、分配、缓存、内存模型共同作用的结果。很多人只盯着“每秒能序列化多少次”,却忽略了内存分配和 GC,结果上线后延迟照样不稳。如果你的业务延迟敏感,优先看“分配了多少字节”而非“耗时多少毫秒”。
第二,优化一定要先采样,不要凭感觉。我们早期凭感觉觉得“ HashMap 的读写很慢”,打算给字典排序换一种编码方式,结果 profile 数据显示字典在整体耗时里占比不到 1%,根本不值得动。真正的大头是字符串编码和 buffer 扩容,这两处才是应该重兵投入的地方。
第三,要给序列化格式留后门。世界上没有不变的数据模型,所以无论追求多高的性能,magic、version、扩展区这三个字段必须留好。它们几乎不占空间,却能让你的格式在未来几年里持续演进。我们最初的版本为了省字节把 version 去掉了,后来花了很多成本重新加回来,这个教训非常深刻。
7. 写在最后:几次踩坑之后的一点体会
最后说点不那么技术、但很实际的感受。自研序列化库这件事,放在今天看依然有点“反主流”,因为生态里已经有非常成熟的方案,普通业务用通用库完全足够。但如果你真的面对极高的性能要求,或者不幸被 GC 停顿折磨得睡不好觉,那就值得花几周时间把这条路走通。
我最想提醒的还是那句话:先测量,再优化。把一个系统的序列化链路放到全链路里看,你可能会发现真正的瓶颈在网络调度、在磁盘 IO,甚至在锁竞争,而不是在字节打包。我们自研最终能成功,是因为前面花了足够多的时间做压测和 profile,确认了瓶颈就在序列化这一段。
如果以后还要做类似的东西,我会考虑把方案再往前推一步:用代码生成器直接根据业务模型生成 TS 或 Rust 版本,让自研库在需要跨语言时也能有一席之地。不过那是另一个话题了。眼下这个固定布局、零分配、预分配缓冲的小库,虽然代码谈不上优雅,但实测稳定、可控,也让我对“性能是怎么从细节里抠出来的”有了更直观的理解。