资讯动态

Protobuf比JSON小多少?字节对比与序列化原理一次讲透

发布时间:2026/10/16 17:53:20 来源:尧图企业网站定制
我个人最早开始接触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 定义我们要序列化的数据为了演示我设计了一个非常常见的用户信息结构包含四个字段字段类型值idint32123namestringAliceemailstringaliceexample.comageint3230这个数据简单但覆盖了整数和字符串两种最常用的类型足以说明问题。下面是JSON的表示采用紧凑格式不携带任何多余空格和换行{id:123,name:Alice,email:aliceexample.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含义用于0Varint可变长整数int32、int64、uint32、bool等2Length-delimited长度前缀string、bytes、packed repeated字段、嵌套消息逐个字段来看id1int32值123tag (1 3) | 0 0x08。123是正数且小于128一个字节的varint就能表示编码值为0x7B。所以id字段占用08 7B共2字节。name2string值Alicetag (2 3) | 2 0x12。字符串长度是5长度值编码为0x05后面跟5个ASCII字节41 6C 69 63 65。这个字段占用12 05 41 6C 69 63 65共7字节。email3string值aliceexample.com先数一下这个字符串alice是5个字符是1个字符example是7个字符.是1个字符com是3个字符总共17字符。tag (3 3) | 2 0x1A长度17的varint是0x11后面跟17字节的字符串内容。所以这个字段占1 1 17 19字节。age4int32值30tag (4 3) | 0 0x20。30的varint编码是0x1E。所以age字段占用20 1E共2字节。把四个字段加起来总字节数字段十六进制字节内容字节数id08 7B2name12 05 41 6C 69 63 657email1A 11 61 6C 69 63 65 40 65 78 61 6D 70 6C 65 2E 63 6F 6D19age20 1E2合计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 字节对比的直观表格为了让大家对“省在哪里”有更清晰的感知我把两者的结构差异列成一张表对比维度JSONProtobuf字段名是否在线上传输是每个键重复出现否只传输字段编号分隔符需要花括号、冒号、逗号、引号不需要整数编码十进制文本比如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序列化后的数据本质上是一串被称为TLVTag-Length-Value的结构。TLV是很多二进制协议的通用设计比如网络协议里也经常用到思路是先告诉你这是什么字段再告诉你这个字段的数据有多长最后才是真正的数据。当然TLV不是绝对的。Protobuf把TLV中的“Length”这个环节根据类型做了裁剪对于Varint整数类型wire_type0一次读取tag后紧接着就是value读取方根据varint的延续位就能判断结束不需要Length。所以是TV结构。对于字符串/字节/嵌套消息类型wire_type2必须带Length因为字符串中间可能有任意字节不标长度无法分割。所以是TLV结构。这种设计让解码端可以跳过不认识的字段。假如你升级了.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; charsetutf-8实际发送就是这62字节没有更多压缩空间。而Protobuf的结构天生压缩了“元数据”比JSON少了整整32字节。如果数据量级到了百万级用户列表这个差距就是几十兆字节。4. 工程中真正要用Protobuf时需要面对的几个实际问题4.1 从语法到工具链protoc和运行时库的配合纸上谈兵完了讲点落地的东西。要用Protobuf第一步是装protoc编译器。不同语言还需要对应的运行时库比如Go的google.golang.org/protobufPython的protobuf包Java的protobuf-java。实际项目里通常不会直接命令行一个一个生成代码而是接入构建系统。比如Maven里配protobuf-maven-pluginGo工程里用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 ...如果没有工具谁也没法直接判断这个数据对应的是id123, nameAlice。而如果这段数据是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 Type0就连续读Varint遇到Wire Type2就读取长度然后拷贝一段字节整个流程是线性的、确定性的。相同条件下Protobuf的解码速度要明显快于JSON。第二在RPC或者消息队列场景里体积直接影响带宽和I/O。我做过一个压测模拟项目同样的接口QPS设定JSON的千兆网卡在某个级别就被打满了而换了Protobuf之后同一个网卡还能再扛两倍以上的流量。这不是Protobuf本身多么神秘就是单纯因为每消息少了几十个字节同样带宽能跑更多消息。系统容量的天花板一下子就抬高了不少。不过提醒一句如果你的数据量极小比如只有一两个字段Protobuf的Savings可能微乎其微。比如一个消息只有一个int32字段值为5Protobuf编码需要2字节TagvalueJSON是{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 11字节false是Varint 01字节。JSON里flag:false需要13字节。还是PB小一些。但如果你的字段值是浮点数且精度很高比如3.14159265358979JSON文本需要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在你的系统里真正发挥价值而不是为了赶时髦引入一套额外的复杂度。

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

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

免费获取报价 →
↑