有一次,我调一个内部订单详情接口。服务端逻辑不复杂,就是查库、组装、返回,但压测怎么都过不去:90% 的响应在 2 秒以上,CPU 火焰图里有一大块编码开销占得刺眼。抓包看更夸张,一个订单详情响应体就 1.6MB,走的还是最普通的“HTTP 接口 + JSON 返回”。当时有同事问了一句:HTTP 传的不就是文本吗?这种场景也需要序列化和反序列化?
这问题看着基础,真要回答清楚并不容易。它牵扯到“序列化到底在解决什么”,以及“RPC 和 HTTP 各自的序列化藏在哪个环节”。这篇文章就把我后来梳理的结论写透:什么时候必须做序列化、什么时候可以不做、做了之后怎么选方案。想彻底搞明白这套逻辑的后端、微服务和网关开发都可以看,刚接触 RPC 的同学也能从中把概念捋顺。
1. 一次接口排查引发的疑问
1.1 那天的现状:一个 HTTP 接口 1.6MB 的返回体
先还原当时的问题。订单服务返回一个订单对象,里面嵌套了商品列表、优惠明细、支付流水,还有一堆冗余的历史字段。服务端代码很直接,把内存里的订单对象直接json.Marshal塞进 HTTP 响应体。
客户端超时的直接原因是响应体太大,网络传输和序列化上下文切换叠加。火焰图里,JSON 编码相关函数占了一个服务实例接近 24% 的 CPU。这里有个反直觉的地方:我们一直以为 HTTP 是“文本协议”,传 JSON 只是“把对象变成字符串”,算不上什么序列化。可实际上,对象到 JSON 字符串这一步,已经把结构化数据变成了线格式,只是这种线格式人类可读、层级依然清晰,所以多数人感知不到它也是序列化的一种。
它没有反序列化吗?也有。客户端收到 JSON 后,要把它还原成客户端语言里的结构体。这一步做的每一件事,都是反序列化:按字段名找值、做类型转换。只不过框架和标准库把这些过程默认处理掉了,出了问题才想起来看一眼。
1.2 关于序列化的三个常见误解
很多人对序列化的认知停留在“RPC 才需要”“JSON 不算序列化”这类层面。我梳理了三个最常见的误解:
误解一:只有 RPC 需要序列化。HTTP body 里任何一个对象转字符串、字符串再转字节的过程,已经是序列化的一部分。区别只在于这里用的是文本型序列化,框架名字不叫“序列化器”而已。
误解二:JSON 只是“转文本”,不叫序列化。严格说,JSON 是一种无 schema 的文本序列化格式。它把对象里的字段名、嵌套关系、数组、数字、字符串组织成特定语法,接收端要按这套语法解析。这不正是序列化的定义吗?把内存态的数据对象变成可传输、可存储的字节序列,反方向再还原。
误解三:序列化只影响性能。对小型内部接口,序列化的性能影响可以忽略。但一旦涉及跨语言、跨版本、不可信数据,序列化方案的选型直接决定兼容性边界和安全底线。后面会细说。
这三个误解不是偶然的,它们共同指向一个根本问题:大多数人把“序列化”理解成语种自带的serialize函数或二进制协议,却忽略了所有离开进程内存的数据,都必须经过一个“变成线格式”的过程。理解这一点,标题的答案就浮出一半了。
2. 序列化的本质:内存对象过不了“进程边界”
2.1 内存对象与网络字节,是两种完全不同的世界
同样叫“订单对象”,在 Java 进程里是一个堆上的引用结构:对象头、字段偏移、指向 List 的引用指针、BigDecimal 的内部表示。这些细节只对当前 JVM 实例和当前进程的地址空间有效。
你把这块内存区域原封不动通过 socket 发出去,对端如果是 Go 服务,它看到的是一堆它无法理解的字节:里面没有任何说明,告诉它哪 8 个字节是订单 ID、哪个偏移开始是商品列表。更麻烦的是,内存地址只在当前进程有效,引用指针指向的堆位置在另一个进程里毫无意义。
打个比方:你不能把自己家的房间、抽屉、柜子原封不动搬到朋友家,因为朋友的房子没有你家的墙和走道。你要寄东西,只能把抽屉里的东西拿出来分类打包,在外面写上标签:“这里面是什么、有几件、怎么摆放”。序列化就是打包的过程,反序列化就是朋友按标签拆包、重新放到他自己家的抽屉里。
RPC 和 HTTP 能跑通,靠的都是这个“拆包-打包”过程。区别只在“标签”怎么写:是用人类能读的 JSON,还是用紧凑的二进制字段编号。
2.2 三种常见序列化形态,没有谁比谁“更不序列化”
选型时常见的序列化方案可以粗分成三类,我列在下面,方便对照。
| 形态 | 典型代表 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 文本型 | JSON、XML、YAML | 可读性好,调试方便,跨语言通用 | 体积大,带字段名开销,类型弱,解析慢 | 对外 API、配置、调试场景 |
| 二进制紧凑型 | 像 Protobuf、MessagePack 这类带 schema 或半 schema 的方案 | 体积小,速度快,字段编号紧凑,类型约束强 | 不可读,需要预定义结构,调试成本高 | 服务间高频调用、大数据量、微服务内部 |
| 语言原生型 | Java 自带的序列化、一些脚本语言的原生序列化 | 使用简单,不额外引依赖 | 强语言绑定,体积大,兼容性差,安全风险高 | 仅限同一语言同一版本体系内部,最好不用 |
文本型和二进制型的选择,本质是“可读性”和“开销”的取舍。JSON 里每个字段都要重复写字段名,二进制方案把字段名换成编号,体积自然缩小一个量级。但这不是说二进制永远更优——调试二进制报文要额外工具,接口结构改起来也没那么随意。
我把它们放在同一张表里,是想强调:别因为方案叫“JSON”就觉得它跟序列化无关。只要数据跨了进程,任何方案都在做同一件事——把结构化内存对象转换为线格式。
2.3 反序列化不是逆运算:版本、类型、安全一个都不能少
真正的坑,往往出现在“反向还原”这一步。反序列化不是把字节逆推回对象那么简单,它需要在缺少上下文的情况下重建一个完整对象,于是三个问题必然出现。
版本兼容:对象结构会变。服务端先加了一个字段,老客户端还没升级,收到的字节里没有这个字段,反序列化出来怎么办?二进制方案遇到字段编号冲突,可能直接报错;JSON 遇到未知字段,默认忽略还是报异常,取决于配置。线上最常见的故障就是“先升级服务端,后升级客户端”,中间窗口期老协议反序列化失败。
类型精度:JSON 里的数字是通用 number,Java 的 Long 超过 2^53 时,JavaScript 端解析会丢精度。这种问题在订单号、雪花 ID 上非常常见。解决方案要么用字符串传递 ID,要么选带严格类型的二进制方案。
安全问题:反序列化不可信数据,本质上是你替对方执行了一段“按字节描述来构建对象”的逻辑。恶意数据可以构造异常对象图,触发危险方法链,造成远程命令执行。这类漏洞历史上在很多语言的原生序列化机制里都出现过,后面我会专门提防御手段。
反序列化的难度提示我们:选型时不能只看序列化有多快,还要看反序列化的容错能力、类型表达能力和安全边界。这也是“什么时候需要序列化”这个问题真正的重量所在。
3. HTTP 场景:序列化其实藏在两层里
3.1 业务层的数据编码,与传输层的隐式序列化
严格拆开,一个 HTTP 请求从业务对象到网络字节,经历了两层“序列化”。
第一层叫数据级序列化,也叫业务编码。你的代码里是结构体对象,通过JSON.stringify、json.Marshal之类的方法变成字符串。这一层由应用代码决定,通常程序员有感知。
第二层叫协议级序列化。HTTP 报文本身要经过 TCP/IP 协议栈:字符流被切分成 TCP 段,加上 TCP 头;再被封装成 IP 数据报,加上 IP 头;最后套上以太网帧头,才真正落到网线。严格说,这些“把结构化报文变成线缆上的比特”,也是一套序列化规则,只是操作系统协议栈承担了,应用开发者通常无感知。
包括 HTTPS 在内,TLS 记录层会先把应用数据分块、加消息认证码、做加密填充,再交给 TCP。也就是说,即使你的应用层用的是纯文本的 GET 请求,它也已经被“协议级序列化”处理过好几轮了。
所以“HTTP 不需要序列化”这句话,准确说法应该是:HTTP 应用层允许你直接用人类可读的文本,但底层协议栈依然在替你做字节化。你不需要手动管理,不等于没有发生。
3.2 HTTP 各版本的“信封规范”本身就是一套线格式
HTTP/1.1 的报文结构是一个很典型的线格式约定:请求行、头部字段、空行、正文,正文长度靠Content-Length头来标明。接收端读到哪算完,全靠这个字段。如果没有这套字节布局规范,接收方拿到的是连续比特流,根本无法切分出“一条完整请求”。
看一个最朴素的请求:
GET /api/order?id=123 HTTP/1.1 Host: order-svc Accept: application/json {"orderId":"123"}空行之前是头部,空行之后是正文。对端解析头部时,需要知道头部在哪结束、正文有多长。这套规则就是 HTTP/1.1 的“线格式”,也就是协议层面的序列化约束。
HTTP/2 更加明显,直接引入了二进制分帧层,所有头部用 HPACK 压缩后放进帧,一个请求被拆成 HEADERS 帧和 DATA 帧。到这一步,HTTP 在传输层已经不再是“文本协议”,而是具备明确二进制帧格式的协议。说“HTTP 只是文本”早就过时了。
理解这一层,对排查问题有帮助。比如某个 HTTP 接口响应偏慢,而报文体其实不大,瓶颈可能不在应用层序列化,而在协议层分帧、TLS 加密、TCP 粘包/拆包的处理上。两者是不同维度的问题,不该混为一谈。
3.3 HTTP 业务里,什么时候要主动选序列化方案
分几个场景说。
常规内部接口、外部开放 API,用 JSON 就好。可读性强、调试工具多、跨语言支持好,不要为了性能玄学引入二进制方案。除非你确认报文体已经明显影响带宽或 CPU。
大报文、高吞吐接口,就应该考虑换成二进制格式,并配合压缩。我实测过一个典型场景:一个订单详情对象,JSON 序列化后 1.6MB,换成 MessagePack 约 900KB,再用 gzip 压缩到 250KB 左右。传输耗时降下来,服务端编码 CPU 也降了。
跨语言对端类型不齐时,优先做 schema 约束。Java 的 Long 在 JavaScript 里会丢精度这种问题,靠口头约定很难根治。要么在协议层面约定“ID 一律用字符串”,要么引入带强类型的二进制格式,从源头消灭歧义。
网关层尽量别碰业务序列化。网关的职责是路由、鉴权、限流,如果每个请求都要反序列化一遍再序列化一遍,CPU 会成倍增加。后面第五章我会专门讲这个。
HTTP 场景的结论是:协议本身已经自带传输层的封装,你要决定的是业务数据层采用什么序列化方式。选择标准不是“HTTP 需不需要”,而是“你的报文多大、对方什么语言、接口生命周期多长”。
4. RPC 场景:序列化是链路的命脉
4.1 一次 RPC 调用里,序列化出现了几次
RPC 的全称是远程过程调用,核心思路是让你像调用本地方法一样调用远程服务。但这层“像本地调用”的幻觉,全靠序列化支撑。
完整链路拆开是这样的:
- 客户端调用代理对象的方法,传入参数对象。
- 代理把方法名、服务名、参数对象交给序列化器。
- 序列化器把参数对象转成字节流。
- 传输层把字节流送到服务端。
- 服务端反序列化字节流,还原参数对象。
- 根据方法名定位到具体业务方法,用还原后的参数执行。
- 返回值再次序列化,传回客户端。
- 客户端反序列化,还原返回值对象。
一次调用里至少发生两次序列化和两次反序列化。而链路上还有注册中心、链路追踪、日志打印,这些组件往往也要处理序列化数据。所以 RPC 对序列化的依赖,比 HTTP 接口要重得多。
| RPC 环节 | 是否涉及序列化 | 说明 |
|---|---|---|
| 代理调用参数 | 是 | 参数对象变字节 |
| 网络传输 | 是 | 协议层再次封装字节 |
| 服务端接收 | 是 | 字节还原成参数对象 |
| 服务端返回 | 是 | 返回值变字节 |
| 客户端接收返回值 | 是 | 字节还原成返回值 |
| 注册中心/SDK 元数据 | 通常有 | 服务信息、调用链 ID 等也要编码 |
没有序列化,RPC 框架无法表达“一个嵌套结构体”“一个带类型的整数列表”。网络世界里只有字节,失去序列化器的解释,所有对象都只是无意义的比特。
4.2 文本型 RPC 与二进制 RPC 的选型边界
RPC 不只有二进制序列化一种玩法。JSON-RPC 就是很常见的文本型 RPC:报文可读、调试方便、任何语言都能实现。
但文本型 RPC 的代价明显。JSON 带字段名和语法分隔符,同样一个订单对象,JSON 可能要 1.8KB,二进制方案大概 500B。在高 QPS 下,体积差直接变成带宽差和 CPU 差。序列化时间上,文本解析也要比二进制按编号取数慢一个量级。
我自己的选型原则是四句话:
- 调试诉求高、接口粒度很粗、调用频率低:用 JSON-RPC,省心、直观。
- 服务间高频调用、数据量大、对延迟敏感:用二进制方案,并用 schema 约束双方契约。
- 团队语言混用严重:二进制方案反而更稳,因为强类型 schema 明确了每个字段的类型,减少跨语言歧义。
- 想用框架默认值:启动前先确认默认序列化器是谁。很多 RPC 框架的默认配置是语言原生序列化,性能不一定比二进制方案好,还有安全风险。
4.3 框架把序列化“藏起来”之后,常见的坑
RPC 框架把序列化透明化了,这带来的副作用是:很多人上线前根本不检查序列化路径。我见过几次典型事故。
参数对象没有实现可序列化接口。某服务用了 Java 系 RPC 框架,业务代码把自定义类直接当参数传。本地测试没报错,一上压测,流量一起来就抛异常,报的是“类不允许序列化”。原因就是那个类没实现相关接口,框架默认序列化器在运行时才发现问题。
接口升版本,老客户端没跟上。服务端新增字段、修改了字段编号,老客户端用旧协议反序列化新字节流,直接失败。这类问题在线上的典型表现是“服务端升级后,部分老客户端异常”。解决思路是新增字段时必须可忽略、不改变已有字段编号,必要时做双版本兼容。
语言原生序列化的安全风险。有些 RPC 框架默认使用语言原生序列化机制,这类机制历史上出过不止一次严重反序列化漏洞。攻击者只需要构造一个特殊字节流,反序列化时就能触发危险操作。防御手段不复杂:尽量不要用语言原生序列化做跨网络传输,换成白名单约束更严的二进制方案,或者在反序列化入口加类白名单校验。
RPC 场景的结论是:只要用了 RPC,序列化就会全程伴随。你真正需要做的是提前确认“框架默认用什么序列化器”“自己的参数对象是否满足要求”“未来版本变更如何兼容”。这些不在选型表上,却在事故列表里。
5. 什么时候可以“不序列化”
5.1 进程内调用:没有边界就没有序列化
本地方法调用是最典型的“不需要序列化”场景。对象还是同一个堆里的对象,传递的是引用,不涉及拷贝、不涉及字节转换。这也是 RPC 想要模拟的“本地调用体验”。
很多团队会在内部代码里把 DTO 转成 JSON 字符串再传给下一个方法,美其名曰“统一格式”。如果两个方法在同一个进程里,这就是纯粹的浪费。对象引用就在手里,转成字符串再解析一遍,白白消耗 CPU,还可能引入类型精度问题。序列化的存在是为了跨越边界,没有边界时,不做才是最快路径。
5.2 共享内存与特定 IPC:绕过序列化的特殊通道
跨进程不一定要跨机器,同机进程间有一些可以绕过传统序列化的通道。
共享内存:两个进程把同一块物理内存映射到各自的地址空间,一方写入,另一方直接读。内存里的数据结构能直接被对方访问,不需要转换成字节再转换回来。代价是双方要自己协商布局、处理同步,复杂度高,只适合特定场景,比如大规模日志传输、批量指标采集。
mmap大文件也类似。文件本身在磁盘上就是字节序列,映射进内存后进程直接读写这一块区域,不需要把“文件对象”序列化成另一种格式。这种情况下,所谓序列化对象根本不存在,只有共享缓冲区的读写。
还有纯透传的本机管道或 Unix Socket,虽然底层依然是字节流,但如果两端的程序约定好“按原始字节解释”,并且数据本身已经是对外协议格式,那就不需要在业务层再做一次对象转换。注意这里只是跳过了业务对象的序列化,字节本身当然还在流动。
5.3 本来就是字节的数据:文件和媒体流别二次序列化
图片、音视频、压缩包这类资源在磁盘上已经是字节序列。服务端要做的事情是读取并转发,底层可以走sendfile这样的机制,让内核直接完成文件内容到 socket 的拷贝,应用层甚至不需要把文件读进内存再写出去。
常见的反例是,有同事把图片的byte[]塞进 JSON 返回,为了安全还先做了 Base64 编码。这一步会把体积放大 33%,再算上 JSON 的字段开销,一个 1MB 的图片可能变成 1.8MB 的响应体。传输变慢,CPU 消耗也在涨。正确的做法是直接在 HTTP 响应里写二进制数据,或返回一个文件标识让客户端去下载。
区分规则很简单:数据在内存里是结构化对象,需要序列化;数据本身已经是对外格式的字节,直接搬运,不要再过一层序列化。
5.4 别在网关层做“重复序列化”
网关和代理层是“重复序列化”的重灾区。不少网关为了做日志记录、鉴权参数提取、响应改写,把请求体反序列化成对象,再序列化后转发。一次转发,等于多做两遍无用功。
如果网关只需要 IP、路径、Header 这些信息,完全可以直接转发原始字节流。Body 原样透传,不仅 CPU 省了,还避免了“反序列化-再序列化”过程中字段重排、精度变化带来的意外污染。
我见过一个网关,为了记录 POST 请求里的某个参数,把整个 JSON body 反序列化再序列化,结果把原本数字类型的字段重排成字符串,下游直接解析失败。血泪教训:透传层就要有透传的样子,不碰业务数据才是对上下游最大的尊重。
6. 判断法则:四问法加一张速查表
6.1 四个问题,决定你要不要序列化
我把判断过程压缩成四个问题,逐一回答就能定位。
第一个问题:数据会不会离开当前进程?调用发生在同一进程内,引用直接传,不需要序列化。调用要跨进程、跨机器,就需要序列化。这是最基本的分界线。
第二个问题:数据会不会被另一种语言或运行时读取?同一门语言的内部结构不一定能被另一门语言解释,必须转换成中间格式。Java 的 HashMap 结构在 Go 的 slice 面前毫无意义,没有中间格式,通信无法进行。
第三个问题:数据会不会被持久化,并在未来某个版本被读取?对象写数据库、写缓存、写消息队列,本质上都等于“序列化后保存下来”。而未来读取时,代码版本可能已经变了,这时序列化方案必须考虑版本兼容。
第四个问题:接收端是否可信任?反序列化不可信输入,等于允许对方参与你程序里的对象构造过程。只要接收的数据来自公网或低信任源,就必须选择安全的序列化方案和防御措施。
四个问题里,只要前三个有一个“是”,就必须做序列化;第四个回答“否”,那么序列化方案的安全性优先级要提到最高。
6.2 常见场景速查表
| 场景 | 是否需要序列化 | 推荐方案 | 理由 |
|---|---|---|---|
| 进程内方法调用 | 不需要 | 直接传引用 | 无边界,无转换 |
| 对外 HTTP API 返回结构化数据 | 需要 | JSON 或带 schema 文本格式 | 可读性好,跨语言友好 |
| 服务间高频 RPC 调用 | 需要 | 二进制序列化带强 schema | 体积小,性能高,契约明确 |
| 对象写入数据库字段 | 需要 | JSON 或二进制序列化 | 必须变成可存储格式,注意版本兼容 |
| 生产者/消费者消息队列传递 | 需要 | 视 Topic 协议而定 | 队列本质是跨进程传输 |
| 大文件下载 | 不需要二次序列化 | 直接字节流复制 | 文件已是字节,别转 JSON/Base64 |
| 同机共享内存传递 | 通常不需要 | 共享缓冲 + 自定义布局 | 双方直接读同一块内存 |
| 网关透传请求/响应 | 不需要 | 原始字节透传 | 避免重复序列化开销 |
| 缓存存储对象 | 需要 | 序列化后写入,选型看读取频率 | 缓存也是持久化存储的一种 |
这张表可以直接当核对清单用。大部分线上问题都能归入其中一行:要么做了一个不该做的序列化,要么漏掉了一个该做的。
6.3 我沉淀下来的几条工程习惯
最后分享几条我在项目里打磨出来的习惯。
对外接口一律先用文本格式起步,维护可读性和生态适配;服务内部的高频链路用二进制并强制契约评审。这个组合让我既保住了调试效率,也控制住了整体资源开销。
所有接口的新增字段默认走“可忽略”策略,不允许读取端因为未知字段直接崩溃。跨服务改造时,服务端先发兼容版本,确认客户端全部升级后再启用新逻辑。这套流程虽然慢,但能避开最坑的“序列化版本不兼容”线上事故。
绝不把语言原生序列化用于跨网络传输。无论框架默认是什么,我都会显式配置成白名单控制的二进制方案,并在反序列化入口加校验。安全风险这种东西,不在流量高峰期赌运气。
遇到性能问题,先看序列化开销占比,而不是盲目加机器。火焰图里如果 encode/decode 相关的函数占用超过 20%,这段代码的真正问题往往是选型,而不是机器不够。
结尾
把序列化看成“跨越边界的代价”,这道题就清晰了。判断要不要做序列化,从来不取决于协议叫 HTTP 还是 RPC,只取决于数据是否跨越了进程边界、语言边界、时间边界。我自己的体会是,能把这个判断想明白,比背一串序列化框架的名字有用得多。
最后给一个动手建议:挑你们调用量最大的一个接口,把火焰图打开,看看 encode、decode、序列化、解析这一排函数占了多大比例。如果超过两成,那大概率是序列化方案在为当初的“图省事”买单。趁早换掉,流量越高越值得。