如果你做过后端或者客户端开发,大概率在某个技术方案里见过 Protobuf 这三个字母。它全称 Protocol Buffers,是一套开源的数据序列化方案,简单说就是把内存里的结构体、对象变成一段可以传输、可以存储的二进制,到了对端再还原成对象。这几年微服务架构铺开后,Protobuf 的出镜率越来越高,尤其和 gRPC 这类 RPC 框架绑定在一起,几乎成了内部接口的默认选项。但很多人第一次想深入它,是因为一个非常直接的问题:Protobuf 和 JSON 比,字节大小到底差多少?真的像网上说的能省一半流量吗?
这篇文章我不会只给结论,我会拿一个实际的用户对象,分别用 JSON 和 Protobuf 序列化,把两种格式逐字节拆开给你看。你看完不光知道哪个小,还能明白为什么小、什么时候该用、什么时候别勉强换。适合正在做接口设计、面试前想搞懂序列化原理、或者被领导一句话“咱们把接口换成 protobuf 吧”整懵的开发朋友。几分钟读完,你会对这两个东西的取舍有自己的判断。
1. Protobuf 是什么:先搞懂它解决的问题
1.1 序列化到底在解决什么问题
在拆字节之前,得先把“序列化”这件事说清楚。你在代码里写一个结构体或者对象,它在内存里是什么样?字符串可能是一块连续内存加一个长度字段,整数可能占 4 个字节,布尔值可能占 1 个字节,更别说还有指针、对齐、引用关系。这些东西离开当前进程之后全都不能直接用。你要么把它写到磁盘里,要么把它发到网络对端,这时候必须把它转成一段连续的、没有歧义的字节流,这个动作就叫序列化;对端拿到这串字节,再还原成对象,叫反序列化。
JSON 本质上就是干这个事的一种方案,它把内存对象写成一段带格式的文本。而 Protobuf 是另一种方案,它把对象编码成紧凑的二进制。很多人在单机、单进程里传对象没感觉,一旦服务拆开、流量上去了,序列化方案的选择就变成实打实的成本问题。
1.2 Protobuf 的工作流程和设计目标
Protobuf 最早是某头部科技公司为了解决内部 RPC 传输效率问题设计的,后来开源出来。它的设计目标写得很直白:比 XML 更小、更快、更简单。工作方式和 JSON 有一个本质区别:JSON 是“无 schema”的,拿到一段文本就能解析;Protobuf 则要求你先用.proto文件定义数据结构,再通过编译器(protoc)生成目标语言的代码,业务代码直接调用生成的类来设置字段、序列化、反序列化。
比如你要定义一个用户对象,得先写一个.proto文件,里面描述有哪些字段、每个字段什么类型、编号是多少。然后你用 protoc 把它编译成 Python、Java、Go、C++ 等任意支持语言的代码。这样一来,跨语言传递的时候,大家根据同一份.proto生成各自语言的代码,字段规则完全对齐,不会出现同一个对象在 A 服务里叫user_name、在 B 服务里叫username这种问题。
这套流程听起来比 JSON 重,但它带来的收益,就是接下来要聊的“字节大小”优势。JSON 因为是文本,里面塞满了引号、冒号、逗号、字段名;Protobuf 则把这些全部丢掉,只留下对端解析时“够用”的最小信息。
1.3 为什么大家总拿它和 JSON 比,而不是和 XML 比
XML 是更早的序列化格式,当年 SOAP、配置文件里到处都是它的身影。但 XML 有个致命问题:标签冗余实在太严重了。一个简单的用户信息,在 XML 里可能要写<name>ZhangSan</name>,开标签、闭标签写两遍,光标签名就占了二十几个字节。JSON 出来之后,开发者的心智立刻被它占据了,因为同样的数据,JSON 用{"name":"ZhangSan"}就搞定了,小了一半还不止。
所以到现在,绝大多数人脑海里“文本序列化”的默认选项就是 JSON,而不是 XML。真正会拿来和 Protobuf 对比的,一定是 JSON。再往后你会发现,JSON 在文本格式里已经算非常精简了,但它在 Protobuf 面前还是显得“啰嗦”,因为 Protobuf 直接把文本这层皮给脱了。
2. Protobuf 和 JSON 字节大小实测:同一个用户对象差多少
2.1 准备一个真实场景的数据结构
为了比较,我构造一个非常常见的用户信息对象,包含用户 ID、昵称、邮箱、年龄、是否 VIP、感兴趣的标签列表。这种结构在几乎任何一个带用户体系的系统里都能见到,拿来比较最有说服力。
先用 JSON 的视角看,这段数据长这样:
{"id":123456,"name":"ZhangSan","email":"zhangsan@example.com","age":30,"isVip":true,"tags":["tech","coding","reading"]}为了公平对比,这里用的是“紧凑模式”JSON,去掉所有多余空格和缩进。你要是把 JSON 格式化成人眼友好模式,字节数还会更高,但紧凑模式已经是 JSON 能拿出的最好成绩了。
如果用 Protobuf,需要先写一份.proto定义:
syntax = "proto3"; message User { int32 id = 1; string name = 2; string email = 3; int32 age = 4; bool is_vip = 5; repeated string tags = 6; }注意这里每个字段后面都有一个数字编号,1、2、3、4、5、6,这是 Protobuf 的关键设计,后面会详细解释。现在先看结果。
2.2 JSON 的序列化结果:119 字节
我把上面那段紧凑 JSON 逐字符数了一下,总共 119 字节。这 119 字节里包括什么?包括六个字段名(id、name、email、age、isVip、tags),包括每个字段名必须配的引号和冒号,包括对象的花括号、数组的方括号、每一对数据之间的逗号,然后才是真正的业务数据:用户 ID 的123456、字符串ZhangSan、邮箱地址、数字30、布尔值true,以及三个标签单词。
也就是说,JSON 为了让自己“可读”,必须反复地把字段名写在每一段数据里。一次传输还好,如果是一百万次调用,这些字段名字符串就会随着每一个消息在网络里反复跑,这部分成本是纯开销。
2.3 Protobuf 的序列化结果:63 字节
同样的数据,用上面那份.proto生成代码序列化,得到的是 63 字节二进制。直接看十六进制的话,长这样:
08 C0 C4 03 12 08 5A 68 61 6E 67 53 61 6E 1A 14 7A 68 61 6E 67 73 61 6E 40 65 78 61 6D 70 6C 65 2E 63 6F 6D 20 1E 28 01 32 04 74 65 63 68 32 06 63 6F 64 69 6E 67 32 07 72 65 61 64 69 6E 67我数了一下,63 字节。对比 JSON 的 119 字节,小了大概 47%。也就是说,在同样的网络带宽下,用 Protobuf 传输这批数据,流量直接砍掉将近一半。这不是我精心构造的极端例子,就是非常普通的用户对象,字段名也不算长,如果字段起名再长一点,比如userProfileAvatarUrl这种,差距会更夸张。
2.4 逐字节拆开看 Protobuf 的布局
光看字节数还不够,我得拆开让各位明白这 63 字节是怎么来的。我按字段一行一行拆:
| 十六进制内容 | 含义 |
|---|---|
08 C0 C4 03 | 字段 id,编号 1,varint 类型,值 123456 |
12 08 5A 68 61 6E 67 53 61 6E | 字段 name,编号 2,字符串类型,长度 8,值 “ZhangSan” |
1A 14 ... 6F 6D | 字段 email,编号 3,字符串类型,长度 20,邮箱地址 |
20 1E | 字段 age,编号 4,varint 类型,值 30 |
28 01 | 字段 is_vip,编号 5,varint 类型,值 true |
32 04 74 65 63 68 | 字段 tags 的第一项,字符串 “tech” |
32 06 63 6F 64 69 6E 67 | 字段 tags 的第二项,字符串 “coding” |
32 07 72 65 61 64 69 6E 67 | 字段 tags 的第三项,字符串 “reading” |
可以清楚地看到,每个字段在 Protobuf 里都由“编号 + 类型 + 实际内容”构成。08里的0表示这是字段 1,8表示类型是 varint;12表示这是字段 2,类型是长度分隔;1A表示这是字段 3,类型同样长度分隔。整个序列化结果里,完全没有任何字段名字符串,只有数字编号和二进制内容。
3. 为什么 Protobuf 能比 JSON 小一半:三个核心编码手段
3.1 第一个杀招:字段名换成数字编号
JSON 之所以大,最大的原因就是必须重复字段名。"userProfileAvatarUrl"这样一个字段,在 JSON 里要原封不动写出来,甚至在一个数组里几百个对象出现几百次。Protobuf 则完全绕开了字段名,它在.proto里给每个字段绑定一个数字编号,序列化时只写编号。
拿上面的例子来说,JSON 里六个字段名id、name、email、age、isVip、tags本身加上引号、冒号,大概占了四十多字节。而 Protobuf 里,每个字段的编号加类型只占 1 个字节开头。就这一下,大头就省下来了。
提示:字段编号越小,tag 编码越短。字段编号 1 到 15 时,tag 只需要 1 个字节;16 到 2047 时,tag 需要 2 到 3 个字节。所以设计
.proto时,把最频繁出现的字段编到 1 到 15,是省字节的最直接手段。
3.2 第二个杀招:Varint 变长整数
JSON 里写数字是直接写成十进制文本。123456在 JSON 里就是 6 个字符,占 6 字节。但 Protobuf 对整数用一种叫 Varint 的变长编码:数值越小,占的字节越少。123456用 Varint 编码只需要 3 字节,30只需要 1 字节,布尔值true也是 1 字节。
Varint 的原理不复杂:每个字节只用低 7 位存数据,最高位作为“续位标记”,如果最高位是 1,说明后面还有后续字节;如果最高位是 0,说明这个整数到此结束。这样 1 到 127 的整数只需 1 字节,128 到 16383 只需 2 字节,依此类推。对比 Java 里int永远占 4 字节、long永远占 8 字节,Varint 对大多数业务场景中“数值其实都不大”的整数非常友好。
在刚才的例子中,JSON 写"123456"用了 6 字节,Protobuf 用 3 字节;JSON 写"30"用了 2 字节,Protobuf 用 1 字节;JSON 写"true"用了 4 字节,Protobuf 用 1 字节。积少成多,差距就出来了。
3.3 第三个杀招:Wire Type 把类型信息压进 tag 里
Protobuf 里每个字段的 tag 本身不仅包含字段编号,还包含一个 3 位的 wire type,用来告诉解析方“后面这个字段的数据长什么样”。常见的 wire type 有这几种:
| 类型编号 | 含义 | 典型场景 |
|---|---|---|
| 0 | Varint | int32、int64、bool、enum |
| 1 | 64 位定长 | fixed64、double |
| 2 | 长度分隔 | string、bytes、repeated 嵌套消息 |
| 5 | 32 位定长 | fixed32、float |
一个 tag 字节里,低 3 位是 wire type,高位是字段编号。这样解析器读到08就知道字段 1 是 varint,读到12就知道字段 2 是长度分隔字符串。JSON 靠引号、冒号这类标点来识别结构,Protobuf 靠 tag 里的二进制位来识别结构,后者的信息利用率高得多,这也是它更紧凑的原因之一。
3.4 二进制不可读,其实是刻意选的成本
有人会问:Protobuf 直接把字段名丢掉了,那调试的时候怎么知道这段字节是啥?这确实是它的代价,二进制格式人眼不可读,也没有自描述能力。拿到一段 Protobuf 二进制,如果不提前知道对应的.proto文件,你是完全看不懂的。
但这个“缺点”恰恰是它字节小的前提。JSON 之所以可读,是因为它把每一位结构信息都用人眼友好的文本写出来了;Protobuf 放弃人眼友好,换来的是字段名、标点、结构标记全部优化成紧凑二进制。对机器来说,解析一个“字段 2,长度 8,读 8 个字节”的指令,比扫描大段文本中匹配引号和冒号要快得多。
4. 什么时候别盲目换 Protobuf:场景选型与避坑指南
4.1 该用 Protobuf 的场景
如果你是内部服务之间的高并发 RPC,每次请求都是一份不小的结构化数据,那么 Protobuf 几乎是最优解。它字节小,意味着同样的带宽可以承载更高的 QPS;序列化反序列化速度快,意味着 CPU 开销更低;有.proto作为契约,意味着服务端和客户端不会悄悄改字段改出问题。
移动端和服务端之间通信也值得考虑 Protobuf,尤其是在网络环境不稳定、流量套餐宝贵的场景下,省下来的流量是用户可感知的。大数据场景里,把数据落盘时用 Protobuf 或者更专门的二进制格式,也能显著减少存储成本。
4.2 继续用 JSON 的场景
如果你的接口要面对浏览器环境,几乎没得选,JSON 就是事实标准。JavaScript 原生支持 JSON,你给一个网页发 Protobuf 二进制,前端还得引入额外解码库和.proto文件,体验直线下降。公开 API 也建议继续用 JSON,因为外部开发者拿到你的接口文档就能直接调试,不用先学一套 Protobuf 工具链。
配置类数据、日志类数据、一次性导入导出的数据,也请老老实实用 JSON。这类场景强调“随时能被人看懂、方便脚本处理”,哪怕多占点磁盘或带宽,也比让运维同学在凌晨三点对着二进制抓瞎要强。
4.3 四个实战中容易踩的坑
第一个坑是随意改字段编号。.proto一旦发布,字段编号就是协议的一部分,后端改了编号,旧客户端按老编号解析,直接数据错乱。正确的是把废弃字段reserved掉,而不是删除重用编号。
第二个坑是更新兼容性。你的规则应该是:只能新增字段,不能删除或修改已有字段的类型。新增字段用新的编号,老客户端解析时会自动忽略它,新客户端解析老数据时用默认值填充,这样协议才能平滑升级。
第三个坑是调试工具链没跟上。JSON 可以直接curl看,Protobuf 不行,你得配 grpcurl、反射、或者用各类工具把二进制转成 JSON 再排查。如果团队以前全是 JSON 习惯,切换 Protobuf 后要第一时间把调试链路建立起来,否则出问题会非常痛苦。
第四个坑是语言绑定差异。有的语言对 Protobuf 的运行时支持很成熟,速度很快;有的语言则主要依赖纯运行时实现,性能并不一定比 JSON 快。做技术选型前,最好先在自己目标语言里跑一下基准测试,别光看二进制大小优化,结果序列化耗时不降反升。
4.4 字节变小之外,还要看整体性能
字节大小是 Protobuf 最直观的优势,但“序列化方案”到最后还是要看综合表现。序列化速度、反序列化速度、内存分配次数、CPU 缓存友好度,都是关键指标。很多做过压测的人会发现,文本格式在小对象上的性能差距没有想象中大,因为对象小、网络开销主导;而在大对象和很深的嵌套结构上,二进制格式的优势才彻底拉开。
还有一个容易被忽视的事实:如果所有数据都在传输前做了 gzip 压缩,JSON 和 Protobuf 的体积差距会明显缩小,因为 JSON 里大量重复的字段名会被压缩算法吃掉。但压缩本身消耗 CPU,而且 Protobuf 即使压缩后通常仍然更小,只是差距从“一倍”变成“百分之二三十”。所以别拿“反正要压缩”当不选 Protobuf 的借口,也别拿“Protobuf 小很多”当不搞压缩的挡箭牌,两者是不同层面的优化。
5. 最后分享几点实战体会
我实际用下来,最大的感受是:Protobuf 的收益是确定的,但成本也是确定的。确定收益在字节大小、解析速度、接口契约;确定成本在工具链、学习曲线、调试复杂度。如果你在一个全是 JSON 的团队里突然引入 Protobuf,最大的阻力往往不是技术,而是同伴们每一次“这个二进制到底啥意思”的崩溃。
所以我的建议是:别把 Protobuf 当银弹,先看场景。内部高 QPS RPC、存储格式、跨语言强契约接口,大胆用;浏览器、公开 API、一次性脚本、配置管理,继续用 JSON,不丢人。若真要切换,先把.proto的版本管理和调试工具搭好,再逐步迁移,别一夜之间把所有接口都换掉。
另外一个小技巧:如果只是想快速体验 Protobuf 的效果,不一定要立刻搭一套工程环境。你可以在线把.proto转成各种语言的代码,再用本地 Python 或 Node 脚本跑一小段序列化对比,几分钟就能得到和你业务真实数据非常接近的字节数结论。数据不会骗人,自己跑一次,比看十篇对比文章都管用。