☰
序列化与反序列化:从对象到字节的跨进程通信基石
2026/10/10 9:43:36 网站建设 项目流程

有一次代码评审,A同学提了个问题:我们服务间调用明明定义好了接口,为什么方法传参都要写成JSON字符串,不能直接把对象扔过去?当时大家七嘴八舌,有人说这是约定,有人说这样方便调试,但真正把"序列化与反序列化"这件事讲明白的人并不多。

这个问题的答案,其实贯穿了大半条网络通信链路。一个对象在进程里是活的,有方法、有引用、有类型信息,但只要它要离开当前进程——无论是走网络、写磁盘还是进消息队列——就必须先变成一串纯粹的字节。这个从"对象"到"字节"的翻译过程叫序列化,反过来从"字节"还原成"对象"的过程叫反序列化。如果你写过RPC、搞过缓存、对接过消息中间件,或者只是好奇为什么互联网服务能这么稳定地互相对话,这篇笔记都适合你。

这篇文章我会用"先简学、再深悟"的顺序,把序列化到底解决了什么问题、主流方案怎么选、版本兼容性如何设计、安全坑在哪里,以及我亲手实现过程中的踩坑记录一条条拆开讲。

1. 序列化到底解决了什么问题:从进程内的"对象"到网络上的"字节"

1.1 进程内无需序列化,因为"引用"只在本地有效

先消除一个常见的认知误区:很多人以为序列化就是把对象变成字符串,这只是表象。真正的关键在于,对象在内存中的存在方式是"引用 + 连续存储",引用保存的是本进程虚拟内存里的地址。

打个比方:你写了一张贴在冰箱上的便条"第三个格子里的牛奶"。这张便条在自己家的厨房里非常好用,你走过去打开冰箱就能拿到。但把这张便条寄给朋友,朋友走进自家厨房,按"第三个格子"找,找到的很可能是鸡蛋。内存地址就是这张便条的坐标,它只在创建它的那个进程里有效。

所以一旦对象要跨进程传输,必须把它整个"打包"成一份不依赖内存地址的完整描述。序列化干的就是这件事:遍历对象的所有字段,把值按约定规则写成字节,并附带上结构信息,让对方拿到字节后能在自己的内存里重建一个一模一样(值层面)的对象。

1.2 序列化与反序列化的完整定义

用一句严谨的话概括:

序列化(Serialization)是把内存中的对象状态转换为可存储或传输的字节序列的过程;反序列化(Deserialization)是逆过程,把字节序列还原为内存中的对象状态。

注意这里说的是"字节序列",不一定是文本。JSON是文本,但它只是众多序列化产物中的一种;更多高性能场景采用的是二进制字节流,肉眼不可读,但紧凑、解析快。后面选型章节我会专门展开。

1.3 实际触发序列化的三类场景

我归纳下来,日常工作中序列化主要在三个位置出现:

  • 跨进程调用(RPC):客户端把请求参数序列化后发到服务端,服务端反序列化还原成参数对象,处理完后再把响应序列化回去。这是最经典的使用场景。
  • 消息队列与异步解耦:生产者把业务对象序列化后投递到消息队列,消费者拉取消息并反序列化。这里还多了一个要求:消息可能在一段时间后才被消费,序列化结果必须具备"跨时间还原"的能力。
  • 缓存与持久化:把热点对象序列化后写入缓存或磁盘存储,下次启动时反序列化加载,避免重建复杂数据的成本。

这三类场景看似不同,但本质一致:先让对象"可搬运",到了目的地再"复原"。可搬运的前提是字节序列自包含足够多的结构信息,而复原则依赖反序列化器对结构信息的解读。

1.4 反序列化不只是"读字节"

有一点初学者很容易忽略:反序列化并不是单纯地把字节读出来,它同时还承担三层校验职责。

第一层是格式校验,确认字节流符合约定结构,长度、边界、字段类型都对得上;第二层是值域校验,比如一个枚举字段传进来一个明显不可能的值,反序列化阶段就要拦住;第三层是版本兼容校验,新旧数据结构不同时,反序列化器要能按兼容规则取舍字段。如果反序列化阶段只做无脑映射,后续逻辑层会接住大量脏数据,排查成本成倍上升。

2. 衡量序列化方案的五个核心维度:别只盯着"快不快"

选序列化方案时,最容易出现的倾向就是拿"快不快、小不小"当唯一标准。实际上真实工程里,均衡比单项极致更重要。我一般从五个维度去评估一个方案。

2.1 文本格式与二进制格式的本质差别

第一层要分清楚的是格式类型:文本型(JSON、XML)和二进制型(Protobuf、MessagePack、Avro)。

文本格式的本质是"可读的结构化字符串",牺牲体积换取人类可直接阅读和调试。它的解析过程通常涉及字符扫描、语法树构建、字符串分配,CPU成本天然高。二进制格式则把结构用预定义的编号、长度和变长编码表示,解析时直接按偏移量取字节,CPU消耗低,体积也小很多。

举个直观对比:一个整数16777216,JSON 文本要存 8 个字符;Protobuf 里它占 4 字节,MessagePack 里是 4 字节加 1 字节类型标记。当这种整数有几千上万个时,体积和解析时间差的就是数量级。

2.2 自描述能力与 Schema 依赖

第二个维度是"数据流本身带不带结构信息"。

JSON 是完全自描述的:每一个键名都写在数据里,接收方不需要任何额外定义就能理解结构,所以它"人类友好"到极致,但也因此冗余。Protobuf 走的是另一个极端:数据流里只写字段编号和值,不写字段名,字段名全部在 .proto 文件里定义,所以它需要"双方提前约定 schema + 先生成代码"。

这里有个工程上的权衡:自描述程度越高,集成越灵活,但体积越大、解析越慢;Schema 依赖越强,体积和性能越好,但双方必须保持 schema 同步。Avro 则是一种有意思的中间态,它要求 schema,但允许 schema 作为文件头随数据一起传递,适合大数据批量场景。

2.3 跨语言与跨平台能力

服务化架构下,调用方和服务方很可能用不同语言实现。这时序列化方案必须声明自己的跨语言能力。

JSON、XML 这类文本格式天然跨语言,任何语言都能解析文本。Protobuf、Avro 这类 IDL(接口定义语言)方案则通过"定义一份中性 schema,再用编译器产出各语言代码"来实现跨语言。MessagePack 虽然没有 IDL,但它定义了一套跨语言的二进制类型标记,各语言实现按同一套规范解析,本质上也是跨语言的。

相比之下,Java 原生的序列化机制、Python 的 pickle 这类方案,虽然用起来最省事,但极度绑定语言本身,换一种语言就无法解析,基本只能用于同语言集群的内部通信。

2.4 性能与体积的真实成本

性能成本要拆成两部分看:CPU 解析成本和传输/存储成本。

CPU 成本主要来自文本解析与格式转换。JSON 解析器要处理字符串转义、数字字面量、键值对匹配,每一步都是字符处理;二进制方案则用变长整数编码(如 varint),数字越小占字节越少,解析时按 1 字节高位判断是否继续读取,循环次数少很多。在万级 QPS 的接口上,这个差距会被放大到影响GC和CPU负载。

体积成本影响的是带宽和存储费用。同样一份订单数据,JSON 可能 1 KB,Protobuf 可能只有 400 字节。看起来单条差距不大,但每天几亿条消息,差距就是几十 TB 的存储和大量网络开销。这也是为什么内部高并发链路普遍用二进制方案。

2.5 版本兼容与演进能力

最后一个是版本兼容,我认为这是五个维度里最容易被忽视、却最能决定方案长期可用性的指标。数据结构一定会变:加个字段、删个字段、把字段从单个改成列表。序列化方案必须能在新旧结构之间平滑过渡,否则每次发布都要停机对齐。

文本格式因为解释性强,天然对新字段宽容(解析时可以忽略未知键);二进制格式则需要 schema 管理纪律(字段号不可重复、删除字段要保留编号)。这一维度我单独用一整章展开讲,因为它值得。

3. 主流序列化方案的选型对比:JSON、XML、Protobuf、MessagePack、Avro

这些年我先后用过 JSON、XML、Protobuf、MessagePack,也看过 Avro 的应用场景,把它们放在一起对比,各自的脾性非常鲜明。

3.1 JSON:上手最快,但别让它干重活

JSON 是当今互联网的"事实通用语"。浏览器、网关、日志系统、第三方开放接口,几乎到处都是 JSON。它的好处是零门槛:结构直观、调试方便、各大语言都有成熟解析库。很多团队第一版内部 API 直接用 JSON 通信,一点问题没有。

但 JSON 的重活在两个地方会露馅:一是数据量大且嵌套深时,解析耗时和 GC 压力明显上涨;二是它没有严格的类型约束,数字、字符串、布尔之间经常隐式转换,跨语言对接时容易出现精度丢失或类型歧义。此外标准 JSON 不支持二进制内容,传图片、加密块必须先做 Base64,体积再膨胀 33%。

所以我的原则是:对性能要求不苛刻、需要人类可读、外部生态对接多的场景,JSON 是首选;内部高 QPS 链路和强类型约束场景,尽早换二进制方案。

3.2 XML:老牌标准,仍在特定领域坚守

XML 是资历最老的文本序列化标准之一,带命名空间、属性、注释等丰富特性,也正因如此,它极其啰嗦。同样的数据,XML 的体积通常是 JSON 的 1.5 到 2 倍。

现在 XML 主要出现在传统企业系统的报文协议、配置文件、某些金融政务接口里。新项目一般不建议再从零引入 XML,除非对接方有硬性要求。还有一个历史教训:XML 解析器历史上出过大量外部实体注入漏洞,如果必须手写 XML 解析,一定要禁用外部实体处理。

3.3 Protobuf:高性能与强约束的"重武器"

Protobuf 是我在跨语言 RPC 场景里用得最多、也最放心的一种二进制序列化协议。它通过 .proto 文件定义消息结构,再用编译器生成各语言代码,序列化和反序列化都是直接操作字节的生成代码,性能和体积都处于第一梯队。

代价也很明显:工程链变重了——要有 .proto 的版本管理、代码生成步骤、构建集成;字段编号需要人工维护纪律;联调时双方必须确保 .proto 文件一致。如果团队没有建立 schema 评审习惯,很容易出现两边 proto 漂移导致的偶发解析错误。

3.4 MessagePack:二进制外壳下的"JSON 友好者"

MessagePack 是一个很有意思的中间方案:它定义了一套紧凑的二进制类型标记,但同时保证"数据和 JSON 结构一一对应"。也就是说,一个 JSON 对象可以直接转成 MessagePack 字节,反解回来又能还原成等价 JSON。

这带来一个独特优势:既有 JSON 的灵活性和自描述性,又比 JSON 小、解析快。但它不像 Protobuf 那样有强类型 IDL,也没有编译期约束,所以严谨性介于 JSON 和 Protobuf 之间,适合希望"无 Schema 无缝升级为二进制"的场景。

3.5 Avro:为大数据批量处理而生

Avro 是面向数据密集型批处理的序列化框架,主要活跃在大数据生态里。它的特点:强依赖 Schema,但 Schema 可以跟随数据一起存储(比如 Parquet 文件头),读数据的人不需要提前拥有 schema。

这个设计非常适合文件存储和批量导数据的场景:文件里自带结构说明,消费者打开就能解析,schema 演进规则也比较成熟,支持默认值和字段别名。但它在低延迟 RPC 场景里不如 Protobuf 常用,因为每个消息都携带 schema 会抵消体积优势,属于"用场景选方案"的典型。

3.6 选型决策:一张表加三个问题

方案格式类型自描述能力跨语言体积解析性能版本兼容典型场景
JSON文本完全自描述好大慢靠约定网关、开放接口、日志
XML文本完全自描述好最大慢靠约定企业报文、配置文件
Protobuf二进制依赖Schema好小快Schema纪律内部RPC、高QPS链路
MessagePack二进制自描述(类型标记)好中中等靠约定轻量二进制化改造
Avro二进制依赖Schema,可随数据携带好小快Schema演进规范大数据批量处理

选型时不用纠结"哪个最好",而是回答三个问题:数据主要由谁消费(人还是程序)?结构变更多频繁(是否有能力维护Schema纪律)?性能瓶颈到底在哪(是解析CPU还是传输带宽)?答案组合基本就能锁定方案。

4. 版本兼容是序列化设计的"试金石":字段增删背后的兼容性实验

4.1 前向兼容与后向兼容:新旧快递员看同一张运单

先明确两个术语,避免后面混乱。

  • 后向兼容(Backward Compatibility):新代码能读旧数据。比如服务端已经升级到 v3 结构,线上还有 v2 客户端在发旧格式消息,v3 服务端必须能正确解析 v2 数据。
  • 前向兼容(Forward Compatibility):旧代码能读新数据。比如客户端还没升级,服务端却已经发了 v4 结构的新消息,老客户端收到后不能报错,至少要能忽略未知字段继续工作。

可以想象成一张运单:老快递员只会看"收件人、地址"两栏,新快递员还会看"电话、备注"。如果运单上多了新栏目,老快递员不该扔掉包裹,而是看自己认识的栏目完成投递;如果运单缺了备注栏,新快递员也不该拒收,按空信息处理就行。序列化的版本兼容,本质上就是让"双方版本不同步"时不至于出致命事故。

4.2 以 JSON 为例:兼容性完全靠解析方自觉

我实际跑过一个小实验来验证 JSON 的兼容行为。假设 v1 版本的用户数据结构只有name和city两个字段:

{"name": "小明", "city": "杭州"}

服务端升级到 v2,新增了age字段:

{"name": "小明", "city": "杭州", "age": 25}

此时让 v1 代码解析 v2 数据,标准 JSON 库的行为是:支持"忽略未知字段"的解析器能正常返回对象,多的age被丢掉;不支持忽略的库会抛UnrecognizedPropertyException。反过来,v2 代码解析 v1 数据,age字段缺失,JSON 库不会自动补默认值,业务代码如果直接读obj["age"]得到的是None。

结论很清晰:JSON 的兼容性不是协议保证的,而是每个解析方自己"自觉"。要实现前后向兼容,接收方代码必须做到两点:解析时忽略未知字段,读取字段时对缺失字段做默认值兜底。这两点都做对了,JSON 也能在很长一段版本周期里平滑演进,只是没有强约束,完全靠代码评审把关。

4.3 以 Protobuf 为例:字段编号是命根子

Protobuf 的兼容性设计就规范得多,它给你的核心纪律是:字段编号一旦分配,永不复用。每个字段在 .proto 里拥有一个数字编号,序列化时数据流只写编号和值。

假设 v1 消息定义:

message User { string name = 1; string city = 2; }

v2 新增字段,编号接着排:

message User { string name = 1; string city = 2; int32 age = 3; }

新代码读到只含编号 1、2 的旧数据,age缺省为 0,完全正常;旧代码读到含编号 3 的新数据,因为不知道编号 3 是什么,会直接跳过这个字段,其余字段照常解析。这就是"编号机制"带来的天然前向和后向兼容。

但如果有人不守纪律,把已删除的字段编号重新交给新字段,灾难就来了。举例:某团队 v1 里string nickname = 5,后来删掉了 nickname,有人新加int64 phone = 5。旧客户端还可能在线上用 v1 结构发送,字段编号 5 的内容是字符串,新服务端却按 int64 去解析,轻则得到一个错误号码,重则解析错位、直接协议错误。Protobuf 的官方文档里明确要求:删除字段时用reserved关键字把编号和字段名锁住,防止复用。

message User { reserved 5; reserved "nickname"; string name = 1; string city = 2; int32 age = 3; repeated string tags = 4; }

4.4 一条真实演进链路的兼容性推演

我把上面这些规则拼成一条连续演进的链路,读者可以推演每一站的状态。

  • v1:只有name = 1。
  • v2:新增age = 3。旧数据缺 age,默认 0;新数据被旧代码读,跳过 age。兼容。
  • v3:city从单值改为repeated string city = 2?这一步其实有问题:字段编号相同但类型从 string 变成 repeated string,Protobuf 会允许解析,但一个单值字符串在 repeated 语义下会被当作 length-delimited 列表解析,业务含义可能错位。正确做法应该是新增字段编号,废弃旧的。
  • v4:删除nickname,并用reserved锁住编号和字段名。兼容。
  • v5:新增address对象字段,内部继续套用同样的编号规则。兼容。

这条链路走下来你会发现,版本兼容不是某个方案天然自带的神奇能力,而是"协议规则 + 团队纪律"共同作用的结果。文本格式靠自觉,二进制格式靠规则,但最终都得靠人遵守。

5. 序列化安全:反序列化漏洞为什么频发

序列化这块还有一个经常被忽略、却格外致命的方向:安全。反序列化漏洞在业界是重量级安全事件的高发区,值得单独拉出来讲。

5.1 为什么反序列化会成为攻击面

反序列化是一个将外部字节流"还原为可执行对象"的过程,这意味着解析方会接受外部输入并触发语言运行时的一系列复杂操作。某些语言的序列化格式里甚至携带了类名、方法信息,反序列化时会自动执行对象里定义的特殊方法,攻击者如果精心构造字节流,就可能把这种"自动执行"变成自己的跳板。

核心问题在于:你无法信任的字节流,被当成可信代码的载体执行了。网络编程里大家普遍对 SQL 注入、越权访问有安全意识,反序列化漏洞却常常被当成"框架内部问题"忽略,直到出事故才追悔莫及。

5.2 常见攻击形态(点到为止)

我不展开具体攻击载荷,重点描述形态,帮读者建立识别能力。

  • 类型混淆攻击:攻击者构造一个"披着合法外壳、内里是危险类"的序列化数据,诱导目标应用实例化一个本不该被实例化的类型。
  • 深递归与超大载荷:发送极深嵌套结构或巨量字段,让反序列化器在递归或内存分配上失控,造成栈溢出或内存耗尽,属于拒绝服务攻击的一种。
  • 利用框架默认机制:某些语言自带的序列化机制,反序列化时不仅还原数据,还会还原对象关联的类,给攻击者可乘之机。

5.3 防御实践清单

我给自己定过一套反序列化安全操作规范,分享出来:

第一,永远不要反序列化不可信来源的数据。来自公网、用户上传、第三方回调的数据,能避免解析就避免,必须解析也要先做严格校验。

第二,用显式 Schema 和类型白名单。对二进制 Schema 方案,反序列化前先校验字节流长度、字段范围、枚举合法值;对语言原生机制,启用允许类型列表,只白名单业务需要的类。

第三,限制消息大小与嵌套深度。在解析入口统一控制最大字节数、最大嵌套层数,防住深递归和内存膨胀。

第四,优先采用安全文本格式或强类型协议。如果业务允许,文本格式加显式字段校验,比直接启用语言原生反序列化要安全得多。

第五,及时升级解析库。解析器自身的漏洞补丁非常关键,很多历史攻击利用的都是已知 CVE,升级到修复版本就能堵住。

6. 从"简学"到"深悟":亲手实现一次序列化实践的踩坑记录

说了这么多理论,最后落到实践。我用一个模拟项目 X 做了一次完整的序列化实践,踩过的坑比看书有用得多。

6.1 用 Protocol Buffers 跑通完整流程

场景很简单:客户端提交订单,服务端处理后返回结果。我在模拟项目 X 里定义:

syntax = "proto3"; package demo.order; message OrderRequest { string order_id = 1; string user_id = 2; repeated string items = 3; int32 total_amount = 4; } message OrderResponse { string order_id = 1; bool success = 2; string message = 3; }

流程是三步:先用 protoc 编译 .proto 生成目标语言的代码,再在发送方把订单对象序列化为字节,接收方再反序列化还原。示例代码(以 Java 风格为例)是这样组织的:

// 发送方:对象 -> 字节 OrderRequest request = OrderRequest.newBuilder() .setOrderId("ORD-2024-001") .setUserId("U-10086") .addItems("book") .addItems("pen") .setTotalAmount(99) .build(); byte[] payload = request.toByteArray(); // 这里 payload 可以写入网络流、缓存或消息队列 // 接收方:字节 -> 对象 OrderRequest received = OrderRequest.parseFrom(payload); System.out.println(received.getOrderId());

跑通这个例子后,最大的感受是:二进制序列化并没有想象中神秘,它就是一个编译器帮你把字段和编号的对应关系焊死了,剩下的解析逻辑都是生成代码自动处理。反倒是中间过程的调试,一旦字节流出问题,肉眼完全看不出内容,必须靠工具解析或者加日志输出字段值,调试成本比 JSON 高不少。

6.2 我踩过的三个实用坑

第一个坑是字段号变更导致的错位。项目早期我图省事,把reserved机制当作"反正是内部项目"跳过,结果删掉一个废弃字段后,新人复用了它的编号。线上一个老客户端还在用旧结构发消息,消息到达后字段张冠李戴,排查了整整一个下午。从那以后我在代码评审里加了一条硬性要求:任何 proto 删除字段,必须同步写reserved。

第二个坑是varint 编码与负数的坑。Protobuf 的 int32 采用变长编码,但负数的补码字节很高,会被变长编码拉长到 10 个字节。后来我发现 proto3 里如果字段可能为负,应该显式使用sint32或sfixed32,它们对负数做了 ZigZag 映射,正负都能保持小体积。这个细节不踩坑很难注意到。

第三个坑是未知字段被静默丢弃。新版服务端收到旧版本的数据,解析正常;但反向时,如果旧服务端把新字段丢掉了,再转发给其他系统时,新字段就永久丢失了。在某些链路较长的场景里,这个"静默丢弃"会造成数据悄悄缺失。我在项目里加了一个约定:关键业务字段禁止随意删除,丢弃要走显式迁移。

6.3 我自己的实操体会

把理论和实践串起来后,我对序列化的理解不再停留在"把对象转成字节"这个表层,而是把它看作一种"跨进程沟通的契约语言"。不同场景要选不同的语言:面向外部生态说 JSON,内部高并发说 Protobuf,批量存数据说 Avro,没有绝对王者,只有场景适配。

我个人的建议也简单:如果你的项目刚开始、接口量不大、团队也没有 schema 评审习惯,直接用 JSON 起步完全足够;等流量和团队规模上来了,或者你需要严格的跨语言约束时,再引入 Protobuf,同时务必把字段编号纪律写进团队规范。技术栈升级不难,难的是让所有人对"结构变更"这件事保持敬畏。序列化和反序列化不是一道需要背的面试题,而是每个写网络服务的开发者,迟早都会亲手踩一遍的必修课。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询