从对象到字节流:HTTP与RPC场景下的序列化原理与实战避坑

发布时间:2026/10/9 17:31:01
从对象到字节流:HTTP与RPC场景下的序列化原理与实战避坑
“我调一个 HTTP 接口为什么动不动就谈 JSON 序列化我直接传字符串不行吗”这类问题我在团队里被问过太多次了反过来的版本也常见“RPC 不是说好了透明传对象吗怎么底层全在聊序列化”这两个疑问本质上是一个问题我们代码里的是一个对象而网络上跑的是字节流中间这道“翻译”的活到底什么时候归框架管什么时候必须自己动手这篇文章就把 HTTP 和 RPC 两条线拆开讲清楚顺便把大家日常踩过的 Content-Type、Redis 乱码、超时报错、反序列化漏洞这些坑串在一起说掉。1. 先把本质讲透序列化到底在解决什么问题1.1 内存里的对象和网络上能跑的东西根本不是一回事你在 Java 里写User user new User(张三, 25)这行代码执行完内存里其实是一块区域对象头、字段值、可能还有若干指向别的对象的引用地址。这个“User 对象”只在当前进程、当前语言运行时里才有确切意义。网络不是这样工作的。TCP 层往下所有数据都是一段连续字节没有“字段名”没有“对象头”更没有“引用地址”。从一台机器把数据搬到另一台机器本质上就是“打包字节、发出去、再按约定解出来”。这个打包过程就是序列化解包过程就是反序列化。很多人在这里有个误区我传的是字符串不是对象是不是就不用序列化了字符串在内存里也对应一段字节编码后的当你要把它放进网络报文、写进磁盘、塞进 Redis同样要解决“按什么规则变成字节、按什么规则恢复”的问题。只不过字符串的规则太常见了常见到你已经不觉得它是个序列化过程。1.2 那“直接复制内存”行不行有人会想既然内存对象就是一堆字节那我直接把对象的那段内存原样发过去不就行了我提三个问题你瞬间就知道为什么不行。第一引用地址跨机器毫无意义。对象里有个字段指向另一块内存这个指针的值 0x7fff1234在接收方机器上可能是完全无关的地址甚至本身就是非法地址。第二语言运行时不对齐。Java 的 int 固定 4 字节Python 的 int 是变长对象C 的结构体还有内存对齐和字节序问题直接发过去反解析必乱。第三你没法优雅地演进。内存布局一旦变化协议立刻崩掉而序列化协议可以加字段、加版本号、做兼容。打个比方内存对象相当于你脑子里的“菜的味道”你没法把味觉直接复制给另一个人必须转化成菜谱文字或者一段语音描述对方再按这个描述重新把菜做出来。序列化就是那个菜谱。1.3 概念边界哪些操作“像序列化但不是序列化”实际开发里有几个概念经常和序列化混在一起把边界理清楚了后面判断“要不要序列化”会简单很多。字符编码UTF-8、GBK解决的是“字符 ↔ 字节”的映射。JSON 字符串本身也是字节它先完成序列化再按 UTF-8 编码成网络字节。编码是序列化的下一层。URL 编码把特殊字符变成%20、%E9这种形式解决的是“把任意字符放进 URL 文本”的转义问题不算严格意义的对象序列化但它的定位一样把结构化信息变成可传输的文本。Base64解决“二进制字节放进文本通道”的问题。比如把图片字节塞进 JSON你得先 Base64。它不描述对象结构只做字节到文本的映射。压缩GZIP纯粹减少体积不管结构。先序列化再压缩这是常见的组合套路。搞清楚这些之后你会发现“序列化”这个词的真正核心是对象结构和字节序列之间的一整套转换规则。规则由序列化协议定义而 HTTP、RPC 只是这个规则的运输载体。2. HTTP 场景不一定要序列化但按 Content-Type 来2.1 完全不用关心序列化的场景HTTP 请求本质上是由三部分组成请求行、请求头、请求体。请求行和请求头本身就是纯文本格式协议规范已经把格式定死了比如POST /api/user HTTP/1.1 Host: example.com Content-Type: application/json这些文本不需要你做任何序列化你只要按协议拼字符串就行。真正不用管序列化的典型场景是这几类静态资源。服务器直接读 HTML、CSS、图片、视频文件把文件字节原样作为响应体返回。比如 Nginx 托管一个 mp4 文件它关心的是文件在磁盘哪个偏移量压根不关心文件内容是什么结构。纯文本接口。接口只返回一段字符串比如OK、hello world客户端直接按text/plain读。字节怎么来、怎么还原都是现成的不用你管。代理转发。比如反向代理只负责把收到的字节流量透明转发给后端它不解析业务字段也不修改 body自然不需要序列化。在这些场景里序列化“缺席”是正常的因为数据本身就是可传输的字节形态。2.2 需要序列化的场景几乎都在 body 和 query 里一旦你要在 HTTP 里提交“结构化数据”——一个对象、一张表单、一组字段序列化就来了。举几个最常见的Ajax 提交 JSON。前端代码大概是JSON.stringify(formData)后端拿到字符串再转成对象。这里的JSON.stringify就是序列化后端接口框架里的parse/readValue就是反序列化。你每天都在做只是没意识到它叫这个名字。表单提交。form表单默认的类型是application/x-www-form-urlencoded浏览器会把表单字段拼成key1value1key2value2的文本并把特殊字符做 URL 编码。这就是浏览器帮你完成的一次序列化。文件上传。multipart/form-data会把每个文件、每个字段用一段随机 boundary 分隔开序列化成带边界的多段报文。你不需要手写这个格式但你要知道它确实是一次结构化编码。XML 接口。老系统对接时经常碰到application/xml的 WebService你得把请求对象序列化成 XML 文本解析响应时再反序列化回来。判断方法其实很短只要你需要在 HTTP 报文的 body 或 query 里表达一个“有结构的东西”你就得定义或使用某种序列化规则。2.3 GET 参数那种 keyvalue 算序列化吗这个问题很多文档不直接回答。我的看法是它算一种“轻量级结构化编码”但和完整意义的对象序列化有区别。GET /api/user?name张三age25里query 的形式是协议规定的文本格式它只能表达扁平的键值对没法表达嵌套对象、数组、二进制。所以严格说它是“按规则拼出的文本参数”不是“对象序列化”。但你要知道从服务端拿到name张三age25再解析成对象这个过程和反序列化在思想上完全同源都是把传输文本还原为内存结构。实际项目中有人会把 JSON 塞进 query 参数比如?filter{name:张三}这时候 JSON 串再被 URL 编码一层。这种“序列化之上再序列化”的做法虽然丑但在不少老系统里真实存在看到别慌逆向解一层就是。2.4 判断口诀看 Content-Type 就知道要不要序列化我常用的判断口诀是看 Content-Type它就是对端解析器的说明书。Content-Type: application/json—— 说明 body 里是 JSON 序列化后的文本需要用 JSON 解析器反序列化。Content-Type: application/x-www-form-urlencoded—— 说明是表单编码需要按切分再 URL 解码。Content-Type: multipart/form-data—— 说明是多段编码需要按 boundary 分割。Content-Type: application/octet-stream—— 说明就是原始字节流没有结构拿到直接写文件或处理。Content-Type: text/plain—— 说明是纯文本本质上没有结构信息。所以回到经典场景后端接口接收 JSON你非要把对象手动拼接成自定义字符串发过去然后把 Content-Type 写application/json对端解析必挂。序列化格式和 Content-Type 必须配套这是 HTTP 场景下最常见也最隐蔽的错误。3. RPC 场景不是“要不要”而是“框架替你做了”3.1 RPC 调用链里序列化发生在哪儿先纠正一个常见认知偏差很多人拿 RPC 和 HTTP 当对立面其实不对。HTTP 是应用层协议RPC 是一种远程调用设计模式RPC 可以跑在 HTTP 之上比如 gRPC 就跑在 HTTP/2 上JSON-RPC 也常走 HTTPRPC 也可以直接跑在 TCP 上比如老版本 Dubbo 默认就是 TCP Hessian2。RPC 的设计目标是“像调用本地方法一样调用远程方法”。你写userService.getUserById(1001)目标是让这个调用在另一台机器执行后把结果返回来。为了实现这个目标RPC 框架在内部做了几件事找到目标地址、把方法名和参数打包、通过网络发给对端、对端解包、执行方法、再把返回值打包发回来。这里面的“参数打包”和“返回值打包”就是序列化和反序列化。它发生在 RPC 框架底层对业务开发完全透明。所以你说“RPC 什么时候需要序列化”答案是每一次真实调用都需要只是你没看见。这套链路大致是这样客户端User u proxy.getUser(1001)→ 代理对象拦截 → 序列化参数 → 网络传输服务端网络接收 → 反序列化参数 → 反射调用业务方法 → 序列化返回值 → 回传客户端反序列化返回值 → 还原成 User 对象3.2 感觉不到不代表你不需要理解虽然框架替你做了但业务开发依然躲不开序列化相关的几个场景跨语言调用。你的服务是 Java 写的对方是 Go 写的两边必须用同一种序列化规则。如果团队选型选了 Protocol Buffers那结构定义是.proto文件Java 生成 Java 类Go 生成 Go 类两边都按同一套二进制规则解析数据。不懂序列化规则跨语言调试会非常痛苦。RPC 超时和体积调优。JSON 文本序列化体积大、慢二进制序列化体积小、快。在核心链路上一个几十万 QPS 的接口光序列化 CPU 开销就能吃掉好几台机器。你会看到很多团队把宁愿牺牲可读性也要换成 Protobuf就是因为省下来的不是一点半点。接口兼容性问题。服务端加了一个字段老客户端传过来的报文里没有这个字段反序列化会不会崩这取决于序列化协议的兼容设计。Protobuf、Thrift 都有很好的向前向后兼容机制而 Java 原生序列化或者某些自定义序列化稍微变个结构就报错。这个选择是架构层面的事但你迟早要为它背锅。3.3 什么时候你得自己补序列化有三类场景框架帮不了你必须自己动手。第一类是byte[] 透传。RPC 接口参数直接定义成byte[]框架会原样传输不做结构解析。这种做法适合已经序列化好的数据比如图片、加密数据、已经成型的消息体你就省掉了二次序列化。代价是接收方得自己知道这些字节是什么格式。第二类是裸写 TCP 自定义协议。你用 Netty 自己实现了二进制协议比如“4 字节长度头 业务消息体”。这时候没人给你定义 IDL也没框架生成代码你得自己对 MessagePack、Protobuf 或者自研格式做编解码封装。这种项目搞多了就会发现序列化往往才是协议设计的核心难点。第三类是中间件存储。RPC 调用链路里参数经常要经过 Redis、MQ。比如把对象塞进 Redis 缓存会用 JDK 序列化或 JSON 序列化把对象发到 Kafka 消息里也需要选择一个序列化器。这块很容易出问题后面第五章我会专门讲一个 Redis 序列化乱码的典型坑。3.4 不同 RPC 协议的序列化风格再补充一个视角不同 RPC 体系的序列化设计风格差异很大。gRPC默认 Protobuf二进制、强类型、IDL 先行跨语言体验好性能优秀是云原生时代的标配。Dubbo默认 Hessian2后来也支持 JSON 和 Protobuf。Hessian2 是二进制序列化Ja​​va 生态里效率不错但跨语言支持一般。Thrift带 IDL 编译器支持多语言序列化格式有 Binary、Compact 等多种选择Facebook 早期推的现在仍有不少公司内部在用。SOME/IP车载以太网领域常用的通信协议最大的特点是把序列化规则直接写进了标准规范里每个字段从字节对齐到数据类型都有严格要求ECU 之间通信基本不留解释空间。这也能看出凡是要做跨进程、跨设备的可靠调用序列化规范就是协议本身的一部分。理解了这些以后再看到“某 RPC 框架支持哪些序列化方式”这个问题你脑子里第一反应就应该是这是选协议的顶层约束不是配置项的小事。4. 文本序列化还是二进制序列化怎么选4.1 常见的序列化方案对比我平时给团队讲选型会搬出这张表方案类型体积性能可读性跨语言典型场景JSON文本大中好极好HTTP API、Redis 缓存、日志XML文本很大慢中好老 WebService、配置文件Protobuf二进制小快差好gRPC、微服务内部链路Thrift二进制小快差好内部 RPC、多语言混部很少用Hessian2二进制中中差一般Dubbo 默认MessagePack二进制小快差好嵌入式、IoT 场景Java 原生二进制大慢差极差基本不推荐仅 JDK 自带这个表背后有一条主线可读性和性能是成反比的。JSON 你抓包就能看懂调试方便排查问题直接 curl 一下即可Protobuf 抓包全是二进制乱码得配专门插件或者写解析脚本才能看懂但换来的是几倍的体积和速度优势。4.2 选型建议别被“性能好”一票带跑我的实际建议是分场景判断而不是无脑上 Protobuf。对外 API 优先 JSON。因为你的调用方可能是各种语言、各种技术栈、甚至可能是不会写代码的业务人员在调接口工具。JSON 的调试友好性是最大的优势。哪怕内部想用 Protobuf需要权衡的点也不是性能够不够而是“跨团队协作成本高不高”。内部高并发 RPC 链路优先二进制。当你的服务端到端多跳、报文反复进出网络时同样的结构体Protobuf 可能比 JSON 小一半甚至更多序列化耗时也只有 JSON 的几分之一。在高频小报文场景CPU 收益非常客观。跨语言场景必须用 IDL 约束。只要涉及 Java 和 Go或者其他语言互相调用建议直接用 Protobuf 或 Thrift。因为 JSON 虽然有“跨语言可读”但结构靠约定两边字段大小写不一致、类型对不齐都是线上的雷。IDL 能把这种“口头约定”变成编译期约束。嵌入式/IoT 场景选 MessagePack 之类。SOME/IP 在汽车行业的原则也类似报文要尽量小、字节对齐要明确、解析要确定性强。这类场景对体积和功耗敏感文本协议基本出局。4.3 兼容性设计踩过的版本演进教训序列化最容易翻车的地方是“上线加字段”。有过一次惨痛经历服务端给对象加了一个非空字段结果从线上到线上、从老客户端到新客户端的流量全挂了。原因是当时用的序列化方案对未知字段直接报错。所以总结几条经验加字段时给默认值。不管 JSON 还是 Protobuf新字段必须可以缺省反序列化后给默认值。请求和响应都要遵守。不轻易改字段名和类型。改名等于删掉旧字段再加新字段新旧版本网关可能同时存在。类型从 int 改成 long 在部分协议里也是不兼容操作。版本号不是银弹。你可以带上序列化版本号但版本号只解决“知道版本不匹配”不解决“老服务能解析新报文”真兼容还得靠字段级兼容设计。拒绝 Java 原生序列化做存储。JDK 序列化产物带完整类路径和大量元数据体积大、解析慢关键是安全性差。放进缓存、MQ 里早晚踩坑。这部分的落地建议是架构选型时就把兼容规则写进开发规范而不是等出了故障再补。很多团队序列表随便定的线上加字段必挂这话我能再强调一百遍。5. 实战中常见的序列化困惑与报错排查5.1 网络超时报错别急着怀疑序列化先看两类很常见的报错cannot finish rpc call in 30 seconds: nul,done. curl 56 recv failure: 连接超时第一类常见于 Docker CLI 和 Docker daemon 之间CLI 本质是在调一个 HTTP 接口只是接口的叫法里带着 “rpc call”。30 秒没拿到响应就超时和“参数结构解析不出来”没有关系是传输层的问题。第二类是 curl 下载或请求时连接中途断掉最常见的原因是连接被服务端主动关闭、代理层断流、网络长时间卡死、或者服务端在响应完成前进程崩了。排查这类报错我一般按这个顺序来先确认服务端是不是正常。看对方进程在不在、CPU 是否打满、业务日志是不是有异常。再确认网络。小文件测一下连通性大报文场景检查有没有代理、有没有 MTU 分片问题。最后才怀疑序列化。序列化失败的报错通常是“反序列化失败、字段解析异常、EOF 意外结束”特征和超时完全不同。还要注意 HTTP 连接复用的问题。一个 keep-alive 连接空闲久了服务端可能已经关了客户端不知道继续复用旧连接就会看到偶发的recv failure。遇到这类偶发超时多半不是序列化而是连接生命周期管理的问题。Docker 报 “500 Internal Server Error” 也是同类逻辑docker search redis request returned 500的意思是 daemon 的 HTTP 接口给了个 500先看 daemon 日志、看资源、看网络不要一上来就扒序列化代码。5.2 Content-Type 不对导致解析失败有一次联调对方说接口返回的数据他们解析失败。我一看他们发的请求头Content-Type 写的是text/plain但 body 是 JSON。服务端一看是 text/plain按纯文本处理不去反序列化或者框架绕过了 JSON 解析器拿到的就是字符串本身。两边各看各的我发的是 JSON你返回的是字符串怎么对得上反过来也有一种服务端返回application/json;charsetUTF-8客户端解析正常如果返回application/json却没带 charset有的老客户端默认按 ISO-8859-1 解码中文全乱。这不是序列化规则的问题是字符编码层的错误但表现起来和“反序列化结果不对”一模一样。所以排查解析类故障第一件事永远先看抓包或者响应头确认 Content-Type 和实际 body 格式一致再往序列化细节里钻。工具用 Wireshark 抓一次请求或者浏览器开发者工具看一眼响应头比翻半天代码快得多。5.3 Redis 序列化乱码Key 和 Value 的序列化器对不上Redis 场景也到处都是序列化的坑。最经典的是 Spring Boot 项目里用 RedisTemplate 存对象下一次查询时发现 key 变成了一串\xAC\xED\x00\x05t...这样的鬼东西。原因很简单Spring Data Redis 默认用 JdkSerializationRedisSerializer。你存的时候opsForValue().set(user: id, userObj)key 和 value 都被 JDK 序列化成了二进制字节写进 Redis所以你在 redis-cli 里看到的不是user:1而是一段乱码字节。第二次查询如果换成 StringRedisTemplatekey 按字符串规则拼当然查不到。解决办法是统一序列化器要么都用 StringRedisSerializer 存 JSON 字符串要么给 RedisTemplate 的 key 和 value 分别配置明确的序列化器并且全项目保持一致。我的习惯是key 一律用 String 序列化value 存对象时用 JSON 序列化器这样 Redis 里既能看到可读的 keyvalue 也能直观排查。别贪图省事直接用 JDK 序列化除了乱码问题它还有安全风险。5.4 反序列化安全不可信字节流的教训提到安全就不能不说反序列化漏洞这在实战里是真实存在的高危问题。原理很好理解反序列化不仅仅是“把字节变回对象”很多语言的反序列化机制在还原过程中会自动调用类的一些方法比如 Java 的readObject()PHP 的__wakeup()或__destruct()。攻击者如果精心构造字节流这些方法就会被恶意利用直接造成代码执行。Pikachu 靶场里有一大堆反序列化漏洞的练习题Fastjson 的 autoType 历史漏洞更是业内教科书级别的案例。Fastjson 早期为了兼容多态类型在 JSON 里用type字段指定类名反序列化时直接按该类名实例化对象攻击者就能通过这个入口触发危险类里的 gadget 链最终拿下服务器。防御思路其实很朴素永远不要反序列化不可信的输入。对外部传入的数据先做白名单校验类型不在名单内直接拒绝。关闭不安全的特性。Fastjson 这类库的新版本默认关闭 autoType生产环境除非万不得已别打开。如果是老项目升级到大版本是正经出路。用安全的序列化方案。优先考虑 JSON 这类“只描述数据、不触发类方法”的方案或者 Protobuf 这种强类型带校验的二进制方案。更新依赖。这一条一定写在清单里反序列化漏洞的修复大多靠升级别让 CVE 列表里的老版本在线上过年。安全区想强调一句这不是“黑客才需要懂”的事。你写了一个 RPC 接口、一个消息消费者就是在反序列化别人传来的数据你就有责任对那部分输入保持警惕。5.5 排查这类问题我常用的几个习惯最后分享几个实在的排查习惯都是多年踩坑换来的第一先分层再定位。任何“调用失败”先分清是传输层、协议层还是序列化层的问题。超时、断连大概率是网络或服务端Content-Type 不对、解析器拿不到正确的流大概率是协议层字段解析失败、类型转换异常才是序列化本身的锅。第二把抓包技能练起来。Wireshark 抓 HTTP 报文直观看到 body 字节比反复看代码日志高效十倍。很多“我以为我传的是这个”的问题抓一次包就真相大白。第三日志里打全链路的关键节点。序列化前打原始对象反序列化后打关键字段一对比就知道是哪边出的问题。不要只打“序列化失败”四个字要打异常类名、堆栈和当前对象 toString。第四改动序列化方案时旧数据兼容性想清楚。缓存、MQ 里的历史数据不会陪着你“升级即忘”切换序列化器之前先确认老数据怎么迁、要不要兼容读。我的习惯是给每个序列化相关配置都写上注释注明选型和历史原因不然半年后的同事包括自己看着一堆序列化器配置根本不敢动。踩坑不可怕可怕的是坑挖完了没人画地图。