序列化与反序列化:性能的隐形杀手

聊到中间件通信延迟,有个东西经常被忽视——序列化。说白了,就是把内存里的对象变成一串字节,发出去,对面再变回来。这个过程看似简单,但选错了方案,延迟能差出好几倍。

我见过太多团队,上来就用 JSON,图省事。结果压测一跑,CPU 飙到 90%,吞吐量上不去。一查,瓶颈就在序列化上。嗯,今天我们就来掰扯掰扯这事。

三大序列化方案:JSON、Protobuf、MessagePack

先说说市面上最常见的三种方案。我个人的习惯是,先看场景再选工具,别盲目跟风。

JSON:简单但慢

JSON 的好处不用我多说,人类可读,调试方便。但它的性能,说实话,不太行。

  • 解析开销大:字符串解析,逐字符扫描,CPU 密集型操作
  • 数据冗余多:字段名重复出现,比如 {"name":"张三","age":30},每次都要传 "name" 和 "age" 这两个字符串
  • 类型支持弱:数字只有一种 Number 类型,整数浮点混在一起,反序列化时容易丢精度
注意:我曾经在一个高并发网关项目里,直接用 JSON 做内部 RPC 的序列化。压测到 5000 QPS 时,CPU 直接打满。换成 Protobuf 后,同样硬件跑到 2 万 QPS 都没问题。这个教训,记忆犹新。

Protobuf:二进制王者

Protobuf 是 Google 搞出来的,专为高性能场景设计。它的核心思路是:用预定义的 Schema 来编码,省掉字段名,只传字段值。

举个例子,同样的数据,JSON 要传 {"id":1,"name":"test"},Protobuf 只传二进制编码后的几个字节。字段名?不存在的,全靠编号对应。

我建议,但凡对性能有要求的内部服务间通信,优先考虑 Protobuf。它的缺点也很明显——你得先定义 .proto 文件,编译生成代码,调试时没法直接看报文内容。

MessagePack:折中方案

MessagePack 有点像「二进制版的 JSON」。它保留了 JSON 的数据结构,但用二进制格式存储。比 JSON 快,比 Protobuf 灵活。

它的好处是:不需要预定义 Schema,动态类型,随时改字段。坏处是:性能还是不如 Protobuf,毕竟它也要处理字段名(虽然压缩了)。

序列化性能对比:数据说话

光说不练假把式。我拿一个典型的业务对象(包含 10 个字段,有字符串、整数、浮点、嵌套对象)做了个简单测试。结果如下:

方案 序列化耗时 (ns) 反序列化耗时 (ns) 数据大小 (bytes) CPU 占用
JSON (Jackson) 850 1200 180
MessagePack 520 680 95
Protobuf 210 280 42

看到没?Protobuf 的序列化耗时只有 JSON 的四分之一,数据大小更是只有四分之一不到。这意味着什么?网络传输时间也省了,内存占用也少了。

为什么会这样?说白了,JSON 在做字符串解析时,每个字符都要判断、转义、拼接。而 Protobuf 直接操作二进制,按位按字节处理,效率自然高。

Schema 演进策略:别让版本升级变成灾难

用了 Protobuf 之后,你马上会遇到一个新问题:Schema 怎么演进?

我举个例子。你定义了一个 User 消息,有 id 和 name 两个字段。上线跑了半年,业务方说:加个 email 字段吧。你改了 .proto 文件,重新编译,部署。结果老服务还在跑旧版本,新服务发了带 email 的数据过去,老服务反序列化时直接报错。

嗯,这就是 Schema 演进的坑。Protobuf 的设计哲学是「向前兼容、向后兼容」。怎么做?

  • 字段编号别重复:每个字段有个唯一的编号,1 到 15 用 1 个字节,16 到 2047 用 2 个字节。所以,高频字段用小编号
  • 新增字段用 optional:老版本不认识新字段,直接跳过。新版本读到老数据,字段值为默认值
  • 别删除字段:实在要删,用 reserved 关键字标记,防止别人误用编号
  • 别改字段类型:类型改了,二进制编码就变了,反序列化会乱掉
我的经验:我曾经在一个项目里,因为删了一个字段没标记 reserved,结果新同事加新字段时用了同一个编号。上线后,数据全部错乱,排查了整整两天。从那以后,我要求团队所有 .proto 文件的修改必须走 Code Review,字段编号必须人工核对。

零拷贝技术:终极优化手段

说到序列化性能,有个东西不得不提——零拷贝。这名字听着玄乎,其实原理很简单:减少数据在内存中的复制次数。

传统的序列化流程是这样的:

  1. 从磁盘/网卡读到内核缓冲区
  2. 从内核缓冲区拷贝到用户空间
  3. 在用户空间做序列化/反序列化
  4. 再拷贝回内核缓冲区发送出去

这一来一回,数据被复制了 4 次。零拷贝技术,就是想办法跳过中间的用户空间拷贝,让数据直接从内核缓冲区到内核缓冲区。

具体实现方式有几种:

  • mmap:把文件映射到进程地址空间,省掉 read/write 的系统调用
  • sendfile:直接从一个文件描述符传到另一个,不走用户空间
  • splice:在两个文件描述符之间移动数据,同样零拷贝

在序列化场景下,零拷贝最典型的应用是:你从磁盘读一个 Protobuf 序列化好的文件,直接通过 socket 发出去。用 sendfile,数据从磁盘到网卡,全程不经过用户空间。

核心要点:零拷贝不是序列化本身的优化,而是数据传输路径的优化。它和 Protobuf 配合使用,效果 1+1 > 2。我建议,在需要大量传输序列化数据的场景(比如日志收集、消息队列),一定要考虑零拷贝。

知识体系总览

下面这张图,是我梳理的本章知识结构。你可以把它当作一个思维导图来看:

序列化与反序列化 三大序列化方案 JSON Protobuf MessagePack 性能对比指标 序列化耗时 反序列化耗时 数据大小 CPU 占用 Schema 演进策略 字段编号管理 optional 字段 reserved 标记 类型不可变 零拷贝技术 mmap sendfile splice 减少数据拷贝次数 图:序列化与反序列化知识体系结构

这张图把本章的核心内容串起来了。从左到右,从上到下,分别是方案选型、性能指标、演进策略、终极优化。你写代码时,可以对照这张图,看看自己卡在哪一环。

好了,序列化这块就聊到这儿。记住一句话:选对方案,延迟减半;用对策略,版本无忧;加上零拷贝,性能起飞。


专注资料整理