资讯详情

资讯详情

Protobuf 实战:为何比 JSON 更高效?原理、场景与选型建议

做后端这几年我一直在反复观测同一类问题接口联调、日志落盘、消息入队、客户端同步不同系统之间交换数据到底用什么格式最顺手。早期我默认 JSON后来发现只要流量一大、字段一多文本格式的浪费就开始变得刺眼。于是我去翻了 Protobuf把它用于几个真实的线上模块才发现它为什么被那么多团队当成“默认答案”。这篇就聊一聊Protobuf 到底强在哪、什么时候该用它、什么时候别硬上。Protobuf 全称是 Protocol Buffers由某大型互联网公司内部设计并开源的一种结构化数据序列化格式。它和 JSON、XML 一样本质都是把内存里的结构体变成“能跨进程传输的一串字节”但它的存在方式完全不同——它不是让人类直接手写一串文本而是先定义一份 schema再生成各种语言的代码最终产生的是一段紧凑的二进制。这套设计让它天然在性能、体积和类型安全上占了便宜但也带来了一些使用门槛。这篇文章就是给你把门槛拆掉先讲原理再给实操最后给选型建议。1. 先搞清楚Protobuf 到底解决什么问题1.1 序列化这件事为什么值得单独聊任何两个进程要通信都会面临“怎么把对象变成字节”和“怎么把字节变回对象”的问题。最简单的做法是手写拼接字符串但很快你会遇到几个尴尬字段顺序错了怎么办、字符串里出现分隔符怎么办、嵌套结构怎么表达。于是才有了标准化的序列化格式。XML 的做法是把数据包进标签通过开始标签和结束标签表达层级关系JSON 的做法更轻用键值对和数组表达结构。这俩都是文本格式人都能直接看懂也方便调试。可文本格式有一个绕不开的代价为了让人看懂额外写入了大量语法字符比如引号、冒号、标签名、转义符。数据量小的时候这点开销无所谓但当你每秒要传输几千条、几万条消息时这些“人可读”的字符就全部变成了带宽和 CPU 的消耗。Protobuf 走的路线正好相反它完全不心疼“人是否看得懂”。发送方和接收方都遵守一份共同定义的 schema消息里只写字段编号和实际值不再重复写字段名。这一下就省掉了文本格式里的大半冗余。1.2 Protobuf 和 JSON/XML 不在一个层面很多人把 JSON、XML、Protobuf 放在同一条赛道上比其实它们的使用方式差别很大。JSON 和 XML 是“约定好的文本语法”任何语言自带解析器拿到文本直接就能读。Protobuf 则是“先定义 schema再生成代码”的开发流程你需要提前在一份.proto文件里描述数据结构然后通过编译器生成对应语言的类或结构体最后在代码里直接操作生成的对象完成序列化。这意味着 Protobuf 不是“拿来即用”的库而是一套“带编译期检查”的工程体系。它比 JSON 多了一个前置步骤但也正因为这个步骤让数据在传输前就已经被严格校验过了。实践里最常见的组合是.proto文件里既定义消息结构也定义服务接口再配套生成 gRPC 代码一条链路下来从结构到接口全部有类型约束几乎不给手误留空间。用一句大白话总结JSON/XML 让你写的是“给人看的文本”Protobuf 让你写的是“给机器高效解析的字节”。人看文本确实舒服但机器跑起来效率差得不是一点半点。2. 为什么我最终放弃了 JSON/XML——三个最扎心的理由2.1 体积差距不是一点半点先看一个非常典型的用户信息消息。{ id: 10086, name: 小王, email: xiaowangexample.com, status: 1, tags: [vip, new] }这段 JSON 如果转成 UTF-8 文本大概是 100 多个字节。同样的内容用下面这个.proto定义syntax proto3; package userv1; message User { uint64 id 1; string name 2; string email 3; repeated string tags 4; int32 status 5; }生成的代码序列化出来实际二进制只有几十个字节。原因不难理解文本格式里name:小王这 10 来个字符中真正有价值的数据只有“小王”两个字引号、冒号、字段名全是描述信息。Protobuf 里字段名在二进制里根本不存在取而代之的是 1 个字节的字段编号和类型标记。字段编号越靠前标记字节越小整体体积自然就下去了。有人会杠数据量小多个几十字节也不痛不痒。但放到真实场景里一条消息可能包含几十个嵌套字段、数组、浮点数多加的字节会翻好几倍如果这批数据还要经过移动网络传输每一千个用户就得多出几十 MB 的流量费用。这种体量差异在低频接口上无感在高频链路里是实打实的成本。2.2 解析速度差距在高频下是数量级的JSON 解析慢不只是因为字符多更核心的原因在于它的解析过程依赖“查找字段名”。一段 JSON 文本摆在那里解析器必须逐字符扫描、匹配键名、处理字符串转义、动态创建对象。字段名越复杂、嵌套越深内存分配和 CPU 指令就越多。Protobuf 的解析机制和它完全不同。发送端按照固定规则把字段编号和值紧凑地写入二进制接收端每读到一个字节就能立刻知道“哦这是 4 号字段是一个字符串”然后直接按偏移量取值。整个过程几乎没有文本扫描和动态字符串操作对 CPU 和内存都友好得多。我在一个实际服务里做过粗略统计同样的消息结构JSON 反序列化一次平均耗时在几十微秒到上百微秒之间而 Protobuf 反序列化基本能控制在几微秒到十几微妙之间。单看一次没啥感觉但压测到每秒上万请求时CPU 使用率的差距会非常明显。早期用 JSON 扛 3 万 QPS 时进程 CPU 经常飙到 70% 以上换成 Protobuf 后掉到 30% 左右同样的机器能扛的流量几乎翻倍。2.3 类型约束和版本演进JSON 给不了的“安全感”JSON 和 XML 都是弱类型文本字段名拼错不报错数字写成字符串也能解析结构里多嵌套一层不检查。这种自由在写脚本、做快速原型时很爽但在多人协作的正式工程里就是隐患。A 服务返回的id一直是数字突然某天变成了字符串B 服务可能要在运行时报错才能发现。Protobuf 因为有.proto这个“合同”从源头上把字段类型锁死了。定义成uint64的字段生成的代码就只会接受整数数组、枚举、嵌套消息全都由编译器保障。一旦某个字段的语义要变不是改一个字符串就行你得先改.proto再重新生成两端的代码这对团队来说反而是一种“强制评审”。更关键的是版本演进。JSON 在接口上加字段老客户端如果没做兼容处理很容易因为不认识新字段直接报错而 Protobuf 的字段编号机制天然支持向前向后兼容。新加字段只要换个新编号老版本程序读到未知编号时直接跳过不会崩删字段时只要不重复使用旧编号老客户端还能继续容错。就冲这一点我在维护长期接口时再也不愿意回到手搓 JSON 的老路。3. 手写一个 .proto 并跑通从定义到序列化3.1 一个最小可用的 .proto 文件长什么样用实操来验证上面这些理论。我准备了一套示例一个用户查询服务。先建一个user.proto文件内容大致是这样syntax proto3; package userdemo; service UserService { rpc GetUser (UserRequest) returns (UserResponse); } message UserRequest { uint64 user_id 1; } message UserResponse { uint64 user_id 1; string name 2; string email 3; int32 status 4; repeated string tags 5; Address address 6; } message Address { string city 1; string street 2; }几个值得注意的点第一行syntax proto3;声明使用 proto3 语法这是当前主流版本比 proto2 少了required/optional修饰符所有字段默认可选。package userdemo;相当于命名空间避免不同模块之间的消息重名。service UserService定义 RPC 接口后面可以配套 gRPC 使用如果你只想要普通的消息序列化不写 service 也没问题。每个字段后面的 1不是默认值而是字段在二进制编码中的唯一编号。这是整个 Protobuf 兼容体系的基石后面我会专门说。3.2 用代码生成和序列化验证以 Python 为例.proto文件写好后需要用protoc编译器生成目标语言的代码。我习惯用 Python 做实验生成命令是protoc --python_out. user.proto执行成功后目录下会出现一个user_pb2.py文件里面就是编译好的消息类。接下来写一段脚本创建用户对象、填充字段、再序列化成二进制import user_pb2 user user_pb2.UserResponse() user.user_id 10086 user.name 小王 user.email xiaowangexample.com user.status 1 user.tags.append(vip) user.tags.append(new) user.address.city 杭州 user.address.street 文一西路 data user.SerializeToString() print(序列化后字节数:, len(data)) print(二进制内容:, data)运行后你会看到这段二进制可能只有几十个字节而且里面能看到 “小王” 这类的原始字符串值但看不到name、email这些字段名。接收端拿到data后只要调用ParseFromString就能恢复成一个结构完整的对象new_user user_pb2.UserResponse() new_user.ParseFromString(data) print(new_user)输出的对象和原来的完全一致嵌套的address、重复的tags也都还原了。整个流程没有手动拼接字符串没有键名匹配所有操作都在内存对象上完成这就是 Protobuf 最典型的使用姿势。3.3 看看序列化后的二进制到底长什么样了解原理的人可能会好奇为什么这么短的二进制能装下这么多信息我们挑一段来说。user_id 10086这个字段二进制里会被编码成类似这样的一段先是 1 个字节表示“这是 1 号字段类型是变长整数”然后紧跟一个 varint 编码的数值。varint 是一种紧凑的数字表示法数字小就用 1 个字节数字大才多占字节不像文本格式里永远固定用完整字节数。字符串字段则采用“长度前缀 字节内容”的方式先写入字段编号和类型标记再写入字符串的长度最后写入字符串本体。接收端解析时能精确知道从哪里到哪里是一个完整字段不需要像 JSON 那样做引号匹配和嵌套扫描。这种编码逻辑虽然细节多但核心思想就是两个字紧凑。所有的字段名都不传所有的数字都按实际大小动态占位数组直接连续排列。于我而言理解到这个层面就够了真正写业务代码时你几乎不需要手碰编码细节编译器已经帮你处理完了。4. 理清几个容易踩坑的细节4.1 字段编号和兼容性加字段随便加删字段要谨慎Proto 文件里的编号不是摆设而是长周期接口的生命线。我的经验是给字段编号时一开始就要留出余量比如新设计的消息从 1 开始但相邻字段之间可以留空不要排得太密。这样以后加字段时你仍然能保持逻辑分组而不必频繁引入带来兼容风险的新编号。删字段时更要注意假设旧代码有int32 temp_flag 3;后来你删掉了它又在同一文件里新加了string remark 3;。老客户端发过来的数据里3 号字段本来是一个整数新服务端会按字符串去解析轻则类型错乱重则直接解析失败。这就是我最想强调的“删字段别复用编号”原则。正确的做法是把废弃字段留一个reserved 3;声明让编译器明确告诉你这个编号已经被占用了。4.2 可读性和调试二进制数据出问题了怎么办很多人初次用 Protobuf最不适应的就是日志不好排查一条消息打出来全是乱码没法像 JSON 那样一眼看出问题。应对方案有几个。第一利用文本格式调试。Protobuf 的官方库通常把消息的文本表示和二进制分开你可以把消息print成类似 JSON 的文本格式来看。Python 里直接print(user)输出就是可读的多行文本能快速确认字段值对不对。第二用工具命令解析线上二进制。protoc自带一个--decode能力指定.proto文件后可以把二进制内容反解成可读文本。排查消息队列里积压的脏数据时这个命令几乎是救命稻草。第三在日志里保留必要的原始入参。我习惯在关键链路的入口和出口分别记录 Protobuf 的十六进制摘要配合--decode_raw做初步判断。等真正定位到是哪个字段出错再用完整 schema 做精确解析速度比全量打印快很多。4.3 别拿小数据做结论性能对比的参考基准网上经常有人做“BenchmarkJSON vs Protobuf”然后得出一个看似科学的标准答案。但这类对比在数据体量很小、字段浅层、序列化次数只有几千次时结果往往看不出明显差距甚至 JSON 有时表现得也不差。我自己的实测范围是单条消息从 200 字节到 2KB 之间嵌套 2 到 3 层数组包含若干个元素连续跑 10 万次。这个时候 Protobuf 的体积优势稳定在 30% 到 50% 之间耗时优势更是能拉开 5 倍到 10 倍的差距。如果你的消息只有{code:0,msg:ok}这种两字段小对象或者一年只调几百次接口那 JSON 完全够用没必要引入额外工具链。性能优化永远要跟随压力和收益走不要为了“理论上更快”而盲目复杂化。5. 场景选型什么时候用 Protobuf什么时候还是老老实实用 JSON5.1 闭门造车内部服务与微服务链路如果你的服务只在公司内部互相调用两端都由自己团队维护那 Protobuf 几乎是首选。这里有两个理由一是内部调用往往有更高的 QPS 和更低的容忍度二是指标全链路可追踪出问题可以用同一套.proto快速定位不需要“面向浏览器打日志”。在这种场景里我会建议配合 gRPC 一起用。gRPC 直接把.proto里的 service 映射成远程调用接口传输层默认就是 Protobuf 编码流式传输、超时控制、健康检查都直接内置。用熟了以后你会发现自己几乎不用再关注“接口文档”这种东西.proto本身就是最权威的契约。当然内部链路也有例外。如果有的系统是老项目已经用 JSON 跑了好几年、团队没人熟悉 Protobuf 工具链那是否值得折腾就要算收益了。我见过不少系统为了“潮流”引入 Protobuf结果部署和排障成本比省下的那点带宽还高最后又退回 JSON。技术选型要尊重存量。5.2 对外输出OpenAPI 和浏览器端浏览器端的情况完全不同。前端 JavaScript 解析二进制并不方便更重要的是公开 API 的调用方五花八门有移动端、有脚本、有合作伙伴的后端让人家先下载.proto再生成代码往往是不现实的。公开 API 几乎就应该用 JSON。另外凡是需要人直接阅读的接口比如管理后台、跑批任务、运维脚本JSON 的“可读即所用”就是最大的优点。你随手curl一个接口就能看见完整响应不用依赖任何工具。这种场景下强行上 Protobuf 纯粹是给自己和调用方添堵。5.3 混合场景移动端、消息队列、大数据流还有些场景介于内部和外部之间需要具体情况具体判断。移动端 App 请求服务端接口体量通常有硬性限制流量越省用户体验越好所以客户端到服务端的核心传输用 Protobuf 很常见但 App 要方便联调服务端最好同时提供 JSON 的调试模式或者在网关层做协议转换。消息队列里存放的数据我几乎一律建议用 Protobuf。队列里的消息消费者往往不只一个不同消费者对字段的需求不同使用 Protobuf 后加字段不会打断老消费者而且序列化体积小能显著降低存储和带宽压力。大数据流的场景同理海量日志数据入湖、进数仓用文本格式会放大好几倍的存储成本Protobuf 的紧凑性和预定义 schema 反而更有利于数据质量管理。6. 我的几个固执建议也就是踩坑后的总结6.1 版本升级前先想好老客户端无论你多小心线上服务升级.proto时都可能踩到“编号冲突”或“类型变更”的坑。我的强制流程是改字段前先grep一遍现有代码确认没有其他地方引用了旧编号新增字段一律追加编号绝不插入到中间删除字段必须写reserved。如果是语义型变更比如“把状态码从 1 改为 2”千万不要直接改字段值而是新增一个状态枚举新值老逻辑继续兜底处理。我在一个支付对账模块里犯过这个错为了“优化枚举”把某个中间态删了结果所有处理到一半的订单全部走错分支排查了一个通宵才定位到问题。从那以后我对枚举和字段编号的态度就是宁可多留几种不再使用的状态也不轻易动既有语义。6.2 必要的时候上 gRPC别硬配 REST很多人接触 Protobuf 时只把.proto拿来做个序列化工具RPC 框架还是继续用 RESTful 风格。这没问题但你会失去很多红利比如流式通信、双向流、内置的负载均衡策略、错误码定义。现代微服务内部链路里我几乎默认用 gRPC只有面对浏览器或第三方时才单独开一个 JSON 网关。需要提醒的是gRPC 虽然好用但调试体验确实比 REST 差一些。你没法直接用浏览器访问一个 gRPC 接口必须依赖类似于grpcurl的工具。团队里如果新人多、工具链不熟我会建议先在一个非核心模块试点跑顺了再推广不要一开始就全量迁移。6.3 别过度设计小项目用 JSON 很快乐最后说句公道话。Protobuf 的强项在“长期演进 高频传输 强类型约束”但它带来的额外成本也非常明确要多维护.proto文件、要接入代码生成、要处理二进制排障。对于一个独立小工具、内部一次性脚本、前端展示后端的简单 API这些成本完全没必要掏。我现在的选型习惯是判断这个接口的生命周期会不会超过一年、调用量会不会到每秒千次以上、是否有多团队同时消费。如果三个条件都不满足我直接用 JSON 写接口完事如果命中至少两个条件我才会认真考虑引入 Protobuf。不盲目跟风带着场景做取舍这才是它真正“牛”起来的方式。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →