相信不少后端同学都有过这种经历:线上接口突然报错,日志里一串莫名其妙的字节流,定位半天发现是两套服务对同一份数据的解析方式不一致,而问题根源就是序列化格式没统一。我刚入职那几年也踩过这种坑,后来把二进制序列化与反序列化这套东西从原理到实战完整啃了一遍,才算真正把这块短板补上。
这篇文章不打算讲教科书式的定义,我会从实际工作场景出发,聊聊二进制序列化到底是什么、协议怎么设计、常用库怎么选、反序列化为什么总出安全漏洞,以及我这些年踩过的坑和排查经验。适合后端开发、协议设计、中间件维护的同学参考,也适合刚接触序列化概念的新手建立整体认知。
1. 先搞明白:二进制序列化到底解决什么问题
1.1 “二进制”在技术社区里其实有好几层意思
在技术讨论里,“二进制”这个词经常混用,我至少见过三种含义。第一种是数学意义上的二进制数制,比如十进制转二进制、位运算、二进制魔术卡猜数字那些游戏,本质是0和1的编码游戏。第二种是编译产物,比如大家搜“docker二进制安装”“nginx二进制安装”,指的是直接分发可执行文件,不用源码编译。第三种才是本文的主角,指数据在内存或网络中的字节序列形态,也就是序列化之后的那串byte。
这三种含义容易混淆,但核心思想相通:都是把信息用一种机器更易处理的方式表达。序列化干的事情,就是把内存里的对象、结构体、字典这类高层数据结构,拍扁成一串字节流;反序列化就是逆过程,把字节流重新还原成对象。这个“拍扁”和“还原”的过程,如果格式设计得不好,轻则性能差,重则直接崩,甚至被攻击者利用。
1.2 序列化家族:文本格式与二进制格式的对比
常见的序列化方案,粗略可以分为文本类和二进制类。文本类的代表是JSON、XML、YAML,优点是人眼可读、调试方便、跨语言支持好。但缺点也很明显:冗余字符多,数字精度容易丢,解析性能一般。
二进制类的代表就多了,像Java原生的Serializable、PHP的serialize、Python的pickle、Google的Protocol Buffers(protobuf)、MessagePack、FlatBuffers、Kryo、Hessian等等。这类格式的共性是:体积小、解析快、能保留更多类型信息,但人眼不可读,调试时必须借助工具,而且格式升级不谨慎就容易不兼容。
我自己常用的判断标准很简单:
| 对比维度 | 文本格式(JSON) | 二进制格式(protobuf/自研) |
|---|---|---|
| 可读性 | 强,肉眼直接看 | 弱,需要工具解析 |
| 体积 | 大,冗余括号和字段名 | 小,通常只有纯数据 |
| 解析速度 | 相对慢 | 快,尤其大数据量时明显 |
| 类型精度 | 弱,大整数和浮点容易丢精度 | 强,按类型编码 |
| 调试排障 | 方便,curl一把梭 | 需要协议解析器或日志转储 |
| 跨语言兼容 | 好 | 取决于格式设计 |
| 安全风险 | 相对较低 | 反序列化漏洞风险高,需严格校验 |
如果你的服务是纯内部调用,对性能和流量敏感,或者要传长整型、浮点型、二进制大对象,那二进制序列化几乎是必选项。如果只是给前端提供接口、写配置文件,那JSON依然是最省心的选择。
1.3 什么场景下必须认真对待二进制序列化
我接触过的场景里,以下四类对序列化方案的选择非常敏感。
第一类是RPC框架的内部协议,像Dubbo、gRPC、Thrift这类,所有请求响应都要经过序列化,选型直接决定吞吐量和时延。第二类是消息队列的消息体设计,尤其Kafka这类追求高吞吐的中间件,序列化格式的压缩率直接影响磁盘占用和网络开销。第三类是存储层的value编码,比如Redis里存对象,直接set一个JSON字符串还是用二进制序列化后的字节数组,内存和性能差别很大。第四类是日志与可观测性数据采集,像Loki、Grafana这类时序处理链路,采集端的数据编码格式很大程度上决定了整个链路的资源消耗。
这些场景你去搜相关热词,会发现大量真实案例。比如“redis序列化”经常有人问为什么存进去的对象取出来变了一堆乱码,多半是用了JDK原生序列化或者没有配置统一的序列化器;“docker二进制安装”是另一种意义上的“二进制交付”,虽然和序列化无关,但背后的思想是一致的——用更紧凑、更高效的方式传递一个完整的东西。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二进制协议设计的关键细节:字节序、变长编码与对齐
2.1 字节序:大端、小端,一个Byte就能让你翻车
字节序是设计二进制协议时第一个要面对的问题。大端(Big-Endian)把高位字节放前面,符合人类阅读习惯,网络协议里常用;小端(Little-Endian)把低位字节放前面,x86和大多数ARM处理器默认就是小端。
这里有个经典翻车案例。之前我们有个服务用Java写,另一个用Go写,两边约定了一个自定义二进制协议,文档里画了字段布局,但没明确说字节序。Java的ByteBuffer默认是大端,Go的encoding/binary默认是小端。联调时发现,明明发的是整数1,对方解析出来成了16777216,盯着报文看了半天才反应过来是字节序不一致。
解决方式很直接:协议文档里务必显式声明字节序,最好在协议魔数后面加一个字节序标志位,或者直接固定全部使用大端或小端。跨语言场景我习惯统一用大端,因为网络字节序就是大端,很多现成库默认支持,能少踩一半坑。如果是纯本机或者同构集群内部通信,跟随平台默认的小端也问题不大,但一定要在代码层面写清楚。
2.2 变长整数与位压缩:把体积再压一个量级
固定宽度整型写起来方便,比如int32永远占4字节,int64永远占8字节。但实际业务里,大部分数字都很小,比如状态码、枚举值、数量,用固定8字节存一个数字1,纯粹是浪费。
这时一般会引入变长整数(Varint)编码,也就是protobuf和很多二进制协议用的方案。思路很简单:每个字
