如果你和我一样,做过几年后端或者DevOps,大概率会有这种时刻:线上接口突然报错,抓包一看,对端发来一串看似乱码的字节流,你隐约知道这是序列化后的数据,但一时半会儿说不清它是怎么从对象变成这堆字节的。也正因此,很多人把序列化和反序列化当成“会用就行”的工具,格式选型、字段兼容、安全隐患全靠踩坑来补课。
这篇文章想把“简学”和“深悟”两件事一起做了。它不只是一份序列化知识点清单,而是从计算机网络的角度,把序列化放在“数据从进程到网络、再到另一个进程”的真实链路里重新讲一遍。你会看到它到底解决了什么问题、不同格式背后的取舍逻辑、为什么反序列化会成为安全重灾区,以及一个可以动手复现的极简序列化协议。适合正在学计网的学生、做后端或中间件开发的工程师,以及所有想搞懂“数据到底是怎么在网络上被表达的”的人。
1. 从计算机网络视角看序列化:它到底解决了什么问题
1.1 先搞清楚:网络上跑的都是字节
从TCP/IP的视角往下看,网络传播的只有比特流。物理层把比特变成电信号、光信号,链路层把比特组装成帧,网络层把帧封装成包,传输层再把这些包抽象成连续的字节流。应用层的一切,HTTP报文也好、RPC调用也好,最后都要被转换成一段接一段的字节,才能交给内核协议栈发送出去。
这个事实看起来简单,却是一切的起点。你程序里的一个对象,在内存里是一块带类型信息、对象头、字段偏移、引用关系的结构化数据,它依赖特定语言的运行时布局。两个进程哪怕在同一台机器上,内存布局也不一样,更不用说跨语言、跨平台、跨设备了。所以“直接拷贝内存里的对象”这件事在网络场景下是完全行不通的。
用寄快递来类比:你心里想的是一个完整的人、一件易碎品,但快递场景里所有东西都得先进箱子,再贴上快递单,最后变成物流系统里能处理的一个包裹。这里的“箱子”就是字节流,“快递单”就是协议头,“能不能把东西完整还原出来”就看收件人有没有遵循同一套装箱规则。序列化就是你往箱子里装东西的动作,反序列化就是拆箱还原的动作。
1.2 序列化 = 给对象做装箱单
序列化的完整定义是:把内存中的结构化数据(对象、结构体、字典、数组)转换成字节序列的过程;反序列化是它的逆过程,把字节序列恢复成内存中的结构化数据。不同技术栈里还有各自的叫法,比如编码解码(Encoding/Decoding)、编排解编(Marshalling/Unmarshalling)、打包解包(Packing/Unpacking),但内核都是同一件事:在对象的逻辑结构和字节流的物理表示之间建立一种可逆的映射关系。
这里说的“可逆”非常重要。序列化不是把对象随便倒成二进制就完事,它必须让对端能从字节里完整还原出类型信息、字段值、嵌套关系,最好还能保持业务语义一致。这要求双方在事前或事中约定好一套格式,也就是协议。你可以把协议理解成装箱单:货品分几类、编号怎么写、长度怎么算、放在哪个位置,都要写清楚,否则收件人拿到箱子也是一脸懵。
为什么不能直接在内存层面做“序列化”?因为对象在内存里的表示包含大量进程私有的信息,比如Java对象有对象头,Python对象有引用计数字段,C++结构体有内存对齐。这些信息对“当前进程”才有意义,对网络对端而言只是噪声。序列化机制要做的是抽取“业务状态”,丢弃“运行时布局”,用一种双方都认的约定格式表达出来。这也是为什么序列化协议通常与语言运行时无关,或者至少做到跨语言兼容。
1.3 序列化是应用层和表示层之间的事
计算机网络的OSI七层模型里有一个经常被忽略的表示层,负责数据表示、压缩和加密,序列化正是这一层最典型的功能。但实际使用最多的TCP/IP四层模型没有独立的表示层,于是序列化通常被当作应用层逻辑的一部分,由应用程序或应用框架自己处理。
这个归属问题看着像纯理论,其实对理解协议栈很有帮助。它解释了为什么序列化方式可以独立于传输方式:你可以用HTTP传JSON,也可以在同一个TCP连接里传Protobuf字节流,还可以把数据用Avro序列化后写进Kafka。传输层不关心序列化格式,它只关心字节流能不能正确到达对端。序列化协议和传输协议解耦,让架构设计有了极大的灵活性。
学习计算机网络时,比起背下每一层的名字,更值得记住的是分层带来的“关注点分离”。TCP只管段和流,IP只管尽力交付,HTTP只管语义化传输,至于业务对象怎么变成字节、对端怎么还原,那是序列化层的事。搞懂这条线之后,你再看到“用Netty传对象”“用gRPC调服务”“用Kafka发结构体”这类说法,就不会觉得它们是魔法了——它们只是在应用层选了一种序列化协议而已。
1.4 字符编码和序列化不是一回事
很多人在中文场景下被乱码折磨过,这里必须把两件事掰开:字符编码解决的是“字符与字节的映射”,序列化解决的是“对象结构与字节流的映射”。一个JSON字符串要上网络,至少要经过两个动作:先把Python对象序列化成JSON字符串,再把JSON字符串按UTF-8编码成字节流。反序列化时反过来,先按UTF-8解码成字符串,再解析JSON字符串还原成对象。
这个概念混淆最常见的结果是:你在一个系统里用了某种序列化库,输出正常,但对端用另一套编码解析,导致中文全变成问号。或者反过来,你把一串已经是JSON格式的字节流当成“字符编码问题”去排查,查了半天发现根因是序列化库的转义开关没开对。记住一句实操口令:序列化管“形状”,编码管“文字”,两者各管一段,排查时先确认对端要的形状,再确认双方都认的字符集。
2. 常见序列化格式与选型:别看表面,都是性能和兼容性的权衡
2.1 JSON:默认答案,但没那么简单
JSON是今天应用层事实上的默认序列化格式。人类可读、跨语言、生态庞大,浏览器原生支持,调试也方便,curl一打就能看到结果。对于大多数HTTP接口来说,JSON足够好,也确实应该是首选。
但它的问题要心里有数。第一是冗长,字段名每个都原样传输,一个包含几十个字段的大对象可能产生几十KB的字节流。第二是数字精度,JavaScript遵循IEEE 754双精度,大到一定程度的整数会丢失精度,所以跨语言传int64时经常需要转成字符串或者用字符串类型承载。第三是没强制schema,线上接口加字段、删字段、改类型都很随意,破坏兼容性的风险全靠开发者的自觉。
JSON里的转义字符也值得专门说一句。字符串内容里的引号、反斜杠、换行等控制字符需要被转义成\"、\\、\n这样的形式,这是JSON规范的一部分。还有一个容易被人忽略的点:非ASCII字符可以原样输出,也可以转成\uXXXX的Unicode转义形式。不同序列化库对此有不同默认值,比如某些Java库的toJSONString会默认转义中文,而Python的json.dumps默认ensure_ascii=True也会转义。如果对端实现有问题,或者日志系统、数据库校验规则不认\uXXXX,就会输出一串看起来正常但解析不了的内容。这不是玄学,就是双方对“转义策略”没对齐。
2.2 XML:老牌企业协议的常客
XML在今天的互联网后端里存在感弱了不少,但在企业级系统和传统协议里仍然是老牌主力。它比JSON更冗长,每个字段都要开闭标签,一个简单的键值对要写好几行。同时它也更强:XML Schema或DTD可以严格定义结构,命名空间可以避免元素冲突,注释、属性、混合内容也都能表达。
SOAP协议就是建立在XML之上的典型例子。你调用一个Web Service,请求和响应都是XML报文,序列化和反序列化由SOAP框架帮你完成。很多金融、电信、政企系统中的核心接口仍然沿用这套东西,原因很简单:它们上线时间早,系统体量大,XML的工具链成熟稳定,迁移成本远高于维护成本。
如果你在做新系统的选型,我的建议很直白:没强制要求就别上XML。它的强约束能力在很多场景下用不上,但体积和解析性能的劣势是实打实的。要约束可以用JSON Schema,甚至后续提到的Protobuf。
2.3 Protobuf/Thrift/Avro:高性能场景全家桶
当JSON的体积和解析性能成为瓶颈时,就该看二进制序列化方案了。Google的Protobuf、Facebook的Thrift、Hadoop生态的Avro是三个最有代表性的选择。它们共同的特点是:序列化结果不是人类可读文本,体积小,解析快,都要求先定义IDL(接口描述语言),再通过工具生成对应语言的代码。
Protobuf核心编码思路可以简化成“字段编号 + 类型 + 长度/值”。每个字段在定义文件里有一个不可变的field number,序列化时只写编号和值,不写字段名。接收端根据编号去查定义,就知道这个值对应哪个字段。这就像一个提前约定好的表格:第1格是姓名,第2格是年龄,发件人不用在包裹上写“姓名”两个字,只要按格式填进去,收件人按约定取出来就行。JSON相当于在明信片上把字段名写全,Protobuf则把所有字段名写进了共同的约定文档里,传输时只传有效载荷。
字段编号的不可变性是Protobuf兼容性的命门。你可以新增字段,只要分配一个新编号,老版本软件会忽略它;你可以删除字段,但不要把编号复用给别人,否则老版本会把新字段解析成旧类型。这些都是server和client升级时的实际约束,理解之后再看“RPC接口演进”就顺多了。
Thrift和Avro思路类似,但各有侧重。Thrift和RPC框架绑定更深,提供多种传输协议和服务器模型;Avro则在Kafka和Hadoop生态中用得非常多,它的schema本身可以随数据一起发送,接收端动态加载schema,非常契合大数据场景下的模式演进。选型时不要只看性能数据,要看你所在的技术栈和运维体系,周边生态往往比单点性能更影响使用体验。
2.4 特殊场景的序列化:SomeIP、Redis、PHP
序列化是个领域性极强的话题,不同行业会对“格式”提出完全不同的要求。这里举三个典型的例子。
SomeIP(Scalable service-Oriented Middleware over IP)是汽车以太网场景下的一种中间件协议,用于ECU(电子控制单元)之间面向服务的通信。它有一套自己的序列化规则,方法参数、事件数据都要按照FIDL(Function Interface Description Language)或后续的IDL映射成字节流。汽车环境对确定性、延迟和软件升级兼容性要求极高,所以序列化方案通常经过严格标准化,跨厂商实现也得能互操作。这充分说明:脱离场景谈“哪个序列化协议最好”没有意义,够用、合规、可演进才是最佳。
Redis里的序列化分两个层面。如果你是Redis客户端,往里面写一个对象,Redis本身不认识对象,它只认字节串。所以你要先把对象序列化成字节串(JSON、MessagePack、Kryo、Protobuf都可以),再通过RESP协议把命令和值发给Redis。Spring Data Redis里默认用JDK序列化,写进去的key和value是带类型头的一长串二进制,肉眼完全看不懂,这就是很多人第一次接触Redis缓存时被吓到的原因。RESP协议本身也可以看成一种简单的序列化协议,它用前缀符号区分类型:+简单字符串、-错误、:整数、$批量字符串、*数组,简单到可以手写解析。
PHP的serialize()则是另一条技术路线的代表,它输出的是带类型标记的结构化字符串,能把对象、数组、字符串、整数完整描述出来,__wakeup等方法会在反序列化时自动调用。中文在这种格式里通常原样输出,但PHP版本不同、配置不同,可能表现为直接输出UTF-8字节还是转义形式,这也是“PHP序列化中文”问题经常被搜索的原因。
2.5 格式选型速查表
| 格式 | 可读性 | 体积 | 解析性能 | Schema约束 | 典型场景 |
|---|---|---|---|---|---|
| JSON | 高 | 较大 | 中等 | 可选(JSON Schema) | HTTP接口、配置、日志 |
| XML | 中 | 很大 | 较慢 | 强(XSD) | SOAP、传统企业系统 |
| Protobuf | 低 | 很小 | 很快 | 强制(.proto) | gRPC、微服务内部调用 |
| Thrift | 低 | 很小 | 很快 | 强制(.thrift) | RPC框架内部 |
| Avro | 低 | 小 | 快 | 强制(.avdl/.avsc) | Kafka、Hadoop |
| MessagePack | 低 | 小 | 快 | 无 | Redis存储、嵌入式 |
选型的经验法则:外部接口默认JSON,没什么好犹豫的;内部服务间如果延迟敏感、数据量大、或接口演进频繁,上Protobuf之类二进制格式;大数据管道里优先看Avro;已经有SOAP/XML遗产系统,别强行改造,先兼容共存;Redis缓存对象时,按团队熟悉度选JSON或Kryo都行,但别用Java原生序列化,那货输出的字节里全是类结构信息,又大又难排查。
3. 反序列化攻击为什么凶险:从拆包的那一刻说起
3.1 高风险的根因:反序列化会“帮你创建对象”
反序列化的天然职责是什么?是根据字节流里的类型信息,在接收端创建出对应的对象。这本身是设计目标,但也是一个巨大的攻击面。正常情况下,它创建的是你期望的类,比如User、Order;可如果字节流里的类型名是攻击者精心构造的,那么反序列化引擎就会按照这个名字去加载并实例化一个“不该出现在这里”的类。
问题还不止于“多创建一个对象”。很多语言的运行时在对象生命周期中会自动调用特定方法:Java的readObject、PHP的__wakeup和__destruct、Python的__reduce__,这些方法在反序列化过程中会被自动触发。如果某个类的这些方法里有敏感操作,比如执行命令、读写文件、发起网络请求,攻击者就可以通过构造一串看似普通的数据,间接触发这些行为。这就是所谓的“Gadget Chain”或“魔术方法链”。
打个比方:反序列化就像是你请了一个搬运工,他不仅按清单把家具搬进新家,还会顺手按下电灯开关、打开水龙头、启动那些本来只是“放在那里”的电器。正常情况下开关都是安全的,但如果屋子里恰好有一台接好了燃料的发电机,搬运工又正好按下了启动键,后果就失控了。安全加固的核心,就是确保“能被搬运工触发的电器”里没有危险设备。
3.2 Fastjson案例:为什么一个JSON库也会被盯上
很多人不理解,JSON解析不就是把文本变成对象吗,为什么Fastjson会成为安全漏洞的重灾区?关键在它的autotype功能。为了把JSON数据里的@type字段映射回具体类,Fastjson允许在JSON文本中指定类名,并在反序列化时实例化这个类。这本是为了功能灵活,但也给了攻击者一个入口:只要能找到一个满足条件的类,反序列化过程中就会触发危险方法。
业内给这类问题起了个名字叫“反序列化漏洞”,但在Fastjson的场景里,问题不止是数据本身,更在于“类型信息的自动处理”。一个纯函数式的解析器不应该有这种创建任意对象的能力,一旦有了,攻击面就被无限放大了。防御上,新版Fastjson提供了safeMode,完全关闭autotype;再往前的缓解方案包括白名单校验、黑名单过滤,但黑名单永远追不上新发现的风险。所以行业共识是:高版本加安全配置是底线,能不用autotype就不用。
这个案例的启示不是“别用Fastjson”,而是“别把不可信数据交给带类型自动加载的解析器”。无论用什么库,先确认它对类型处理的支持程度,再决定是否在公网暴露这类接口。
3.3 Pikachu靶场:在合法环境中理解攻击链路
想理解PHP反序列化漏洞,我建议去靶场环境里亲手折腾一遍,而不是在公网系统上试错。Pikachu是业内比较常见的Web安全靶场,里面专门有PHP反序列化漏洞的练习模块。它的设计把问题简化成一条清晰链路:前端传入序列化字符串、服务端未经验证就反序列化、期间触发了预设的危险方法。
在靶场里你会看到,一个看起来完全“无辜”的字符串,经过反序列化后,居然可以控制对象属性,进而影响程序里的判断逻辑。这个过程一旦走通,你对“反序列化是创建对象的过程”这句话的理解会深很多。前提是只在授权靶场和本地实验环境里做,不要把类似的攻击尝试带上线上系统。
3.4 反序列化防御清单
结合上面所有案例,把这几年能用的防御手段整理成一张清单:
- 不要对不可信数据做原生反序列化。最有效的防御是避免使用“从字节直接恢复对象”的机制,改用JSON这种结构化文本格式,并做严格的字段校验。
- 启用白名单。如果必须使用Java/PHP原生反序列化或带autotype的JSON库,严格限制允许加载的类名集合。
- 升级到安全版本并关闭高风险特性。Fastjson开
safeMode,Jackson禁用enableDefaultTyping,都是底线操作。 - 为敏感接口加完整性校验。在序列化字节流中附加签名或MAC,接收端校验通过后才反序列化,可以有效防止字节被篡改。
- 最小化类加载路径。能跑业务逻辑的类越少,攻击者可用的gadget越少,系统整体被利用的风险越小。
防御的底层原则总结成一句话:反序列化本质上是“把字节流翻译成可执行逻辑”,对它的信任程度,应该和对用户输入代码的信任程度一样低。
4. 实操:手写一个极简序列化协议,顺便把网络课的知识用起来
4.1 不靠第三方库,先搞懂协议长什么样
理论讲再多,不如自己写一遍。这里我用Python设计一个极简的二进制序列化协议,目标是传输一个带消息类型和JSON体的小包。我们不直接用现成的库,而是亲手定协议头、算长度、拼字节,把序列化的“骨架”看透。
协议设计如下:头部3字节,第1字节表示消息类型,第2、3字节表示包体长度,长度按大端序存储;包体是JSON编码后的UTF-8字节。为什么包体还用JSON?因为这是一个教学演示,重点不在“怎么写出比JSON更好的格式”,而在“怎么自己定义一个传输协议并正确处理字节”。设计代码:
import struct import json def pack(msg_type, payload): body = json.dumps(payload, ensure_ascii=False).encode('utf-8') header = struct.pack('>BH', msg_type, len(body)) return header + body def unpack(data): msg_type, length = struct.unpack('>BH', data[:3]) body = data[3:3 + length] return msg_type, json.loads(body.decode('utf-8'))这段代码只有不到10行,但它把序列化的核心元素全带出来了:消息类型、长度字段、字节序、字符编码、结构化数据的编解码。对端只要调用unpack,就能把字节流还原成消息类型和字典。
4.2 为什么是大端?字节序不是可有可无的细节
在上面代码里,我特意用了>BH,>表示大端序。如果两台机器的小端序设备直接按struct.pack('BH')打包,解析端用同样的本机序解包,大概率没有问题;但这套字节流一旦经过网络传输给另一台架构不同的机器,字段顺序完全可能颠倒,长度就会从一个正常的1024变成完全不可信的65536或4。
字节序是计算机网络课里的老知识点:网络字节序统一采用大端序。设计任何自定义网络协议时,都建议在头部显式指定网络字节序,不要依赖本机字节序。这个习惯看起来多写一个字符,实际上避免了一整类跨平台兼容问题。很多自研协议踩坑,不是败在序列化算法上,而是死在“谁也没想到字节序会不一样”这件小事上。
4.3 粘包、半包和长度字段:序列化协议绕不过的坎
有了长度字段,还得处理传输层的流特性。TCP是字节流,没有消息边界,所以可能出现“粘包”:两个包连在一起到达;也可能出现“半包”:一个包的数据还没到齐。解决办法就是我们把头部固定为3字节,接收时先读满3字节拿到长度,再按长度读满包体。实现一个recv_exact帮助函数:
def recv_exact(sock, n): data = b'' while len(data) < n: chunk = sock.recv(n - len(data)) if not chunk: raise ConnectionError("connection closed") data += chunk return data def read_packet(sock): header = recv_exact(sock, 3) msg_type, length = struct.unpack('>BH', header) body = recv_exact(sock, length) return msg_type, json.loads(body.decode('utf-8'))这里的循环读满逻辑是基础中的基础,绝对不能只读一次就当成完整包。实战里很多坑都来自“假设一次recv就能拿到整个消息”,这在局域网里可能侥幸过得去,一旦经过复杂网络路径,马上原形毕露。
如果是UDP,没有粘包问题,因为UDP数据报本身有边界,每个报文对应一次sendto的完整数据。但也别高兴太早,UDP报文有长度上限,IPv4下超过约65507字节就发不出去,所以大对象序列化后还是要考虑分片或用TCP。
4.4 再往前走一步:这个协议该怎么演进
这个手写协议如果真要上生产,至少还缺三样东西。一是版本号,头部放一个版本字段,方便协议不兼容升级时双方可以感知。二是校验值,比如对包体算CRC32或MD5放在头部,接收端验完再解析,防止数据损坏或被篡改。三是更丰富的类型系统,当前只支持一个JSON包体,如果字段里有二进制大对象,JSON的Base64编码会让体积白白膨胀。
把这三样补上,你的自研序列化协议就具备了一个“够用”的雏形。做这个实验的意义在于:下次你再读Protobuf源码、研究Netty的编解码器、或者调RPC框架的序列化配置时,会清楚地知道头部、长度、字节序、边界这些概念在具体代码里落在哪里,理解速度完全不一样。
5. 从学习到实战:结合计算机网络课程的进阶建议
5.1 校学和自学资源怎么用
序列化这个知识点,在经典教材里经常散落在不同章节。《计算机网络自顶向下》的方法是从应用层协议讲起,当你看到HTTP报文的实体部分时,其实已经接触到序列化的应用现场了。王道和谢希仁的教材偏重考试逻辑,408会反复考TCP/IP分层、协议封装,这些内容虽然不直接提“序列化”三个字,却给了你理解数据格式的底层框架。湖科大教书匠的视频讲解风格很细,配合抓包工具看一遍HTTP、TCP的报文结构,再看序列化会顺很多。
这里有个学习顺序上的建议:先看传输层和网络层的封装过程,建立“数据是被逐层包装”的直觉;再看应用层的公开发协议,比如HTTP、DNS,理解“格式即约定”;最后回头研究序列化,你会发现它不过是在应用层做的事情里抽取出的一个通用子集。反过来,如果一上来就学Protobuf,你可能会各种细节都懂,但不知道它对应网络模型里的哪一环。
5.2 工作中的排查经验速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 收到的数据全是乱码 | 双方字符编码不一致 | 确认UTF-8/GBK设置,检查Content-Type和日志编码 |
| 反序列化报ClassNotFound | 服务端类和客户端类不完全一致 | 比对新旧版本字段,检查schema兼容性 |
| 大对象传输特别慢 | 文本序列化体积过大 | 用二进制方案替换,或开启压缩 |
| 大整数精度丢失 | JSON数字被解析成浮点 | 改为字符串传输大整数 |
| 接口报autotype非法类型 | JSON库关闭了类型自动加载 | 检查@type字段,调整白名单或安全配置 |
| Redis里键值不可读 | 使用了JDK原生序列化 | 换成JSON或Kryo等人类可读格式 |
这几条都是我实际排查过的类型,让它们直接给你省掉一些弯路。核心思路就一句话:出现异常先别怀疑网络,先确认“从对象到字节”和“从字节到对象”这两条路上,双方的约定到底一致不一致。
5.3 再做几个小实验巩固直觉
如果你看完还是觉得抽象,我强烈建议在一个隔离环境里做三个小实验。第一个是用Wireshark抓一次普通的HTTP请求,找到TCP流里的JSON数据,自己数一数字段名占了几个字节、值占了几个字节,感受下文本格式的冗余。第二个是用protoc编译一个简单的.proto文件,序列化一条同样的数据,与JSON版本对比字节数,理解二进制格式的收益到底来自哪里。第三个是在Pikachu靶场里完成PHP反序列化的练习关卡,观察魔术方法的调用时机,把“反序列化会创建对象”从抽象概念变成看得见的现象。
结尾
这几个月我复盘过不少线上故障,发现凡是跟数据格式、字段兼容、跨语言通信有关的问题,底层几乎都能追溯到序列化理解不透。我自己也在公司内部干过把RPC负载从JSON换成二进制格式的事,上线后带宽和延迟都有了肉眼可见的改善,但过程中最大的阻力不是性能,而是“让所有团队理解协议为什么这么设计”。序列化这个知识点的价值,恰恰不在于背会多少格式、调过多少参数,而在于建立起一种“一切字节流背后都有约定”的意识。只要你抓住了“结构变字节、字节变结构”这条主线,再回头看网络模型、中间件、安全防御,很多分散的知识点会被一根线串起来。希望这篇“简学深悟启示录”,能帮你把那根线也攥在手里。