资讯动态

二进制序列化凭什么比 JSON 快?一文讲透原理与选型

发布时间:2026/10/20 15:53:52 来源:尧图企业网站定制
如果只给刚接触后端开发的新人提一条建议我会说别把 JSON 当万能协议。二进制序列化在体积、解析速度、内存占用上都明显优于文本序列化代价是失去了直接可读性。我上周刚处理了一个线上推送服务的 CPU 毛刺追到最后发现元凶就是一段看着人畜无害的 JSON 解析代码。这个场景特别典型服务之间内部通信数据量不大但 QPS 高得离谱每个请求都去strconv或json.Unmarshal里滚一圈CPU 很快就烧没了。这个题目不是“二进制一定比文本好”这样简单的二极管结论。实际工程里你需要知道它好在哪、怎么量、怎么权衡以及什么时候别硬上。我下面会用一次真实事故作为引子把二进制序列化拆开揉碎讲清楚尽量把原理、选型、坑和工程判断都覆盖到。1. 一次线上事故文本协议成了性能瓶颈1.1 事故的现象和初步定位起因是我维护的一个内部推送网关负责把一批业务事件转成消息投递给下游消费者。这个网关上个月做了一次架构调整把一些原本由 SDK 内部直连的请求改成了统一经过网关转发。调整后大概一个星期监控面板上的 CPU 使用率开始缓慢爬升从一开始的 30%一路干到 85%。但业务量并没有明显增长QPS 基本稳定在 8 万左右消息体也很小平均一个事件才百来个字节。最初的直觉是网络连接泄漏或者 GC 有问题。我抓了线程 dump结果发现大量的堆栈都停在一个地方json.Unmarshal。追踪后定位到网关接入层为了兼容旧客户端保留了过时的 JSON 格式协议下游服务消费消息之前必须先做一层 JSON 解码把字符串变成结构体然后重新序列化成内部格式再落库。等于同样的数据在链路里被 JSON 编码和解码了两次。这里就是文本序列化的核心问题第一次暴露出来的地方解析 CPU 开销跟数据量的关系不是线性的而跟字符个数、嵌套层级、字符串分配次数强相关。JSON 一旦嵌套变深、字段变多状态机要走的分支数量会明显上涨每一次 token 解析都可能触发一次内存分配把年轻代堆塞满GC 也跟着遭殃。1.2 压测复现CPU 都烧进了解析器我先把网关流量切一部分到影子环境用同样的请求报文做压测。单机 QPS 压到 2 万时CPU 已经顶到 60%继续压到 5 万响应延迟从 3ms 飙到了 25ms。抓火焰图发现json.Decoder相关函数占了总 CPU 时间将近 43%紧随其后的是reflect相关调用又占了 17%。这个现象说明两个问题。第一文本解析非常吃 CPU因为要逐字节扫描、判断引号、冒号、花括号、转义符第二动态语言里常用的“反序列化成 map”或“反射映射到 struct”又叠加了昂贵的反射开销。而如果用二进制序列化接收端拿到字节流后直接按固定偏移量读各个字段省掉了字符扫描和反射匹配整体开销往往能降一个数量级。我把网关里最关键的那个事件类型改成二进制序列化后单机压测 CPU 从 60% 降到了 18%P99 延迟从 25ms 降到 6ms。改动量其实不大就是把协议定义从 JSON 手写结构体改成 schema 定义然后把收发两侧的序列化调用换掉。这是最直观的一课文本协议是生产力友好但性价比在核心链路上是负的。2. 二进制和文本序列化到底差在哪里2.1 体积的账存储、带宽、内存三级放大我们从一个最简单的例子看起。假设要传输一个用户信息对象包含用户 ID、昵称、关注数、在线状态四个字段。用 JSON 表示大概是{uid:123456,name:张三,followers:987,online:true}这段文本 63 个字节。换成二进制用固定长度整数 UTF-8 字符串 布尔位设计一个紧凑布局uid8 字节int64nickname先写 2 字节长度再写 UTF-8 字节假设昵称 6 字节共 8 字节followers4 字节int32online1 字节合计约 21 字节。好家伙直接小到三分之一。这还只是单条消息。消息在网络上传输时带宽是真金白银到了对端解析出的对象占用的内存同样和源数据相关。文本格式里每个数字都是一个字符数组需要转换成 int64 或 float64 存储字符串要额外分配。二进制格式可以直接读成原生类型省掉一次转换。存储、带宽、内存三层同时受益这是文本协议做不到的。2.2 解析过程的账从字符扫描到内存布局文本解析器和二进制解析器的最大区别在于“要不要猜”。JSON 解析器需要看到一个 token 才知道是什么类型遇到123还要先判断是整数还是浮点、有没有负号、是否超出范围然后再调用strconv.ParseInt之类的函数完成转换。字符串更是要先扫描一遍寻找转义符\n\\\如果转义频繁还需要二次拷贝。二进制序列化则不一样。它的格式定义里写死了字段类型解析器拿到字节后可以直接按类型去读。比如一个 int32读 4 个字节按字节序拼起来就行一个长度前置的字符串先读长度偏移量再从那个位置直接取字符序列。整个过程没有状态机没有模式判断没有中间缓冲。现代 CPU 对这类“线性读取 少量位移”的操作极度友好分支预测成功率高缓存命中率也高。我用一个很粗糙的类比文本解析像你从一堆文字里找一个数字先得经过眼睛扫描和大脑识别二进制解析像直接打开 Excel 表格光标到自己那一格读内容。哪怕文本解析已经做到很高效的库底层该有的字符遍历一步也躲不掉。2.3 类型映射的账弱类型 vs 强类型另一个容易被忽略的点是类型信息。JSON 本身只有字符串、数字、布尔、数组、对象没有 int32、int64、float32、float64 这些细分。为了不丢精度很多 JSON 库解析数字时默认用 float64 或json.Number到了业务代码里再做类型断言一来一去又增加判断成本。二进制序列化通常依托 schema 或者明确的编码约定比如“第 1 个字段是 int32”“第 2 个字段是 uint64”解码端拿到字节就知道应该放到哪个类型的变量里。强类型不仅压测性能好还让错误在编译期就被发现而不是等运行时解析的时候抛异常。文本序列化里最常见的“线上数字字段突然变成字符串导致解析失败”在二进制方案里基本不存在因为类型是协议的一部分。3. 三种主流二进制序列化方案的选型实测3.1 强制 schema 的正确打开方式提到二进制序列化绕不开的就是带 schema 的 IDL 方案。这类方案先定义一套数据描述文件然后生成对应语言的类或结构体序列化和反序列化 API 都跟着生成代码走。好处是跨语言、类型安全、体积小坏处是进度上多一步代码生成每次改字段要重新生成和发布。我一般建议面向服务间通信、长期稳定的数据模型用这类方案。它的字段编号机制非常关键千万不要随便改字段号。设计 schema 时预留几个编号给未来扩展字段类型只增不改废弃字段保留编号但不使用这样老客户端和新客户端之间才可能平滑兼容。生成代码看起来有点重但是工程上的收益是实打实的你不再可能写出“字段名拼错了但没人发现”的解析逻辑。序列化后的数据有明确的 wire format任何语言实现都遵循同一种字节布局跨语言调用就不容易踩坑。3.2 零拷贝方案的适用边界第二类方案主打“零拷贝”。这种方案的思路是把序列化后的数据直接放在一块连续内存里反序列化时不创建对应对象而是通过偏移量访问字段。用的时候拿到的是缓冲区上的一个视图想读哪个字段就按偏移量去读避免了一次完整的对象解码过程。在只读场景或者高频只读访问的场景下性能优势非常明显。例如一个配置中心客户端每分钟拉取全量配置配置内容又很大用零拷贝方案就能做到“拿到字节流直接读几乎不产生临时对象”。但代价是 API 操作别扭嵌套对象和可变字段处理起来很绕而且这种方案通常不适合做跨语言复杂对象转换因为暴露了太多底层布局细节。如果在你的场景里数据以读为主、写很少而且对 GC 敏感可以优先考虑。3.3 从 JSON 平滑迁移的轻量方案第三类是兼容 JSON 字段概念的二进制格式。它的理念是“把 JSON 的 key-value 模式改成二进制 key-value”没有 schema也没有代码生成。每个字段依然有名字标识但是以二进制字节形式存在而不是用引号字符串包裹。这类方案最适合“手上已经有一堆 JSON 接口想快速降低体积和延迟但不想引入完整 IDL 流程”的团队。迁移成本低旧 JSON 数据也能通过编码对应关系转换成二进制格式。不过它比带 schema 的方案要慢也大因为它仍然要存储字段标识和长度信息。拿它跟 JSON 比收益明显拿它跟 IDL 方案比就差一截。3.4 一组模拟压测数据为了说明差异我用 10 万条用户记录做了个压测对比数据包含数值、短字符串、布尔和嵌套对象。跑出的结果大致如下方案序列化后体积单线程序列化反序列化耗时JSON23.6 MB980 ms二进制 key-value15.1 MB640 ms带 schema 方案9.8 MB210 ms零拷贝方案8.5 MB95 ms再次强调这是模拟数据真实场景会因为字段类型、重复率、访问模式不同而变化。但量级差异是明确的同样一份数据二进制方案体积至少小三分之一耗时反而更快。这还没算 GC 压力文本方案通常会产出更多的临时对象触发 GC 更频繁。4. 从 JSON 迁到二进制序列化工程改造的关键决策4.1 选准切面只改热点路径迁二进制最忌讳“一刀切”。我见过有团队把整个项目所有序列化点全部改成二进制结果花了两个月还弄出一堆可读性灾难。正确做法是先找出真正值得优化的链路流量最大、消息体积最大、延迟敏感、GC 压力大的地方通常就是那 20% 的接口。比如网关的推送事件、队列的 task 消息、实时推荐的特征数据这些链路都是二进制的天然受益区。像日志写文件、对外 API 响应、配置管理后台展示这些场景对可读性的要求远高于那几个百分点的性能收益就别动。流程上优先做“协议内部切换、入口出口兼容”。内部服务之间直接用二进制协议对外仍然保留 JSON 适配层。这样上线时不影响外部调用方后续再逐步收紧。4.2 兼容性设计版本号、字段增删、默认值一旦上了二进制协议字段演进就再也没法靠“肉眼识别”来兜底了。这里有几个实战原则必须遵守每个消息都要有一个版本号字段或者至少依赖 schema 文件本身带版本管理避免新旧实例乱传。给结构体预留字段编号区间新增字段只增加编号不修改已有字段类型。写入方和读取方都要处理“未知字段”的情况新字段不认没关系但绝不能因此解析失败。数值字段要明确定义缺失时的默认值。JSON 里字段不出现就是 undeified很多逻辑只是判断“if not null”二进制里可能根本没有这个字段读取时必须返回一个合理默认值。上面这些点通过 schema 定义 生成代码的方法一般都内置了但你如果自己写二进制协议就必须手动实现。最容易踩的坑是把两个字段的偏移量写反编译期发现不了运行期数据错乱。4.3 可观测性降级十六进制日志和采样开关这是所有从文本迁到二进制项目最容易被低估的部分。JSON 在日志里直接能看到出错一行curl就能复现。二进制是乱码抓包还要专门做十六进制解析。我建议在接入初期保留一个“调试模式开关”。线上默认二进制但当某个调用方想排查具体消息内容时可以通过配置中心下发布尔开关让网关在日志里输出字节流的 hex dump 或者 base64。为了不拖垮性能采样率可以调成 1% 甚至更低。另外一定要写一个配套的命令行小工具能够从一个二进制文件里解析出可读的 JSON 或表格展示这比临时写脚本高效得多。我经历过的最痛苦的一次排障就是生产环境两条消息看起来字节数一模一样但业务结果不同。后来发现是字节序混乱两个服务一个读小端一个读大端。如果没有解析工具我可能要对着一堆 hex 反推内部结构。5. 二进制序列化不是银弹常见陷阱5.1 可读性归零后排障方式得跟着变文本协议最大的隐性价值是可读性这是所有性能指标都换不回来的东西。一个接口出问题你打开日志文件扫一眼就大概知道是哪条数据不对劲。二进制协议出问题没有工具辅助就只能靠猜。所以上二进制必须同时配套对应的可观测性建设否则等于蒙着眼睛开车。我现在的做法是在消息结构里尽量保留一个request_id和event_type这样的文本型字段并且要求日志打印时把它们提取出来单独打 plain text。这样既不影响二进制传输体积又能让日常排障快速定位链路。纯二进制日志在多数情况下应该避免要么转十六进制要么在入口转换成可读摘要日志。5.2 跨语言和跨平台的字节序陷阱最常见的坑就是大端小端问题。JSON 字符串没有字节序二进制整数有。协议约定用 little-endian 就全局统一用约定 big-endian 也全部统一。很多新入行的开发在一个用 C 写的底层库和一个用 Go 写的业务服务之间传二进制数据C 那侧直接 memcpy 一个int32_tGo 这侧也直接binary.LittleEndian结果一个平台是 x86 一个平台是 ARMmacOS 和 Linux 默认字节序都是小端大概率没事但如果中间经过某个用 Java 实现的组件Java 默认是大端思路一不小心就翻车。我的建议是只要跨语言就不要依赖平台原生字节序统一用明确函数把整数拆成字节数组收包时反拆。零拷贝方案内部虽然做得很彻底但你一旦拿到底层 buffer 自己解析数字字节序问题就会冒出来。浮点数的二进制表示也要注意。IEEE 754 标准基本统一了但 NaN、Infinity、正负零在不同语言里序列化出来可能不一样。协议设计时要约定清楚哪些浮点值属于合法值反序列化时最好做一层合法性检查否则一个 NaN 传过去业务逻辑比较大小会出怪问题。5.3 反序列化的安全检查不能省很多人觉得二进制序列化速度快就把所有校验都省了。这是严重误区。二进制方案的消息长度字段是最常见的攻击面解码方按长度字段申请内存恶意客户端传一个 0x7FFFFFFF收到这种包直接崩在内存分配上。我处理过一个事故就是因为没限长客户端发了一个声称 4GB 长度的消息体导致服务端 OOM。应对办法很简单读取消息体长度前先做上限校验超过阈值直接丢弃或告警。嵌套对象的深度要限制防止递归反序列化把栈打爆。未知字段不要慌跳过去但如果未知字段数量异常多也要怀疑是恶意构造。另一个容易被忽略的是字符串编码。文本序列化天然带 UTF-8 校验二进制没有你直接按长度区块拷贝字符串可能把非法 UTF-8 数据也塞进对象。业务层如果使用字符串函数去处理就会出现诡异的 panic。入库前建议做一次编码合法性校验成本很低。6. 我的工程建议什么场景能上什么场景别碰6.1 决策清单干了这么多年我总结了一套行之有效的判断标准不一定适合每个团队但拿来做个参考完全没问题维度建议使用二进制建议保持文本流量每秒请求量超过 1 万每秒请求量只有几十数据形态结构化强、字段稳定字段多变、灵活扩展调用关系服务间内部调用面向第三方公开 API排障要求有完整日志和解析工具希望直接肉眼debug团队能力有 IDL 经验和代码生成能力团队规模小追求简单兼容性要求长期演进、跨版本临时一次性脚本整体判断原则就一条性能热点上二进制序列化的收益大到可以抵消可读性成本非热点上为了这点收益去牺牲可读性不值当。6.2 一点掏心窝的经验我踩过的最大的坑是“过早优化”。当时一个内部管理系统每天调用量不到几千次我也硬上了二进制协议结果每到排查问题就得写一堆解析脚本后悔得不行。后来学乖了先在监控面板上找出真正的热点接口用火焰图和 GC 日志确认瓶颈确实在序列化层再做迁移。真正的王道不是“选二进制还是文本”而是“让我能看清楚数据在做什么”。如果文本已经足够看清就别挪如果挪了能让系统多撑一倍流量、少一次扩容那就义无反顾地挪但一定要把配套工具和可观测性一起带上。毕竟二进制序列化省下来的每一毫秒最后都要用开发者的时间去换排障的快捷这笔账要算大别算小。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑