跨语言通信必看:从JSON到二进制序列化的性能优化实践

发布时间:2026/10/5 12:05:59
跨语言通信必看:从JSON到二进制序列化的性能优化实践 手头有个活要调一个跨语言的通信协议队伍里一边是Java一边是Go还有一堆嵌入式C。一开始大家图省事全用JSON结果压测一跑150字节的报文直接膨胀到1.2KCPU还被解析逻辑烧掉不少。后来把核心通信层整体换成了二进制序列化方案报文体积缩到原来的十分之一解析耗时也降了一个数量级带来的新麻烦就是排查问题的时候再也“肉眼可见”了Byte数组里全是天书。这篇文章就把我这次改动前后涉及的完整技术细节捋一遍从方案选型、字段编码、跨语言兼容到反序列化安全按实操顺序全部铺开。1. 序列化和反序列化到底是干什么的1.1 一次通信背后的两次“翻译”序列化和反序列化本质上就是两个互逆的过程序列化把内存里的对象、结构体、字典变成一段连续的字节流反序列化再把这段字节流恢复成内存对象。注意不是把对象“拍平”而是完完整整地在两台机器、两种语言、两种架构之间搬运数据这个过程要解决三个核心问题数据的线性化表达把指针、引用、嵌套结构变成一长串连续的0和1数据类型的自我描述让接收方能清楚解析每个字段的类型与边界跨语言跨平台的表示一致性不能出现Java写的int和C读出来的int对不上号的情况落到底层序列化就是在“内存里的数据”和“字节流”之间做映射。内存里的int是4个字节float是4个字节字符串是一段连续字符加长度。序列化要做的就是把字段的顺序、长度、类型约定好再按照这个约定把内存内容原封不动地搬出来。类比一下序列化就好比你要把一柜子衣服托运到外地你得把衣服叠好、压缩、装进箱子写好清单标注每件是什么反序列化就是收到箱子之后的拆包过程照着清单把衣服一件件拿出来、挂好恢复原样。核心在于那份“清单”——也就是格式约定。1.2 文本格式与二进制格式的本质差异很多团队目前仍默认使用JSON、XML这类文本格式做数据交换它们其实是二进制序列化方案的“对照组”。JSON优点明显可读性好、调试方便、主流语言都有完整支持但它有几个很难忽视的性能短板体积大整数123在二进制里占2字节short或4字节int在JSON里至少是3个字符如果再带上字段名age: 123那需要的字节数会进一步增加。字段名会被反复发送本身就是一种冗余。解析代价高JSON解析涉及字符串扫描、跳空白、字符转义、数字逐位识别每一步都是CPU密集操作。在高吞吐场景下JSON的解析开销常常占请求处理总耗时的一半以上。浮点数误差风险序列化双层浮点数时JSON格式需要转成十进制字符串涉及到精度取舍1.1这种数字在二进制浮点下本来就不精确经过字符串转换之后读回来很容易出现微妙差异。缺乏类型约束JSON的数字不区分int、long、float、double下游得靠猜或者靠文档约定。运气不好一个用float存的数值被下游当作double读出来整体就会出问题。二进制序列化方案则完全不同。直接把内存里的整数按字节写出来double直接按IEEE 754标准拷贝8个字节字符串前面加个长度标记即可输出就是紧凑的原始字节流。代价是调试时人眼无法直接阅读这也是很多团队明知JSON性能差却迟迟不肯迁移的原因。1.3 二进制方案的典型应用场景二进制序列化并不是一个“万能银弹”它有自己的合理适用范围下面是几个最适合落地到生产环境的场景RPC框架内部通信协议Dubbo的Hessian2gRPC的Protobuf以及自研协议服务间调用延迟和数据量敏感时首选游戏服务器状态同步与客户端通信量级小而频率极高每毫秒都要同步数十个实体状态IoT设备上报数据嵌入式设备带宽和电量都极其有限报文能省一个字节是一个字节分布式存储引擎的落盘格式日志文件、索引文件、状态快照批量读写对空间利用率和读写速度要求都极高消息队列的消息体编码Kafka、Pulsar这类系统允许自定义序列化器也正是为了压缩消息体积、提升吞吐如果你遇到的场景是浏览器端跨域接口调试、配置文件、开放API对外输出那还是务实一点继续用JSON就行。二进制方案带来的性能提升在这种场景下完全体现不出来反而徒增排查门槛。2. 设计二进制格式前一定要想清楚的四件事2.1 字节序是头号大坑字节序Byte Order是所有二进制协议设计里最容易踩、也最难排查的坑。Intel和AMD的x86架构是小端Little-Endian低字节放在低地址而网络协议和很多RISC架构默认使用大端Big-Endian。如果发送方是小端机器接收方是大端机器又不做转换整数读出来会完全错乱。举一个真实的差错例子 发送方内存里的int值0x01020304在小端机器上按字节排放的顺序是04 03 02 01接收方拿到这4个字节后如果按大端规则解释得到的值是0x04030201完全变了。这个问题跨语言、跨平台时几乎必然出现只要两边的CPU架构不同。处理这个问题有两条路线全局统一使用网络字节序大端发送前转换接收后转换。这种方式写代码时容易遗漏而且每做一次数据交换都要转一次。在格式头中显式声明字节序发送方把当前主机字节序写进头部标志位接收方读到之后自行判断是否需要翻转。灵活性高但要自己实现翻转逻辑。绝招是在项目初期就约定一个“主线语言”的字节序作为基准所有其他语言的实现都以这个准绳为准避免各写各的、各转各的造成二次翻转。2.2 长度字段的设计定长还是变长长度字段是二进制协议的骨架。拿字符串切片来说如果接收方不知道这段字符串多长它就没有办法从字节流里正确切出边界。最常见的做法是“长度前缀数据本体”即先写一个整数表示长度再写实际内容。长度字段本身也有设计空间长度方案存储方式适用范围优缺点固定长度4字节int始终占满内存宽度长度值范围小业界成熟解析快长度值小的时候浪费几个字节手动变长1字节表示0-255长度不够就用标记位升级短消息多、长消息少的业务实现复杂读取逻辑分支多VB64/Leb128每字节高1位是延续位低7位是数据适用于长度差异极大的混合负载无损压缩但要循环解析以个人经验来看业务系统里90%的消息长度都远小于64KB这时候用“2字节定长数据本体”最合适。最稳妥的做法是先统计线上真实的报文长度分布再确定采用定长还是变长不要想当然地套用某个“标准”。2.3 类型标记让协议具备自描述能力“哪一段字节代表什么”是二进制协议的灵魂。要做到让接收方不解自明常见的设计有3种固定字段顺序模式发送和接收双方共享一份结构体定义通信时只传字段值完全不传字段名和类型信息。这种模式最高效但任何一方的结构体改动都会导致解析错乱对协议版本兼容要求极高。TLV模式Type-Length-Value每个字段先写一个类型标签再写长度最后写值。接收方看到标签就知道后面如何解析。这种模式支持向后兼容可以跳过未知字段是工业协议的主流选择。索引标记模式发送方只传字段的编号而非字段名接收方根据编号找到对应的字段定义。Protobuf就是这套逻辑字段号一旦定下终生不能改。对于自研协议我强烈建议至少预留一个类型标记的位置哪怕初期只是0x01。因为线上系统一定会遇到“加一个字段”的需求没有类型标记的年代加字段就意味着协议版本号要变两边必须同步升级想想都头大。2.4 字段类型编码策略二进制格式里基础类型的编码策略直接决定报文体积整数型int32和int64直接定长输出看着很省心但如果业务里大量数值都很小比如状态码1、2、3使用Protobuf的Varint方案会更优小数字只占1字节大数字自动扩展到最高10字节。布尔型理论上1bit就能表达但如果单独占用1字节就浪费了。可以把多个布尔值拼成一个bitmap位域或者干脆放进Flags字段。字符串推荐先写字符数注意是字符数非字节数再写UTF-8字节序列接收方才能正确处理中文。浮点型float和double均直接按IEEE 754位模式拷贝不要在编码层做任何十进制转换。数组和列表写长度、写元素个数、再逐元素编码。注意在C语言场景下编译器的默认对齐填充可能会在结构体里偷偷插入padding直接二进制拷贝结构体很容易出错必须逐个字段手动编码。3. 一个消息系统的二进制序列化重构实操3.1 原始JSON实现的问题复盘我之前维护的一个即时通讯IM服务端登录、单聊、群聊、系统通知全部走自定义JSON协议。消息结构大概是这样的{type: 1, fromUserId: 10001, targetId: 20002, content: 你好这是一条测试消息, timestamp: 1733456789, msgId: 89723456780}这个报文实际算下来将近120字节。压测200并发在线每条消息还要带上客户端信息、路由信息等一堆冗余字段200字节的规模是常态。消息量大之后网关的带宽占用首先拉满其次是消息的序列化和解析造成大量CPU消耗。更痛的是很多老版本App的消息解析逻辑在不同平台实现出现对齐问题、乱码问题派生了无数个bug。3.2 自定义二进制协议的整体设计基于上面的痛点我设计了一套极简协议协议头固定8字节偏移字段长度说明0Magic2字节魔数0x5A6B用于快速校验2Version1字节协议版本号用于兼容3Type1字节消息类型4HeaderLen2字节头长度6BodyLen2字节体长度紧接着头部是消息体消息体内部字段采用“字段号长度值”的TLV编码字段号1字节长度1或2字节值按类型编码。数字优先用Varint压缩字符串用UTF-8存储。这套设计有几处刻意为之的细节Magic起快速过滤作用解析数据流时如果前2字节不是0x5A6B直接丢弃避免脏数据进入解析器。头部和体部的长度分开这样日志采集时可以根据HeaderLen迅速定位到真正的消息体不需要完整解析整个包。字段号用整数而不是字段名省掉了字符串匹配开销也给后续字段扩展留了空间。3.3 从Java侧实现序列化与反序列化以Java为例核心编码过程如下public class BinaryMessageCodec { public static byte[] encode(Message message) throws IOException { ByteArrayOutputStream baos new ByteArrayOutputStream(64); DataOutputStream out new DataOutputStream(baos); out.writeShort(0x5A6B); // Magic out.writeByte(0x01); // Version out.writeByte(message.getType()); // Type out.writeShort(0); // HeaderLen 预留 out.writeShort(0); // BodyLen 预留 // 预留头部扩展字段先空着 // 写入消息体start index int bodyStart baos.size(); writeField(out, 1, message.getFromUserId()); writeField(out, 2, message.getTargetId()); writeField(out, 3, message.getContent()); writeField(out, 4, message.getTimestamp()); writeField(out, 5, message.getMsgId()); int bodyLen baos.size() - bodyStart; // 回头填BodyLen out.flush(); byte[] bytes baos.toByteArray(); bytes[6] (byte) ((bodyLen 8) 0xFF); bytes[7] (byte) (bodyLen 0xFF); return bytes; } private static void writeField(DataOutputStream out, int fieldNo, String value) throws IOException { if (value null) return; byte[] utf8 value.getBytes(StandardCharsets.UTF_8); out.writeByte(fieldNo); out.writeShort(utf8.length); out.write(utf8); } // 数值字段的Varint编码略 }关键点是消息头里的BodyLen要等消息体写完再回填。这里DataOutputStream默认按大端序写正好和我们的协议定义一致。反序列化是逆过程入口先校验Magic再读Version再按字段号逐一遍历。这里要特别提醒消息体长度字段的值不能被直接信任在分配缓冲区之前一定要核对这个值是否在合理范围内否则遇到恶意构造的数据包接收方很容易被拉进OOM的坑。3.4 实测性能对比与数据改造完成后我在同样的机器上做了AB对比测试结果如下指标JSON方案二进制方案改善幅度1000条消息总体积118840字节8490字节减少了92.85%单条消息平均大小118.8字节8.5字节—序列化1000条消息耗时46ms6.6ms快了约7倍反序列化1000条消息耗时52ms5.1ms快了约10倍这个结果完全符合预期因为消息体中最长的是内容文本而JSON格式里字符串要加双引号还要转义换行和引号导致体积膨胀。二进制方案用两字节长度前缀替代全部格式符号体积自然显著下降。序列化省掉的还有数字到字符串的转换开销。有了这次实测数据我再也没有被“二进制太难看不懂”这种声音说服过。这个直观对比也成了我在团队内部推动技术选型的最佳材料。4. 跨语言互通与版本兼容的实践心得4.1 为什么跨语言场景最容易出幺蛾子如果通信的双方是Java那问题还不大直接用同一套序列化框架即可。但业务一旦出现“Java后台 Go微服务 C客户端 Python数据分析脚本”麻烦立刻浮现。光是“整数是多少字节”“字符串用什么编码”“结构体字段顺序是否一致”这些问题就能折腾半天。跨语言互通的关键原则有两条格式规范必须以文档形式锁定为唯一事实源不能以任何代码实现作为参考标准。代码会改文档应该保持同步。每种语言实现必须有一套独立的测试用例用相同的输入做编码再用他方解码验证。没有交叉验证就别声称“互通”了。4.2 用测试向量保障跨语言正确测试向量是我对抗跨语言的利器。先选定一批固定的输入对象包含各种边界值负数、极大数、空字符串、中文、emoji、Nesting结构等由参考语言的实现序列化得到预期的字节数组然后把字节数组存成十六进制文件。每个新语言的实现都要保证对同一份测试向量输出完全一致的十六进制结果。以本协议为例编号输入数据期望十六进制输出vector01message(id:1, content:A)5A 6B 01 01 00 05 00 05 01 01 01 01 02 01 01 03 01 01 41 04 01 01 01 05 01 01 01vector02message(id:2, content:中文)5A 6B 01 01 00 05 00 05 01 01 02 02 01 02 03 01 06 E4 B8 AD E6 96 87 04 01 01 02 05 01 01 02这样每次改协议或者新增语言实现只要跑一遍向量就能发现不兼容点。不同语言对字节数组的打印表现各不相同但十六进制是中性表示任何语言都认。4.3 版本兼容策略线上系统很难做到同时升级所有端协议设计必须考虑老客户端。我的历史教训是字段只能增加、不能删除字段号只能占新号、不能复用旧的。核心设计思路解码实现遇到不认识的高位字段号直接跳过该字段的字节而不是报错终止。这一点必须贯彻。编码实现允许配置是否携带某个字段这样老客户端虽然不认识新字段但也不影响基本通信。对Message增加一个最低兼容版本号接收方发现对方版本太低但消息又包含新字段时可以选择降级处理例如退回旧格式响应。如果哪天必须废弃某个字段只能标记为deprecated并在文档里注明“禁止新数据使用”解码逻辑仍然要保留。注意字段号的分配要有一个统一的登记表大家约定好“1-50给消息元数据51-100给业务字段”否则多个团队并行开发时各占各号冲突是迟早的事。5. 序列化框架选型手写二进制协议还是直接用现成库5.1 主流二进制序列化框架横向对比除手写协议外现成框架能免去协议设计的诸多麻烦。我对几个主流方案的理解是这样的ProtobufGoogle出品成熟稳定支持多语言生成代码体积小解析速度快带着天然的字段号兼容机制。缺点是生成的代码可读性一般且需要维护.proto文件新手有入门成本。MessagePack理念是“像JSON一样自由比JSON更紧凑”。它保留了动态类型不需要预定义结构特别适合临时改字段的敏捷项目。缺点是体积和性能比Protobuf稍逊。FlatBuffersGoogle为游戏和移动端优化最大的卖点是不经过反序列化直接读字段零拷贝访问适合延迟极度敏感的场合。缺点是生成代码体量较大。Hessian老牌Java序列化协议跨语言支持较好但性能已经逐步被新生代替代。适合依赖老协议的遗留系统。ThriftFacebook出品的完整RPC框架包含了序列化和传输层适合需要完整RPC方案而不是单纯序列化库的项目。框架体积解析性能跨语言易用性适用场景Protobuf极小极快好中RPC、存储、强schema场景MessagePack较小快好高动态消息、快速迭代场景FlatBuffers小极快好低游戏帧同步、超低延迟场景Hessian中中中中老Java服务通信Thrift小快好中完整RPC框架需求我这边的实际原则是如果协议定义基本不会有大变动且追求极致性能用Protobuf如果业务刚起步、字段变来变去用MessagePack临时过渡如果核心链路已经到了游戏帧同步这种级别再考虑FlatBuffers。5.2 手写协议的适用范围和风险手写协议也并非一无是处。我选择手写的原因有三个报文结构足够简单只是固定几种消息类型根本用不上重量级框架。对报文体积有极致要求我可以通过精心排列字段顺序把长度字段省到最小。需要和旧系统对接对方只认私有格式只能手写。但手写协议也有三宗罪动手前必须有心理准备边界条件处理非常琐碎编码/解码分支多了很容易犯低级错误。没有schema生成器字段编号全靠自觉规范散落在文档里人员流动后文档就变成历史文物。测试成本高每增加一个语言都需要重写一遍编解码器。如果团队规模不大、迭代速度要求高新项目一律先从现成框架起步除非有硬性约束否则不要轻易自研。6. 反序列化漏洞与攻击防御6.1 为什么反序列化会成为攻击面反序列化过程是“接收不可信字节流并依据其中内容构建对象”这就天然引入了攻击面。攻击者精心构造一段字节序列如果目标语言的反序列化机制过于“智能”就会在解析字节流的过程中触发意想不到的代码路径。历史上非常典型的几个反序列化漏洞有Java原生的ObjectInputStream反序列化攻击者可以构造恶意序列化字节流触发任意代码执行链CommonsCollections这条链曾经轰动一时。fastjson的autoType机制攻击者可以通过在JSON里指定type字段来指定反序列化目标类型配合某些底层类成功实现RCE。PHP的unserialize()在校验不严的情况下也会触发魔术方法 __wakeup 或 __destruct构造不当就会命令执行。Ruby、Python的不同pickle实现都出现过类似的绕过限制案例。6.2 常见攻击手法简析反序列化漏洞的核心是把“数据”混入“元数据”或者把“类型信息”混入“数据结构”中。攻击手法通常分几步找目标反序列化入口比如用户上传的token、请求体、缓存数据、MQ消息消费。构造包含恶意类名的payload利用目标类的getter/setter或魔术方法做跳板。串联已有的工具类链达成写文件、执行命令、甚至直接反弹Shell。最典型的攻击链是攻击者指定反序列化类型为某个危害类该类构造器里又会调用另一个类的方法层层嵌套直到执行系统命令。6.3 防御措施清单我从实践里整理出来的防御要点按优先级排列明确反序列化输入源白名单不信任任何来自外部的原始数据。所有输入网络数据默认不可信。启用反序列化类型白名单机制只允许列表里声明的类被反序列化其他一律拒绝。尽量优先使用纯数据格式如Protobuf、MessagePack替代自带图灵完备机制的语言原生态序列化方案如Java原生、Python pickle。升级底层库到带漏洞修复的版本特别是fastjson这类高发区订阅安全公告是日常功课。二进制解析时严格控制长度值和深度值防止恶意长度字段导致的内存耗尽或栈溢出。提示二进制协议框架一般不会触发类型注入漏洞因为它是纯数据编码几乎没有可编程性。真正危险的是那些自带动态类型发现机制的方案例如Java原生序列化、fastjson默认配置。越是“方便”的机制越容易成为攻击入口。fastjson就是典型的反面教材网上被公开的CVE一个接一个很多版本存在默认autoType绕过问题。如果团队里还在用fastjson应当尽快收敛到Jackson或gson并做好安全配置。7. 常见问题排查与避坑速查7.1 跨语言后数值全错的排查思路跨语言通信最常见的现象是字段长度对了但数值完全对不上。比如发送的是1000接收方读到65536常有的问题这一类基本就是字节序不一致。排查方法很简单在帧头加一个固定的魔数比如0xCAFE接收方读出来如果字节是反的0xFECA那就可以确定双方字节序冲突。还有一种临时做法是设计一个0x00000001的整数debug字段读出来的值是0x01000000说明字节序翻转了。7.2 反序列化报EOF或缓冲区越界这类问题的根源通常是长度字段与实际数据长度不一致。常见原因有发送方统计长度时包含了自己没写入的填充字段。多字节编码的字符长度按字符数算而不是按字节数算中文在这个坑上特别容易出现。发送方在write后没有flush数据尚未完全落到网络流里。排查建议把报错前后的十六进制dump出来手动用长度字段切分数据定位差异。一旦确认是长度统计错误不要试图在接收方打补丁硬解回到发送方从源头修正。7.3 序列化后体积反而更大的情况不是任何时候二进制都比文本小。如果字段全部是长字符串而且字符串里重复内容很多二进制格式的长度前缀开销虽然小但字符串本身没压缩体积会和文本差不多。如果字段名很短如a、b优势就更有限。因此做二进制改造前先分析目标数据的组成特征再动手整数型多占明显体积优势字符串型多二进制格式只省了引号和冒号体积优势缩小长重复文本建议考虑在序列化层之上叠加压缩算法如zstd压完之后体积会有进一步下降7.4 跨语言中文乱码问题二进制协议出现中文乱码十有八九是编码方式不统一导致的。一个系统里同时混用了GBK和UTF-8就会出现这种情况。解决办法是在协议规范里明确约定“字符串一律使用UTF-8编码”并在解析代码里固定使用UTF-8而不能依赖系统默认编码。特别留意Windows环境下Java的File.encoding在中文系统上经常是GBK坑过很多人。数据校验也可以用来辅助解析编译期就把字符串合法性校验打开遇到非法UTF-8序列直接报错并记日志可以快速定位哪一端编码写错了。8. 序列化协议上线前后的最终自查清单8.1 整理一份我每次都会做的事前检查改动协议这种事上线前不把细节捋清楚线上出了状况极难排查。我的习惯是过一遍以下清单字节序协议文档是否明确写明大端还是小端是否所有实现都遵守长度字段各长度字段上限是否经过实际数据分布验证极端长度有没有测试过魔数与版本新增字段时是否遵循“追加”原则旧版本客户端能否正常解析新报文类型与字段映射字段号有没有去登记表里核对过避免重复占用安全性是否有专门的模糊测试用例覆盖异常长度、异常类型标记、乱序字节流跨语言测试向量新旧版本是否都跑通了同一批十六进制向量链路日志排查问题时能否从日志中快速拿到最近一条报文的十六进制dump我个人的体会是协议设计和功能开发最大的不同在于功能出bug只影响特定调用协议出错影响的是所有通信方而且双方经常是不同团队、不同语言定位问题的成本成倍增长。所以宁可多花时间把文档写清楚、把交叉测试做透也不要抱着“边写边debug”的心态。8.2 后续扩展的三个方向这套协议后续还能往几个方向演进加入字段级校验码或者整体CRC在弱网环境下尽早识别损坏包避免业务层解析出错。引入TLV的嵌套容器结构让复杂对象也能表达同时维持向后兼容。叠加压缩层使用Zstd或LZ4对有重复内容的整体报文做压缩预计还能进一步减小传输体积。从经验上看这些扩展点的改造难度都远低于“推翻重做”。协议设计的时候留好扩展位后续演进会顺利很多。篇幅所限这篇文章就到这。最后分享一句我从踩坑里总结出来的话序列化协议设计不是为了应付眼前的接口而是为了服务未来两三年里不断新增的设备、业务和团队。一个团队有没有认真设计协议、有没有认真对待边界情况观察它反序列化异常处理的代码风格就够了。写完这套自定义二进制协议之后我最大的收获不是性能和体积的优化而是对“数据如何变成字节、字节如何变回数据”这件事有了真正掌握的感觉。