拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

二进制序列化实战:从JSON瓶颈到自研编解码器

二进制序列化实战:从JSON瓶颈到自研编解码器 前几年团队把一个在线对战服务器的状态同步从 JSON 改成二进制序列化改动量不算大收益却立竿见影网关机高峰期 CPU 从 70% 多跌到 20% 左右带宽占用直接砍掉七成。后来做物联网设备上报几千台设备走按流量计费的蜂窝网络报文每省一个字节都是实打实的钱。所谓二进制序列化与反序列化说人话就是把内存里的结构体编译成紧凑的字节序列再把字节序列还原成结构体。跟 JSON、XML 这类文本格式相比它在数据体积、编解码速度和内存占用上往往有代差级别的优势。这篇记录不是教科书式的概念讲解而是我这些年在游戏同步、物联网上报、离线数仓里反复折腾序列化方案沉淀下来的经验会讲清楚底层原理、主流选型、手写编解码器的完整步骤以及反序列化安全这些容易被忽略的坑。适合正在被 JSON 性能瓶颈困扰、或者想真正理解序列化底层逻辑的工程师。1. 性能瓶颈出现时为什么我建议先看序列化层1.1 一个真实场景游戏状态同步从 JSON 换到二进制的收益先说我印象最深的一次优化。实时对战游戏里每个玩家每帧要上报坐标、朝向、血条、技能冷却等状态一次快照少说二三十个字段。用 JSON 表示一条快照往往要 300 到 600 字节因为每个字段名都跟着数据重复出现。比如{position_x: 12.345678, position_y: 98.765432, hp: 150, mp: 20}这种结构光键名就占了一半。按单服 500 人在线、每人每秒上报 10 帧来算网关每秒要处理 5 万次 JSON 解析。解析12.345678这种字符串转 double得逐位做乘法和加法比直接拷贝 8 个字节慢一个量级。换成 Protobuf 之后同样一条快照大概 20 到 50 字节解码端只需按字段号匹配后做位移和拷贝。实测下来网关 CPU 从 70% 多降到 20% 上下高峰期的消息丢弃也没有了。1.2 文本格式到底贵在哪里JSON 序列化工具这些年优化得很猛fastjson、Jackson、Gson 在性能上都已经做到很好了但文本格式本身有几项结构性开销是绕不过去的键名冗余。每个对象都带字段名嵌套越深浪费越明显。一条三层嵌套的数据键名占比经常超过一半gzip 能压缩却换不来低延迟解析。数字解析开销。十进制字符串和浮点数之间的相互转换涉及逐位乘除。数据量上来以后这个开销比二进制直接拷贝高一个量级。无法随机访问。要读某一个字段必须从头扫描改一个字段也要把整个对象重新序列化一遍。二进制固定偏移布局可以按字段定位这是质的不同。另外别忽略转义逻辑。用 fastjson 这类工具序列化包含换行、引号、反斜杠的字符串时需要额外扫描并插入转义符。日志系统里文本字段一多这个损耗非常明显。网上经常搜到fastjson 序列化不包括转义字符这类问题本质就是文本格式自带转义规则的麻烦。1.3 哪些场景必须上二进制高频实时协议游戏同步、音视频信令、RPC、消息队列。等 JSON 解析完实时的窗口已经过去了。物联网上报NB-IoT 这类网络按报文数和流量双重计费设备电池也在为每一帧数据耗电。少发几个字节长期成本差很多。大规模落盘科学计算常用的 HDF5 格式元数据存为属性、二进制数据存为数据集原因就是数值型数据按二进制连续存储查询和读取效率远高于文本行。缓存层Redis 的 value 如果直接存 JSON内存和网络成本直接翻倍。同一份数据改用 MessagePack 或自研二进制体积往往降一半以上。1.4 那 JSON 还有存在价值吗当然有。对外公开 API、配置文件、低并发管理面、需要人工调试的数据我依然推荐 JSON 或 XML。判断标准就是这句人是瓶颈的时候用文本机器是瓶颈的时候用二进制。没有一刀切的答案。很多项目 JSON 跑得好好的别为了炫技硬换二进制换来一堆兼容性麻烦反而得不偿失。2. 二进制格式的本质编码、边界与对齐2.1 整数编码固定宽度与变长编码整数是最基础的序列化单元常见两种方式。固定宽度uint32占 4 字节uint64占 8 字节。优点是读写简单、可以随机定位缺点是数值再小也固定占位。内存里的结构体天然是这个布局跨语言传输协议里也常见。变长编码varint这是 Protobuf 的核心技巧。每 7 个有效位占一个字节最高位表示后面还有字节。举个例子300 的二进制是100101100从低位开始每 7 位分组0101100和0000010加上续位标记后变成10101100 00000010也就是十六进制的0xAC 0x02。看到0xAC 0x02这种十六进制序列读端要能快速映射回二进制。编码器的实现思路其实就是做二进制除法每右移 7 位相当于整除 128余数作为当前字节输出。这个思路和我们手工做进制转换时不断除进制数取余的扩展做法是相通的只是基数从 2 换成了 128。习惯了之后你看到0xAC 0x02就能条件反射算出 300这种十六位二进制和十进制之间的互相转换能力排错时会救命。再说有符号整数。负数在计算机里按补码存储直接做 varint 会变成全 1 的连续字节压缩效果很差。Protobuf 用 ZigZag 编码把-1映射成11映射成2这样接近 0 的负数也能压得很小。2.2 字符串和字节数组怎么定界字符串必须告诉读端这段内容到底有多长。主流做法是长度前缀先写 2 字节或 4 字节长度再写 UTF-8 编码的内容读端先读长度再读内容。另一种是 C 风格的空字符结尾省去长度字段但内容不能含有\0读的时候还要逐字符找结束符很容易被构造数据搞出越界读取。我自己的协议里一律用长度前缀并且接收端必须校验长度字段 当前位置没有超过整个缓冲区这是最基本的边界检查。还要注意字符串编码。Java 的String.getBytes()不带字符集参数时会用系统默认编码换一次 JDK 或者换一台服务器就可能出乱码。所有跨语言协议都该显式指定 UTF-8。2.3 浮点数与时间戳浮点数直接按 IEEE 754 的字节布局拷贝float4 字节、double8 字节。解码端直接memcpy到对应类型不要先转字符串再 parse。协议设计时把字段类型写死float32和float64混用会丢精度这个在联调时经常引发诡异问题。时间戳我统一用int64存 UTC 毫秒或者秒绝对不用字符串格式。时区问题、格式化问题全都消失前后端各自在展示层处理本地时间即可。2.4 结构体对齐与零拷贝这里要特别提醒C/C 里直接memcpy原生结构体做序列化是个大坑。编译器会在字段中间插入 padding 对齐不同编译器、不同编译选项下的布局都可能不同。跨语言传输时千万不能依赖原生结构体内存布局必须自己明确定义 wire layout。零拷贝方案是另一条路。FlatBuffers、Capn Proto 这类框架通过精心设计的偏移表让数据在内存中的布局就是 wire format读端可以直接通过指针访问字段完全跳过解析阶段。适合配置表、资源包这类读多写少的场景。2.5 帧格式长度、版本、校验码流式传输时二进制协议必须先定义帧。我常用的帧结构是[长度字段 uint16] [协议体] [校验码]。长度字段让接收端知道从哪里切出完整一帧版本号放在帧头用于老设备和老客户端无法平滑升级时的兼容判断。校验码别省。LRC纵向冗余校验就是把整帧字节逐字节异或算法简单到单片机都能轻易实现适合串口和小包设备CRC 稍贵但检错更强。二进制的数据链路总会被电磁干扰、Bug、内存翻转搞出损坏多一层校验能挡住绝大多数静默错误成本极低。3. 主流二进制方案选型别只看跑分3.1 先按有没有 schema 分阵营选型之前先搞清楚一件事你需要 schema数据结构定义还是不需要。这决定整个开发流程。方案是否需要 schema零拷贝支持跨语言典型场景Protobuf是.proto否好RPC、游戏、服务间通信MessagePack否否好缓存 value、日志、临时存储FlatBuffers是.fbs是好配置表、资源包、读多写少Avro是JSON schema否好Kafka、HDFS、数据管道Kryo否可注册类否差Java 集群内部 RPC、SparkCBOR否否好IoT 标准场景RFC 8949Java 原生序列化否否差不建议对外使用3.2 逐个说下我的使用感受Protobuf最稳的通用选择。工具链成熟代码生成后没有反射开销跨语言生态最全。代价是引入 protoc 编译步骤proto 文件要纳入版本管理字段改动要走评审。MessagePack不需要 schema和 JSON 结构几乎一一对应Redis 缓存 value 我经常用它。但跨团队共享字段时全靠约定命名改了没人通知你就等着联调翻车。FlatBuffers学习成本高一点写入也比 Protobuf 繁琐但读性能极强。做客户端配置热更新的时候非常香。Avro核心特点是 schema 会跟着数据走文件头里自带 schema所以大数据生态几乎默认它。但动态编码风格在低延迟 RPC 上不如 Protobuf。KryoJava 序列化领域性能王者体积小速度快。但注册类机制要求两端提前注册跨语言基本别想版本兼容性也脆。CBORRFC 8949 标准适合 IoT 场景但各语言库水平参差要自己踩坑验证。3.3 我给团队用的快速选型公式跨语言服务间通信无脑选 Protobuf已有 Thrift 体系的可以继续用 Thrift。Java 内部高吞吐 RPC选 Kryo。Redis 缓存 value、快速接 JSON 流量但没有强烈 schema 维护意愿选 MessagePack。只读配置、资源文件、客户端热更新选 FlatBuffers。数据要进 Kafka 或者离线数仓选 Avro。3.4 跑分怎么读网上有大量序列化 benchmark但你要分清楚测的到底是序列化耗时、反序列化耗时、内存分配、GC 压力还是最终包体大小。不同格式在不同阶段优势不一样。我的经验是拿真实数据样本压测样本要覆盖小包高频、大包低频、深层嵌套、字符串占比高这些不同形态。别拿一个三五行数据的 case 就下结论那样选型必翻车。4. 从零撸一个自研二进制编解码器4.1 协议定义与设计原则为了把原理讲透我在这里定义一个极简协议三个字段uint32 id、string name、repeated uint32 skill_ids采用 tag 方式每个字段一个 tagtag (字段号 3) | wire_type字段号从 1 开始不要用 0两种 wire type0表示 varint2表示 length-delimited长度前缀的数据设计原则tag 让新老版本可以互相跳过未知字段。老版本读到新版本才加的字段只需要按 wire type 把数据长度跳过不影响后续字段。这是 schema 演进的基础字段号定了就永远不能改。4.2 编码端实现我用 Python 做演示逻辑最直观生产上换成 Go、Java、C 都是一样的思路。def enc_varint(value): value 0xFFFFFFFF # 按 32 位无符号处理 out bytearray() while value 0x7F: out.append((value 0x7F) | 0x80) value 7 # 二进制除法整除 128 取商 out.append(value) return bytes(out) def enc_field_varint(field_no, value): tag (field_no 3) | 0 # wire type 0 return enc_varint(tag) enc_varint(value) def enc_field_bytes(field_no, data): tag (field_no 3) | 2 # wire type 2 return enc_varint(tag) enc_varint(len(data)) data def encode_user(user): buf bytearray() buf enc_field_varint(1, user[id]) name user[name].encode(utf-8) buf enc_field_bytes(2, name) for sid in user[skill_ids]: buf enc_field_varint(3, sid) return bytes(buf)拿{id: 300, name: aj, skill_ids: [7, 99]}来跑一下输出字节是08 ac 02 12 02 61 6a 18 07 18 63逐个拆开看08字段号 1 左移 3 位是0x08wire type 0。ac 02varint 编码的 300前面已经算过。12字段号 2 左移 3 位得到0x10加上 wire type 2 得到0x12。02name 的长度是 2。61 6aa和j的 UTF-8 字节。18 07字段号 3varint 7。18 63字段号 3varint 990x63 正好是 99。看到没有整个编码过程就是移位、按位或、右移取商没有字符串解析没有键名重复这就是二进制快的根本原因。4.3 解码端实现与边界处理解码端要做的检查比编码端多得多。编码端数据是自己产生的可以信任解码端面对的是网络字节流永远默认不可信。class Reader: def __init__(self, buf): self.buf buf self.pos 0 def read_varint(self): result 0 shift 0 count 0 while True: if self.pos len(self.buf): raise ValueError(varint 越界) b self.buf[self.pos] self.pos 1 result | (b 0x7F) shift count 1 if count 5: # 32 位整数最多 5 个字节 raise ValueError(varint 长度超限) if not (b 0x80): break shift 7 return result def read_bytes(self): n self.read_varint() if self.pos n len(self.buf): raise ValueError(长度字段越界) data self.buf[self.pos:self.pos n] self.pos n return data def decode_user(buf): r Reader(buf) user {id: 0, name: , skill_ids: []} while r.pos len(buf): tag r.read_varint() field_no tag 3 wire_type tag 0x07 if field_no 1 and wire_type 0: user[id] r.read_varint() elif field_no 2 and wire_type 2: raw r.read_bytes() user[name] raw.decode(utf-8, errorsstrict) elif field_no 3 and wire_type 0: user[skill_ids].append(r.read_varint()) elif wire_type 0: r.read_varint() # 跳过未知的 varint 字段 elif wire_type 2: r.read_bytes() # 跳过未知的 bytes 字段 else: raise ValueError(f不支持的 wire_type: {wire_type}) return user四个检查必须做死varint 循环次数不能超过 532 位或 1064 位否则恶意数据会让解析陷入死循环长度字段和当前位置相加不能溢出缓冲区未知 wire type 直接拒绝UTF-8 解码报错直接抛异常绝不静默替换。4.4 用 hex 断言写单元测试自研二进制协议的单元测试最可靠的方式是写死期望的十六进制def test_encode_user(): user {id: 300, name: aj, skill_ids: [7, 99]} assert encode_user(user).hex() 08ac021202616a18071863 def test_decode_user(): raw bytes.fromhex(08ac021202616a18071863) assert decode_user(raw) {id: 300, name: aj, skill_ids: [7, 99]} def test_decode_malformed(): try: decode_user(b\x08\xac\x02\x12\xff\xff) # 长度超出剩余缓冲 except ValueError: pass这种期望 hex 写死的测试能立刻暴露字段顺序、varint 算法、大小端、长度计算任何一处问题比只看解码结果可靠得多。每次改动协议先跑一遍全量测试出问题马上看 hex diff。4.5 顺手把帧头和校验加上单包传输可以把序列化结果直接当消息体流式传输必须定义帧。我常用这个import struct def lrc(data): s 0 for b in data: s ^ b return s def pack_frame(payload): if len(payload) 0xFFFF: raise ValueError(帧太长) header struct.pack(H, len(payload)) return header payload bytes([lrc(payload)]) def unpack_frame(frame): if len(frame) 3: raise ValueError(帧太短) length struct.unpack(H, frame[:2])[0] if len(frame) ! length 3: raise ValueError(帧长不符) payload frame[2:2 length] if lrc(payload) ! frame[-1]: raise ValueError(LRC 校验失败) return payloadLRC 就是逐字节异或算法简单到任何平台都能实现。注意校验要在任何转义、加密之前计算接收端先验校验再解密。5. 反序列化攻击为什么二进制格式不能让人松口气5.1 问题本质不在格式而在还原时执行了额外动作网上搜反序列化攻击搜出来的基本都是 PHP、Java、fastjson 的漏洞这个方向我必须重点讲因为它特别容易被忽视。先看几种经典形态PHP 的unserialize在还原对象时会触发__wakeup()、__destruct()等魔法方法。攻击者构造一个恶意对象属性链就能形成 POP 链执行任意代码。知名的 Pikachu 漏洞靶场里就有专门的 PHP 反序列化漏洞练习模块。Java 的ObjectInputStream.readObject()会解析字节流里的类描述并实例化对象。配合 CommonsCollections 这类 gadget 库一条恶意流就能远程执行命令。fastjson 的 autoType 特性允许在 JSON 字符串里指定类名框架自动加载并实例化。这个设计长期是漏洞重灾区社区已经出现过多个绕过补丁的案例。Python 的pickle更直接它的设计文档里就写明不要反序列化不可信数据因为 pickle 可以编码任意执行步骤。5.2 二进制化解决不了信任问题有个很大的误区以为把 JSON 换成二进制格式就安全了。分两种情况看。protobuf 这类基于 schema 且只承载数据的格式没有类加载机制也没有魔法方法回调攻击面确实小很多相对安全。但如果用的是 Java 原生序列化、PHPserialize、Pythonpickle这类对象序列化方案不管外面套多少层 base64、加密、二进制压缩核心风险一点都没变。原因很简单对象序列化在还原数据的同时还会自动构造对象、触发回调、加载类。攻击者控制的是整个对象生成过程换包装格式只是表面工作。所以我在项目里第一原则就是凡是可能接触不可信输入的接口一律禁止使用原生对象序列化格式。5.3 我实际部署的防御措施严格限制格式对外公开接口优先 protobuf、MessagePack、CBOR 这类纯数据格式Java 原生 Serializable、PHP serialize、pickle 一个都不许出现在公网上。遗留系统必须用对象序列化时加类白名单。Java 用ObjectInputFilter或类校验器限定允许实例化的类fastjson 直接启用 safeMode 并关闭 autoTypePHP 在unserialize时传入allowed_classes参数只放行自己项目里的实体类。加完整性校验服务端用 HMAC 对序列化内容签名客户端和服务器两侧都验签。这样能挡住攻击者自行构造的恶意流但注意签名不能替代白名单因为签名只保证来源可信不代表内容安全。反序列化操作放进隔离环境独立低权限账号、容器沙箱、内存和包大小配额。就算被突破拿到手的权限也有限。依赖更新比想象的重要。fastjson 这类组件我建议直接换掉不要一直追补丁。多次绕过案例已经证明同一套设计缺陷很难通过补丁彻底修复。5.4 一张自查表检查点风险等级处理方式公网接口是否允许 Java/PHP/Python 原生反序列化极高立即换成数据格式或加白名单fastjson 是否开启 autoType极高关闭 autoType启用 safeMode反序列化入口是否有类白名单/黑名单中到高补上按最小化原则是否有长度和嵌套深度上限中加上防止 DoS数据源是否可信有没有签名中加 HMAC 验签反序列化是否在隔离环境运行低到中尽量沙箱化6. 实战踩坑与调优把二进制协议用到稳定6.1 大小端不一致的惨案有一年我们做跨平台联调C 客户端跑在 x86 上服务端是 ARM 架构的小机器。客户端按小端写了一个uint32服务端直接memcpy读出来原本的 1000 变成了几亿的乱数。排查了半个多小时最后用 hexdump 对比字节流才反应过来x86 上1000的字节是e8 03 00 00ARM 服务端按自己的大端解释成了0xe8030000也就是 38 亿多。从此以后我的协议文档第一行就写死所有整数显式按小端序禁止直接依赖主机序。代码里统一用同一套封装的读写函数Python 就用struct.pack显式带前缀。这个教训值不少线上时间。6.2 schema 演进字段号就是你未来的接口兼容性二进制协议上线之后最怕结构变化。三条铁律只能增字段不能删字段。删掉的字段号要标记为 reserved留给未来的类型兜底防止被新业务误复用。永远不要改已有字段的类型。比如int32改int64老客户端解析新数据会丢掉高 32 位新客户端解析老数据会得到一段垃圾高位。这类问题排查起来非常隐蔽。字段号是稀缺资源规划协议的时候预留一个扩展区间。解析端的 tag 跳过逻辑前面 decode_user 里的 else 分支就是为兼容性设计的。遇到未知字段按 wire type 跳过长度后面的字段照常解析。6.3 字符串、浮点和时间的隐蔽坑Java 的String.getBytes()不带字符集参数时会用系统默认编码同一份代码换个 JDK 或者服务器就可能出乱码一律显式UTF-8。Cstd::string可以存任意二进制但协议里既然定义成 UTF-8 文本就不要混入\0否则一堆库函数直接截断。float32和float64混用时发送端先转成 double 再发接收端想拿回 float32 就丢了精度。协议文档里把每个浮点字段的精度写死。时间戳只存int64UTC 毫秒展示层再做时区转换。字符串时间格式一旦跨语言时区、格式、夏令时全成了问题源。6.4 性能调优从这四件事开始缓存复用。编解码器的输入输出 buffer 都做池化别每次 new 大数组GC 压力立刻下来。避免反射。手写编解码永远快过反射。用现成库时优先开启代码生成模式而不是动态反射模式。减少拷贝。能直接读字节就别先转byte[]再转StringC/C 端可以 mmap 映射只读文件配合固定 offset 布局实现零拷贝访问。压测带真实流量。小包高频和大包低频的优化方向完全不同前者拼调用开销后者拼批量拷贝和内存分配。6.5 调试二进制流的几个土办法我维护一个 hexdump 调试函数任何环节都能把字节打印出来。单元测试里把期望 hex 写死回归一跑就知道有没有人改动破坏了格式。抓包用 Wireshark可以给自家协议写一个简单的 dissector临时排查就先用tcpdump -X的 hex 输出凑合看。怀疑越界或乱码的时候先看长度字段。二进制协议九成问题出在长度被篡改或者类型转换后长度算错上。最后分享一个小经验协议里预留一个 traceId 字段线上排查时同一帧里能带回全链路信息。二进制虽然没有 JSON 那么直观但配合日志和 hex 转储定位问题一点不比 JSON 慢甚至因为结构固定比解析一堆嵌套文本更快。
返回列表