我个人最早开始接触Protobuf,是接手一个内部接口重构的模拟项目,当时团队在JSON和Protobuf之间反复纠结。大家最关心的一个问题很直接:Protobuf到底比JSON小多少?是不是真的值得引入一套编译工具链?抱着这个疑问,我拿同样的数据分别跑了一遍,结果很直观:同一个对象,JSON序列化后是62字节,Protobuf序列化后只有30字节,直接砍掉一半。这篇文章就从这个字节对比切入,把Protobuf的核心设计、序列化原理、工程落地经验一次讲透。适合刚接触Protobuf的后端开发、客户端开发,以及正在做技术选型、对接口性能和传输体积有要求的开发者。
1. Protobuf到底是个什么东西
1.1 先理解“序列化”这件事
在分布式系统里,数据从一端发到另一端,不能直接丢一个内存对象过去,因为不同语言、不同机器对内存对象的布局完全不同。你需要把结构化数据转换成一段字节流,到了对端再还原回来。这个“转换并还原”的过程,就叫序列化与反序列化。
序列化方案有很多,最朴素的是手写格式,比如两端约定好“用逗号分隔字段”,但不是所有人都能对格式达成一致,而且容易出现字符冲突、转义地狱。后来出现了JSON、XML这些文本格式,它们的好处是肉眼可见、调试方便,代价是体积大、解析慢。再往后,就有了二进制序列化方案,Protobuf就是其中最出名的一种。
Protobuf全称Protocol Buffers,是Google设计的一种语言中立、平台中立、可扩展的结构化数据序列化协议。它先把数据结构写进一个以.proto结尾的接口定义文件里,然后用专门的编译器生成对应语言的代码,程序里直接调用生成好的类或函数,就能完成序列化和反序列化。相比手写二进制格式,Protobuf把字段编号、类型、顺序全部规范化,数据结构调整时不需要双方同步改解析逻辑,只要遵循兼容性规则,老程序和新程序可以混跑。
1.2 和JSON的本质差异不在“大小”,而在“设计哲学”
很多人以为Protobuf的优势只是“压缩体积”,其实这只是结果,真正的差异在于设计哲学。
JSON属于“自描述型文本格式”,字段名、类型、分隔符都写进数据里。它像一本书,每一页都印着章节标题和页码,读者拿到就能直接看,不需要额外目录。而Protobuf属于“预定义契约型二进制格式”,发送端和接收端都有一份相同的“目录”——也就是.proto文件,所以线上传输的字节流根本不需要包含字段名,只需要字段编号和值。接收端靠编号反查自己手上的“目录”,就能还原出原来的结构。
这个差异决定了三件事:第一,Protobuf在同等数据下几乎一定比JSON小,因为省掉了字段名、花括号、冒号、引号这些“多余的标签”。第二,Protobuf的编解码速度通常比JSON快,因为二进制格式可以直接按偏移和长度读取,不需要逐字符解析字符串。第三,Protobuf的可读性极差,抓包看到的是一堆乱码,你没法像看JSON那样直接读懂。这三条,正是技术选型时最重要的权衡点。
2. 用同一个例子,实测JSON和Protobuf的字节差距
2.1 定义我们要序列化的数据
为了演示,我设计了一个非常常见的用户信息结构,包含四个字段:
| 字段 | 类型 | 值 |
|---|---|---|
| id | int32 | 123 |
| name | string | "Alice" |
| string | "alice@example.com" | |
| age | int32 | 30 |
这个数据简单,但覆盖了整数和字符串两种最常用的类型,足以说明问题。下面是JSON的表示,采用紧凑格式,不携带任何多余空格和换行:
{"id":123,"name":"Alice","email":"alice@example.com","age":30}我逐字统计过,这段字符串一共62个字符。由于ASCII字符在UTF-8下每个占1字节,因此JSON序列化后的大小就是62字节。
2.2 编写Protobuf定义文件
接下来,在.proto文件里定义同样的结构。这里我用proto3语法,字段编号从1开始:
syntax = "proto3"; message User { int32 id = 1; string name = 2; string email = 3; int32 age = 4; }注意这里的“字段编号”是序列化时真正写在字节流里的东西,不是字段值。它相当于数据库表的主键,每个字段在消息里有一个唯一编号。编号一旦上线就不能再改,否则老数据会解析到错误的字段上,这也是一些后端团队在做兼容性设计时容易踩的坑。
把这份文件丢给protoc编译器,生成对应语言(比如Go/Python/Java/C++)的代码。运行时创建一个User对象,赋值,然后调用序列化方法,得到一段二进制字节流。
2.3 拆解Protobuf序列化后的每一个字节
Protobuf序列化结果不是一串模糊的紧凑二进制,而是由一组组“字段块”按顺序拼接的。每个字段块的结构是:
字段标识 + 值内容
其中字段标识本身融合了“字段编号”和“Wire Type”(线缆类型),编码规则是:
tag = (field_number << 3) | wire_typewire_type的定义有很多种,我们这次用到的有两种:
| Wire Type | 含义 | 用于 |
|---|---|---|
| 0 | Varint(可变长整数) | int32、int64、uint32、bool等 |
| 2 | Length-delimited(长度前缀) | string、bytes、packed repeated字段、嵌套消息 |
逐个字段来看:
- id=1,int32,值123
tag = (1 << 3) | 0 = 0x08。123是正数且小于128,一个字节的varint就能表示,编码值为0x7B。所以id字段占用:08 7B,共2字节。
- name=2,string,值"Alice"
tag = (2 << 3) | 2 = 0x12。字符串长度是5,长度值编码为0x05,后面跟5个ASCII字节41 6C 69 63 65。这个字段占用:12 05 41 6C 69 63 65,共7字节。
- email=3,string,值"alice@example.com"
先数一下这个字符串,alice是5个字符,@是1个字符,example是7个字符,.是1个字符,com是3个字符,总共17字符。tag = (3 << 3) | 2 = 0x1A,长度17的varint是0x11,后面跟17字节的字符串内容。所以这个字段占:1 + 1 + 17 = 19字节。
- age=4,int32,值30
tag = (4 << 3) | 0 = 0x20。30的varint编码是0x1E。所以age字段占用20 1E,共2字节。
把四个字段加起来,总字节数:
| 字段 | 十六进制字节内容 | 字节数 |
|---|---|---|
| id | 08 7B | 2 |
| name | 12 05 41 6C 69 63 65 | 7 |
| 1A 11 61 6C 69 63 65 40 65 78 61 6D 70 6C 65 2E 63 6F 6D | 19 | |
| age | 20 1E | 2 |
| 合计 | 30 |
完整的30字节序列化结果如下:
08 7B 12 05 41 6C 69 63 65 1A 11 61 6C 69 63 65 40 65 78 61 6D 70 6C 65 2E 63 6F 6D 20 1E同样的数据,JSON 62字节,Protobuf 30字节,体积缩小了51.6%。如果把这个用户对象塞进一个数组里,比如返回100个用户列表,那差距会更加惊人。JSON需要把每个对象的字段名都重复一遍,而Protobuf只需要在每个消息里重复字段值,不需要重复字段名,体积优势会线性放大。
2.4 字节对比的直观表格
为了让大家对“省在哪里”有更清晰的感知,我把两者的结构差异列成一张表:
| 对比维度 | JSON | Protobuf |
|---|---|---|
| 字段名是否在线上传输 | 是,每个键重复出现 | 否,只传输字段编号 |
| 分隔符 | 需要花括号、冒号、逗号、引号 | 不需要 |
| 整数编码 | 十进制文本,比如123占3字节 | Varint二进制,123占1字节 |
| 字符串编码 | 直接带引号输出 | 用长度前缀标记,不转义 |
| 可读性 | 人类可直接阅读 | 二进制乱码,需要工具解析 |
| 编解码性能 | 需要解析文本,较慢 | 按格式读取,较快 |
这份对比放在面试、技术方案评审或者团队内部培训里都很实用。尤其要记住一个点:Protobuf的“小”不是靠压缩算法,而是靠传输模型优化。它天生就没有字段名,所以无论数据长什么样,这部分体积都是省掉的。
3. 字节变小背后的核心机制:Varint与TLV编码
3.1 Varint:让整数不再是固定长度
如果你认真看了上面例子,会发现int32字段在序列化后居然只占1字节。这在传统C/C++里是不可想象的,int32是4字节。JSON把数字写成文本,也是按字符数占空间。Protobuf则使用Varint,也就是“可变长子整型”。
Varint的核心思路很简单:每个字节的最高位是“延续位”,表示下一个字节是否仍然属于同一个整数。低7位是有效负载。数值越小,占用的字节越少。
以整数123为例,二进制是1111011,只有7位,所以直接放入一个字节的高7位,最高位设为0,得到01111011即0x7B。如果数值大于127,比如300,二进制是100101100,需要拆成两段:低7位是0101100,高7位是0000010。组装时低7位放在第一个字节,并且最高位置1表示后面还有数据;高7位放在第二个字节,最高位置0。结果就是10101100 00000010。这样300只占2字节,比固定4字节省一半,更比JSON文本3字节省1字节。
这就是Protobuf在大量小整数场景下体积特别有优势的根本原因。但要注意,如果全是很大的数字,比如几百万、上亿,Varint会膨胀到5字节,比JSON文本写数字还长(比如1234567在JSON里只有8字节),反而不划算。所以在设计协议时,如果确定某个字段的值永远比较小,可以直接用int32;如果可能变大,int64在中间范围内有时候比int32更不划算,需要结合场景选择。
3.2 TLV三元组:Protobuf的基本结构单元
Protobuf序列化后的数据本质上是一串被称为TLV(Tag-Length-Value)的结构。TLV是很多二进制协议的通用设计,比如网络协议里也经常用到,思路是:先告诉你这是什么字段,再告诉你这个字段的数据有多长,最后才是真正的数据。
当然,TLV不是绝对的。Protobuf把TLV中的“Length”这个环节根据类型做了裁剪:
- 对于Varint整数类型(wire_type=0),一次读取tag后,紧接着就是value,读取方根据varint的延续位就能判断结束,不需要Length。所以是T+V结构。
- 对于字符串/字节/嵌套消息类型(wire_type=2),必须带Length,因为字符串中间可能有任意字节,不标长度无法分割。所以是T+L+V结构。
这种设计让解码端可以跳过不认识的字段。假如你升级了.proto文件,给某条消息新加了一个字段编号5,老客户端读到的数据流里没有这个字段,没关系,老客户端解析完自己认识的字段后,遇到字节流结束就正常返回;新客户端给老客户端发数据时,如果带了老客户端不认识的新字段,老客户端读到字段编号5,如果它不知道这个编号,可以直接按照Tag里携带的wire_type来跳过相应字节,然后继续解析。这就是Protobuf向后兼容的底层基础。
3.3 压缩空间从哪来:进一步对比JSON逐层消耗
我们再拿数据逐层拆解一次,看看JSON到底把字节花在了什么地方:
- 花括号出现在开头和结尾,各1字节,共2字节。
- 四个字段名 id、name、email、age,加上包裹它们的引号和冒号,一共消耗了:id相关5字节,name相关7字节,email相关8字节,age相关7字节,总共27字节。字段名在Protobuf里完全不需要,这就是最大的一块“无用开销”。
- 四个字符串字段值包含在引号里:name的值加引号7字节,email的值加引号19字节。Protobuf的string长度前缀只占1字节,而且不需要转义特殊字符。JSON遇到引号要加反斜杠,遇到换行要转成
\n,这些额外字节Protobuf全部省掉。 - JSON数字用文本表示,123是3字节,30是2字节;Protobuf的varint分别是1字节和1字节。
紧凑JSON已经是文本里最省空间的写法了。如果你在HTTP里传输,Content-Type是application/json; charset=utf-8,实际发送就是这62字节,没有更多压缩空间。而Protobuf的结构天生压缩了“元数据”,比JSON少了整整32字节。如果数据量级到了百万级用户列表,这个差距就是几十兆字节。
4. 工程中真正要用Protobuf时,需要面对的几个实际问题
4.1 从语法到工具链:protoc和运行时库的配合
纸上谈兵完了,讲点落地的东西。要用Protobuf,第一步是装protoc编译器。不同语言还需要对应的运行时库,比如Go的google.golang.org/protobuf,Python的protobuf包,Java的protobuf-java。
实际项目里通常不会直接命令行一个一个生成代码,而是接入构建系统。比如Maven里配protobuf-maven-plugin,Go工程里用go generate脚本,前端接入grpc-web时用protoc-gen-grpc-web。生成代码的路径、版本管理、API风格在不同语言里差异很大,我自己的经验是:不要把这些生成代码提交到代码仓库,而是放在编译产出目录里,通过构建脚本统一生成,这样可以避免不同机器上的.proto版本不一致导致的“我这边能编、你那边不能编”问题。
.proto文件本身的版本管理反而要非常严格。它应该是只读的契约。小型团队通常用一个独立仓库管理.proto,类似内部的一个“协议中心”。所有下游服务通过指定版本引入。这个和实践中的API网关策略类似,先有契约再实现服务,双方不会跑偏。
4.2 可读性和调试问题:二进制协议最大的“反人性”
这是我实际使用中感受最深的一点:线上排查现场,队友发来一段消息体,我一眼看到的是这样的十六进制串:
08 7B 12 05 41 6C 69 63 65 1A 11 ...如果没有工具,谁也没法直接判断这个数据对应的是id=123, name="Alice"。而如果这段数据是JSON,所有人都能瞬间读懂。所以在长期运行的项目里,网络上传输用Protobuf,日志和监控里输出用JSON,是目前最标准的做法。
调试Protobuf数据流,我用过一个很笨但很有效的办法:在显式依赖Protobuf接口的内部系统之间传输,把串行化数据打印成十六进制,结合.proto手动对照字段编号解析。这样调过几次之后,你对TLV结构的理解会突飞猛进。生产环境则推荐用支持Protobuf解码的抓包工具,或者自己写一个小的解码脚本,输入十六进制字符串和.proto定义,直接输出人可读的JSON。团队内部完全可以搞一个这样的私有小工具,虽然简陋,但排查效率提升不是一点。
4.3 字段编号和兼容性:一不小心就会踩的坑
Protobuf好不好用,很大程度取决于你是否遵循了字段编号的演进规则。这里有几条铁律:
新增字段时,必须使用一个新的编号。比如在User消息里加一个phone字段,编号用5,不能复用原来的4。复用编号会直接把老数据解析到错误字段上,这是最严重的兼容性事故。
删除字段时,编号不要重用,也不要删除reserved声明。正确做法是在.proto文件里声明保留号:
message User { reserved 4; reserved "age"; }这样一旦有人不小心用了编号4或者字段名age,编译器会直接报错,避免幽灵字段出现在老数据里。这个细节在多人协作的团队特别重要。
想让字段兼容,要注意wire type的匹配。比如原来是一个int32字段,想改成固定32位(fixed32)还可以接受;但想改成string,由于wire type从0变成2,老数据会被解析错,必须换一个新编号,把老字段标为废弃。
我在一个实际项目中亲眼见过因为“复用了一个删除掉的字段编号”导致解析错乱的情况,那是典型的为了不升级版本号、图省事直接改.proto惹出来的。排查了一个下午,最后用二进制对照才发现是字段编号冲突。这个教训值得每个写协议的人牢记。
4.4 嵌套结构与Repeated字段:体积优势的另一面
实际业务里很少只有一条平铺消息,更多是嵌套结构或列表。这正好是Protobuf的又一大优势。
比如我们要定义一条学校班级的列表,里面包含班级名称和若干学生信息:
message Class { string name = 1; repeated User students = 2; } message School { string name = 1; repeated Class classes = 2; }当一个列表里有100个User对象时,JSON会把每个对象的id、name、email、age四个字段名重复100遍。而Protobuf只会把字段的Tag和长度前置,每一个User对象内部依然只编码字段值,Tag总共就那几个。假设每个字段名平均4字节、字段名重复4次,每个User对象在JSON里就会额外多出64字节左右,100个就是6.4KB。这在Protobuf里是零开销。
更妙的还有repeated字段的packed编码。在proto3里,对于基本数字类型的repeated字段,默认会采用packed编码,也就是把一组数字打包成一个字段,用一个长度前缀标识整个列表:
message ScoreList { repeated int32 score = 1; }如果分数是[99, 98, 97],packed编码会变成:
0A 03 63 62 61其中0A是字段1的Tag(长度前缀型),03是整个列表总长度3字节,后面三个数字各占1字节。比起为每个数字单独写一个Tag,这样省下了多个Tag字节。在数据量大的时候,又是一笔可观的节省。
不过,嵌套结构也会带来一个问题:消息嵌套的序列化字节中会包含Length前缀。如果你嵌套了很多层,解码时递归调用频繁,性能会有一定损耗。但对于绝大多数业务系统,这完全不是瓶颈。真正的瓶颈通常在网络传输层,不在本地编解码。
4.5 性能对比并不仅仅是“体积小”
很多人直接把“体积小”等同于“性能高”,这个逻辑对,但不是全貌。Protobuf性能优势的核心有两点:
第一,解析过程不涉及复杂的字符串转义和语法判断。JSON解析器拿到的是文本,需要做“词法分析”,也就是把一个个字符归类成token。遇到数字,还要判断是不是科学计数法;遇到字符串,还要寻找结束引号并处理转义;遇到空格,还得跳过空白字符。Protobuf解码器则是按Tag跳转,遇到Wire Type=0就连续读Varint,遇到Wire Type=2就读取长度然后拷贝一段字节,整个流程是线性的、确定性的。相同条件下,Protobuf的解码速度要明显快于JSON。
第二,在RPC或者消息队列场景里,体积直接影响带宽和I/O。我做过一个压测模拟项目,同样的接口QPS设定,JSON的千兆网卡在某个级别就被打满了,而换了Protobuf之后,同一个网卡还能再扛两倍以上的流量。这不是Protobuf本身多么神秘,就是单纯因为每消息少了几十个字节,同样带宽能跑更多消息。系统容量的天花板一下子就抬高了不少。
不过提醒一句:如果你的数据量极小,比如只有一两个字段,Protobuf的Savings可能微乎其微。比如一个消息只有一个int32字段,值为5,Protobuf编码需要2字节(Tag+value),JSON是{"a":5},紧凑编码7字节。两者差距没有大体积数据那么悬殊。如果还要考虑代码生成、调试复杂度,那体量小的系统完全没有必要上Protobuf。能用JSON解决的,不要为了“性能指标好看”而过度设计,这是我个人深有体会的建议。
5. 常见问题与排查技巧实录
5.1 反序列化时提示“字段编号越界”或“未知字段”
这个问题通常是因为.proto文件版本不一致导致的。发送方用的是新版本,接收方用的还是旧版本。Protobuf的设计允许接收方跳过未知字段,所以一般不会直接报错,而是会把未知字段放入UnknownFields里。
排查思路:
- 对比两端的
.proto字段编号,不能只比对字段名,要比对编号。 - 查看生成代码的哈希时间戳,确认是不是用了缓存重新生成。
- 在接口日志里加一个消息类型的唯一ID,比如把
.proto文件的sha256输出作为协议版本,这样对账特别方便。
5.2 同一个字段,Java解析出来的值和原始值不一致
基本逃不出Varint编码问题。int32和int64使用Varint编码,负数在Varint里会变成10个字节(因为负数是按2的64次方补码表示的,高位全是1)。如果你定义了一个int32 negativeValue,值设成-1,序列化后占用10字节而不是1字节。而如果用sint32类型(由ZigZag编码处理负数),-1只用1字节。这就是所谓负数陷阱。
排查方式很简单:看原始二进制字节数是否远超预期,再看字段类型是不是写成了int32而不是sint32。我个人建议,在协议设计阶段,凡是可能取负数或者正负都有的数字字段,直接用sint32或sint64,可以避免后期为了压缩体积而改类型带来的兼容性问题。
5.3 字符串长度错乱导致解析失败
这种问题经常发生在“手动拼接字节”或“从十六进制字符串粘贴数据”时。比如你在调试工具里,把一个字段的Length写错了,多算或少算1个字节,后续所有字段都错位。如果前导字段是Varint,还可能出现“负长度”这种完全无解的错误。
排查技巧是:把一个合法序列化出来的消息体,用hexdump按字节分隔开,然后对照.proto一个字段一个字段地核对。先核对Tag,确认wire type;再核对Length,确认字符串的实际长度是否和内容匹配;最后看Varint的延续位,确认有没有被截断。这需要耐心,但也是你最快理解Protobuf编码结构的方式。
5.4 使用Protobuf时常见的体积误解
有些人会认为Protobuf一定比JSON小,于是直接把JSON无损替换成Protobuf,结果有些数据反而变大了。常见场景是:
- 消息里包含大量必选的布尔字段,序列化为true时是Varint 1(1字节),false是Varint 0(1字节)。JSON里
"flag":false需要13字节。还是PB小一些。 - 但如果你的字段值是浮点数且精度很高,比如
3.14159265358979,JSON文本需要16字节,Protobuf默认用float/double固定编码,double固定8字节。这么看PB是小的。 - 真正的反例是枚举值的字符串表现。如果你把枚举直接当string存,而不是定义为enum,那字段名反复重复也会膨胀。但正常Protobuf设计不会这么做。
所以结论是:只要遵循基本规范、按照.proto定义合理使用类型,Protobuf几乎总是比同等JSON小。它莫名其妙变大的情况,基本都是因为字段编号设计得过于稀疏,或者负数没有用ZigZag编码。
5.5 建议的落地路线:先在单条消息、内部系统试点
考虑到Protobuf需要引入工具链、生成代码、调试变难,我建议初次尝试的新团队不要急着把所有接口都迁移过去。可以先把一个高流量、连续返回大量数据的只读接口改造成Protobuf,用对比数据来验证收益。先在内部系统之间试点,因为内部服务通常有统一的.proto管理能力,也方便快速迭代;对外API暂时保留JSON格式,通过网关做一次格式转换,等到内部试运行稳定,再决定是否推进到外部。
我的经验是,如果一个系统每天消息量在百万以下、平均包体积不超过1KB,迁移到Protobuf的收益其实没有想象中那么高,反而增加维护成本。但如果消息量大、网络是瓶颈,或者你的业务对端到端延迟敏感,那Protobuf值得认真投入。技术选型不是看谁更先进,而是看谁更贴合当前系统的瓶颈。
6. 最后分享一点我做字节对比实验的心得
如果你也打算自己动手跑一遍Protobuf和JSON的字节对比,不要只看最终结果,一定要把整个序列化过程“解剖”开来看。我试过最好的方式,是先用一个最短的proto消息,比如只有一个int32 id = 1,序列化后打印出字节,手工把每个字节的含义标注出来;再逐字段增加,直到构造出完整的消息。这个过程走一遍,你对Tag、Length、Varint这些概念的记忆会比看一百篇教程都牢固。
另外一个操作上很实用的小技巧是:在工程项目里,给Protobuf消息加一个DebugString()或者自定义的toJSON()方法(很多语言生成代码里自带),排查问题时把二进制转成JSON打印到日志里。线上故障定位时,不要直接打印RawMessage的二进制字节,那样既占空间又没法看。我的习惯是:日志中打印{ "protobuf_hex": "...", "decoded": {...} }这种复合结构,既保留原始信息,又有人类可读的副本。既方便快速判断,又能深挖细节。
最后再强调一次:Protobuf的字节节省,真正来源是“契约先行、元数据免传”的思想,而不是什么神奇的压缩算法。理解这一点之后,你在设计接口、选择序列化方案时,就不仅会关心“它比我现在的方案小多少”,还会去思考“我的字段名是否值得在网络上频繁传输”“我的协议是否需要一个长期稳定的演进机制”。只有把这些想清楚,才能让Protobuf在你的系统里真正发挥价值,而不是为了赶时髦引入一套额外的复杂度。