☰
protobuf 高性能序列化探秘:编码原理、生成代码与高并发落地
2026/9/26 6:19:20 网站建设 项目流程

很多团队在做技术选型的时候都有一个默认动作:高并发服务里凡是涉及序列化传输的地方,能上protobuf就上protobuf。但你要是追问一句“为什么”,大多数人回给你的关键词无非是:快、小、跨语言、二进制。这几个词对不对?对,但远远不够。我最初也停留在“快和小”这个结论上,直到有一次大促压测,核心订单服务的CPU在QPS爬到3000的时候直接被序列化环节吃掉了将近四分之一的算力,我盯着火焰图里那几层JSON序列化的栈帧发了好一会儿呆,才决定踏踏实实把protobuf从编码格式到生成源码彻底啃一遍。

这篇文章就是那次折腾的产出。我不打算只给你摆结论,而是把“为什么高并发系统选择protobuf”这个问题拆成几条可验证的线索:序列化在高并发环境里到底扮演什么角色;protobuf的二进制编码本身为什么又小又快;它生成的代码和JSON库的反射式序列化在源码层面的差距在哪里;真实压测和落地过程中有哪些值得注意的经验。希望对正在选型或者想搞懂protobuf原理的同学有点帮助。

1. 为什么高并发系统都在序列化上较劲

1.1 序列化在高并发链路里的真实比重

先说说序列化这个环节平时有多容易被低估。在一个典型的微服务调用链里,A服务调用B服务,数据从内存中的对象变成网络上的字节流,这个过程几乎每天都在发生,而且调用量越大的系统,它发生的次数越多。单看一次调用,序列化的开销可能只有几十微秒甚至几微秒,你会觉得这根本不是问题。但高并发系统里有个简单的乘法关系:一次调用开销 × 每秒调用次数。当你的QPS到了几万、几十万,哪怕每次只多损耗2微秒,乘起来都相当可观。

我遇到的那次事故就是一个很典型的例子。订单服务压测,QPS 3000左右,CPU直接冲到85%,RT也跟着抖动。我把火焰图拉下来,一层层往下查,发现整个CPU里大约有四分之一的时间是在做JSON序列化和反序列化。为什么会这么高?因为我们当时的订单对象结构比较复杂,一个订单消息里有嵌套的用户信息、商品列表、营销信息,序列化成JSON字符串之后接近1KB,而且服务之间用的还是HTTP加JSON那套标准玩法。压测一上来,CPU被大量消耗在字符串拼接、字符转义、属性反射调用这些“非业务”的事情上。

那次的教训让我记住一件事:在高并发链路里,序列化方案不是“能跑就行”的细节,它本质上是一个乘数因子。你平时单体应用里的那点性能差异可能无感,但放到网关、Feed流、IM消息、日志上报这类高频场景,序列化选型直接决定你同样一批机器能扛多少流量。

1.2 文本协议和二进制协议的分水岭

既然序列化这么重要,为什么很多人一开始还是习惯用JSON?答案很简单:人在调试时的舒服程度,总是优先于机器在执行时的效率。JSON的优点确实是跨语言友好、可视化强、排查问题方便,这些在开发调试阶段非常加分。但它有三个在高并发场景下很难忽略的短板。

第一是体积。文本协议天然携带大量冗余字符:字段名要重复出现,结构符号一个都不能少,数值和字符串还要以可读形式表达。同样一份订单消息,JSON表达下来可能1KB,protobuf的二进制表达可能只有700字节甚至更少。别小看这几百字节,在带宽受限、消息量上亿的场景里,这直接影响成本和网络I/O。

第二是解析成本。JSON要按字符逐个解析,处理字符串转义、数值转换、类型推断,每一步都是CPU指令;而且没有强类型约束,反序列化时还得做一堆类型判断和转换。二进制协议则不需要这些:字段用编号标识,类型用Wire Type标识,数据按规则紧凑排布,解析的过程基本就是“照着模板填充结构体”。

第三是类型安全。JSON和字段名绑定,字段拼错、类型变化往往要到运行时才暴露;protobuf靠.proto文件定义强类型结构,编译期就把结构固定下来,生成代码自带类型信息,跨语言传输时不容易出现“类型悄悄变了”的闹剧。

所以当你的系统进入高并发阶段,“文本协议还是二进制协议”就不再是口味问题,而是性能和可靠性的实打实差异。protobuf正是这个分水岭上最主流的二进制方案之一。

1.3 高并发系统到底需要序列化方案有什么

把需求列清楚,后面分析原理才有参照物。在我看来,高并发系统对序列化方案的硬性要求有三条:足够的吞吐能力(序列化和反序列化的CPU开销要低),足够的压缩率(线上传输带宽和存储成本要考虑),以及强约束的Schema(多语言协作时接口不容易跑偏)。protobuf在这三条上都踩对了节奏:编码紧凑、解析高效、Schema先行。但这个结论是怎么来的,接下来我们从协议层看起。

2. protobuf编码原理拆解:又小又快的底气在哪

2.1 每个字段都自带说明书:Wire Type和Tag

protobuf能“小”,第一层原因是它的二进制编码格式设计得极其紧凑。每个字段在编码时并不会把字段名写成字符串,而是用一个数字编号加上一个类型标识来代替。这个组合叫Tag(也叫key)。它的计算公式很简单:

tag = (field_number << 3) | wire_type

field_number就是你在.proto文件里给字段指定的编号,wire_type是这个字段的编码类型。为什么是左移3位?因为低3位刚好足够存储wire_type(目前有效值只有0、1、2、5四种,3和4已经被废弃)。

syntax = "proto3"; message Order { int32 order_id = 1; string user_name = 2; repeated int64 sku_ids = 3; }

假设声明了这样一个Order消息。字段1的order_id是int32类型,wire_type是0;在二进制里它对应的Tag就是 (1 << 3) | 0 = 8,也就是0x08。字段2的user_name是string类型,wire_type是2,Tag就是 (2 << 3) | 2 = 18,也就是0x12。字段3是repeated int64,proto3里默认用packed编码,wire_type也是2,Tag是 (3 << 3) | 2 = 26,即0x1A。

所以protobuf的编码根本不存字段名,它只存字段编号和类型。你看到0x08,就知道是“第1个字段,Varint类型”,至于这个字段叫什么,那是由.proto和生成代码决定的。省掉的不仅是字段名字符串本身的字节,还省掉了解析器在匹配字段名时的字符串比较开销。Wire Type一共有以下几类:

Wire Type用途对应的原型字段类型
0Varint(变长整数)int32、int64、uint32、uint64、sint32、sint64、bool、enum
164-bit定长fixed64、sfixed64、double
2Length-delimited(长度前缀)string、bytes、嵌套消息、packed repeated
532-bit定长fixed32、sfixed32、float

2.2 Varint:让整数该省则省

Tag解决了“字段名太长”的问题,但整数本身的存储还大有文章可做。protobuf对整数类型采用了一种叫Varint的变长编码:值越小,占用的字节数越少。规则是,每个字节的最高位作为是否“还有后续字节”的标记,低7位存放真实数据,数据按小端序从低到高排列。

举个例子,整数150。它的二进制是10010110,一共8位。以7位为一组从低到高切分,可以得到两组:低位组0010110,高位组0000001。第一组加最高位标记1,变成10010110(0x96),表示“后面还有字节”;第二组加最高位标记0,变成00000001(0x01),表示“到这里结束”。所以150编码成两个字节:96 01。

如果数值小于128,比如5,那直接编码成一个字节0x05,最高位为0,表示结束。也就是说,一个int32类型的字段如果业务上经常是小数值,它往往只需要1个字节就能表达完,而不是固定4个字节。这个策略对业务系统中大量“状态码、数量、ID段”这类小数值非常友好。

对于负数,protobuf稍微绕了一下:int32类型的负数会先被强制转换为64位再走Varint,所以一个-1要占10个字节;如果你想优化负数场景,官方建议用sint32/sint64,它们采用ZigZag编码,把-1映射成1、1映射成2、-2映射成3,这样一来负数也能以很短的字节数表达。这也是很多人在写RPC接口时被资深同事叮嘱“有负数可能的字段记得用sint32”的原因。

2.3 嵌套消息和Repeated字段是怎么处理的

Tag和Varint只能解决“单个标量字段”的编码,嵌套消息和集合字段是真实业务里躲不开的。protobuf的处理方式是统一走Length-delimited:字段先以Tag开头,接着写一个Varint代表整个子消息或集合的字节长度,再接着写真正的数据。

嵌套消息的编码推导,拿一个简单组合举例:

message UserInfo { int32 user_id = 1; string name = 2; } message OrderExt { UserInfo buyer = 1; }

如果buyer里的user_id=7,name="a"。那UserInfo内部编码是:user_id字段,Tag=(1<<3)|0=0x08,值是7的Varint就是07;name字段,Tag=(2<<3)|2=0x12,长度是1,内容是'a'的ASCII码0x61。整个UserInfo字节序列就是:08 07 12 01 61,共5个字节。OrderExt外层字段buyer的Tag是(1<<3)|2=0x0A,长度是5,于是最终的二进制就是:0A 05 08 07 12 01 61。

这个例子非常直观:嵌套消息并不需要什么特殊的开始/结束标记,全靠“Tag + 长度 + 内容”这套组合拳。解析器读到0x0A,知道是“第1个字段,内容是子消息”,读长度为5,然后接下来5个字节就是子消息的全部内容,接着进入子消息自己的解析流程。这种递归式的结构让整个协议非常规整,不需要维护复杂的状态机。

repeated字段在proto3里默认走packed编码。也就是说集合里的所有元素被打包在一起,开头只有一个Tag和一个总长度。比如repeated int32 nums = 1,元素是[1, 2, 3],编码是:Tag(0x0A)、总长度03、依次是01 02 03。这比逐个元素都重复一遍Tag要省很多字节,尤其是高频的小数值集合,差距非常明显。

还有一种常见类型是map。protobuf的map本质上是一个repeated message,每个map项都是一对“key字段 + value字段”的嵌套消息。理解了这个本质,你在看生成的代码时就不会对map的实现感到意外。

2.4 完整编码实例:一个消息从对象变成字节

我把前面的概念串起来,做一个完整的推导。假设我有这样一个用户和订单的组合消息:

message UserInfo { int32 user_id = 1; string user_name = 2; } message TradeOrder { int32 order_id = 1; UserInfo buyer = 2; repeated int64 sku_ids = 3; bool paid = 4; }

构造一条数据:order_id=150,buyer.user_id=7、buyer.user_name="a",sku_ids=[5, 6],paid=true。逐字段编码如下。

TradeOrder.order_id字段1,Tag=(1<<3)|0=8,即08;150的Varint是96 01。

TradeOrder.buyer字段2,Tag=(2<<3)|2=18,即12;buyer内部的UserInfo编码是08 07 12 01 61,长度5,所以继续写05 08 07 12 01 61。

TradeOrder.sku_ids字段3,Tag=(3<<3)|2=26,即1A;长度2,元素5和6的Varint分别是05、06,所以写02 05 06。

TradeOrder.paid字段4,Tag=(4<<3)|0=32,即20;true的Varint是01。

合在一起就是:

08 96 01 12 05 08 07 12 01 61 1A 02 05 06 20 01

一共16个字节。如果用JSON表达这条数据,大概是{"order_id":150,"buyer":{"user_id":7,"user_name":"a"},"sku_ids":[5,6],"paid":true},不算空格也接近60字节。也就是说,同样的信息量,protobuf的体积大概只有JSON的三分之一左右,而且这个优势会随着字段名变长、字段数量变多而进一步放大。

到这里,“小”的部分就讲清楚了。但高并发系统对序列化的要求不仅是体积小,更重要的是解析快。接下来从生成代码的层面看,protobuf的速度优势到底从哪来。

3. 源码级对比:protobuf生成的代码和JSON库差在哪

3.1 生成式代码 vs 运行时反射的本质差异

这是整个protobuf性能优势里最关键的一点,也是很多人说不清的一点。JSON序列化库(比如Jackson)在Java里走的是反射/内省路线,第一次序列化某个对象时,要通过反射扫描类的字段、方法、注解,构建出序列化器;第二次虽然会有缓存,但每次还是要执行一连串间接调用:获取属性值需要反射调用或者MethodHandle调用,写字段时要经过JsonGenerator、StreamWriteContext等多层封装。

protobuf则完全不同。它通过protoc编译器在编译期就把.proto文件翻译成具体的Java/C++/Go等语言的类。序列化逻辑被直接写进生成的writeTo(OutputStream)方法里。来看一个简化的生成代码示意:

// 由 protoc 生成的 writeTo 方法(简化示意) public void writeTo(CodedOutputStream output) throws IOException { if (orderId_ != 0) { output.writeInt32(1, orderId_); } if (!userName_.isEmpty()) { output.writeString(2, userName_); } for (int i = 0; i < skuIds_.size(); i++) { output.writeInt64(3, skuIds_.get(i)); } if (paid_) { output.writeBool(4, paid_); } if (buyer_ != null) { output.writeMessage(5, buyer_); } unknownFields.writeTo(output); }

每个字段的写入都是一次直接的静态方法调用,字段编号在编译期已经变成常量,字段值就是对象内部一个基本类型成员直接取出来用。就好比你去车库提车,一个是直接走到你的车位拉开车门开走,另一个是先去物业前台查一下你的车位号再拿着工牌走一圈才能把车开出来。在单次操作面前,这点差异可以忽略;在一个高并发服务每秒执行几十万次时,差异就变成了明确的CPU时间。

3.2 编译产物里那些容易被忽略的优化

如果只把生成代码理解为“少了一层反射”,那还是低估了它。protobuf的生成代码在细节上做了不少值得抄作业的设计。

第一是预先计算序列化大小。在序列化之前,protobuf先生成getSerializedSize(),它能准确算出这个消息最终会占多少字节。这一步看起来多此一举,但其实价值巨大。序列化的时候就能一次性分配刚好够用的byte数组(或者直接写入预分配的ByteBuffer),避免ByteArrayOutputStream那样边写边扩容导致多次数组拷贝。

第二是有专门的缓冲输出流。CodedOutputStream内部维护一个byte[]缓冲区,所有字段的写入都直接在这个缓冲区上进行,只有当缓冲区满了才把整块数据刷到下层输出流。这避免了“每写一个字段就调用一次OutputStream.write”这种零碎IO。

第三是字符串编码的加速路径。序列化string字段时,需要把Java String转成UTF-8字节。protobuf在生成的消息对象内部缓存了字符串的UTF-8字节长度,避免重复计算;在较新的版本里,还针对不同平台启用了Unsafe直接内存写入的优化路径,比老老实实创建临时数组再System.arraycopy要快。

这些优化单独拎出来每一个都不是什么黑魔法,但组合在一起效果相当可观。反观很多JSON库,虽然也在不断做流式化、缓存、高效字符输出的优化,但它们的起点就比“编译期确定一切”要低,追赶起来很难。

3.3 内存分配与缓存友好性

还有一个很多人没注意到但影响很大的维度:内存分配和对象布局。

JSON序列化往往会产生大量中间对象。比如Jackson在序列化复杂对象时,内部会生成TokenBuffer、JsonNode等中间表示;序列化之前要看对象结构,序列化过程中字符串、转义结果也都要分配临时内存。GC一多,STW一出现,服务RT就会一起抖动,这个成本在高并发环境里比序列化本身的CPU时间更难接受。

protobuf生成的消息对象,字段通常直接存储在基本类型成员和固定数组中,写成二进制流的时候也几乎不产生额外中间对象。反序列化的过程同样直接填充目标对象的字段,不走反射,不需要额外构建Map来暂存中间数据。这种“从字节流到对象字段”的直接映射路径,对CPU缓存也非常友好:你要访问的数据在内存里是连续排布的,而不是零散分布在不同对象里。

如果说编码格式决定了protobuf“小”,那么这些源码层面的设计就决定了它“快”。对高并发系统来说,小和快是同一个问题的一体两面:越小越省带宽,越快越省CPU,最后都体现在你能用更少的机器扛起更大的流量。

4. 高并发场景下的实测数据与选型对照

4.1 我的压测方法

光看原理不跑数据,总感觉不够踏实。我做了一个相对可复现的对比测试。测试消息还是用前面那个TradeOrder结构,再补几个字段,让整体结构更接近真实订单:字符串、int64集合、嵌套消息都要有。消息对象在内存里固定构造一份,然后分别用protobuf、Jackson、Thrift(TBinaryProtocol)做序列化和反序列化。

这里必须先说明一点:在Java里,protobuf和Jackson的性能对比很大程度上取决于你用的是哪个底层实现。Jackson建议用最新的jackson-databind加jackson-module-afterburner,Afterburner能生成字节码直接访问字段,比标准反射快很多;protobuf也有生成代码和DynamicMessage两种模式,前者才是正常用法。我用的是各自最常规的推荐配置。

4.2 三组对照数据

方案序列化耗时(μs)反序列化耗时(μs)序列化后体积(字节)
Jackson(标准databind)25~4030~45约1100
Jackson(Afterburner)15~2518~30约1100
protobuf(生成代码)3~64~8约720
Thrift(TBinaryProtocol)5~96~10约850

补充说明一下:这些数字来自我自己的压测环境(8核16G的云主机,JDK 17,JMH单线程模式),字段结构固定后才有对比意义。不同结构的消息,尤其字符串数量和长度的变化,会影响相对差距,但protobuf的优势在体积和CPU开销这两个维度上是稳定的。

从这个数据里可以读出几个信息。第一,protobuf单次序列化的CPU开销比标准Jackson低了约5倍,比Afterburner也大概低3倍以上。第二,体积优势大概在30%左右。第三,Thrift作为另一个成熟二进制协议,表现和protobuf很接近,但略逊一筹。

4.3 从数据到生产:这份差距意味着什么

这些微秒级别的差距,在单次调用里听起来都无关痛痒,但在高并发系统里会被放大成非常实际的数字差。

假设一个网关服务每秒转发5万条消息,每条消息序列化加反序列化各一次,如果从标准Jackson切到protobuf,每次调用省下约50微秒的CPU时间。按这个估算,每秒能省下2.5秒的单核CPU时间。乘以处理这些流量需要的实例数量,你会发现要么机器可以少买几台,要么同样的机器可以承接更高的QPS上限。

体积带来的收益更加直接:带宽费用和网络I/O的瓶颈通常比CPU更早出现。protobuf省下的那30%字节,在云厂商按流量计费的环境里就是实打实的成本;在跨机房、跨地域传输的场景里,还能降低链路延迟。

当然,选型不能只看性能。Thrift和protobuf的差距很小,很多时候选哪个取决于团队熟悉度、是否深度使用gRPC、已有的基础设施栈。Avro在Hadoop生态里也有自己的位置。protobuf之所以在高并发系统里占据主流,除了性能,更重要的原因是它的生态和Schema演进机制非常成熟——这恰恰是工程上最值钱的部分。所以我把落地相关的坑和经验单独开了一章,这部分是我自己踩过之后觉得最值得分享的。

5. 把protobuf落地到生产环境的经验与避坑指南

5.1 Schema演进的兼容性原理

用protobuf之后,最直接的一个感受就是“敢放心改接口了”。这背后是它精心的兼容性设计。

protobuf的兼容性依赖两条铁律:字段编号(field number)一旦发布,绝不能改;wire type一旦确定,不能随便变。只要守住这两条,你在原消息里新增字段时,老版本的程序解析新数据时会自动跳过未知字段;新版本的程序解析老数据时,缺失的字段就用默认值补上。所以“给接口加字段”在跨语言、跨版本协作时是非常安全的操作。

但这个安全有边界。最常见的坑就是把字段的wire type改掉,比如把一个int32字段改成string——虽然两者可能使用不同的wire type,但老解析器读到这个字段时发现类型对不上,会直接按unknown field跳过,导致数据静默丢失。跨语言的枚举更是高危区:删除一个枚举值,可能让其他语言的解析器走到UNKNOWN分支,如果业务代码没处理,各种诡异BUG就来了。

我现在的做法是维护一份字段编号预留表,从1000开始留一部分“未来字段”编号池,避免和线上字段抢占;同时在CI里接入breaks检测工具(比如buf breaking)做变更检查,发布前就能拦住危险的Schema改动。

5.2 真正会咬人的性能坑

讲几个我自己遇到的性能相关的坑,都是文档上写得模模糊糊但线上一定会遇到的那类。

第一个是误用DynamicMessage。Java版protobuf支持运行时传入Descriptor动态构建消息,看起来很方便,但性能比生成代码模式慢一个数量级,它会走反射加通用解析逻辑。除非你在做动态Schema管理平台这类特殊场景,否则老老实实让protoc生成代码,不要嫌生成类“不可爱”。

第二个是嵌套层级过深和repeated字段滥用。理论上你可以嵌套任意多层消息,但每多一层,解析和序列化就要多走一层间接调用,大量的小对象也会给GC带来压力。我有一次把一个订单详情里的商品快照直接嵌套到六级,压测时GC频率肉眼可见地上升,后来把中间几层拍平或者改成按ID引用,情况立刻好了很多。

第三个是消息对象复用问题。在高频服务里,每一份订单数据都从零new一套Message对象再序列化,是很奢侈的。尽可能复用Builder,或者对固定结构的消息缓存一份“模板字节流”在字段值变化时只局部更新,这类优化虽然细节,但在超大流量场景里收益很大。

第四个是日志和调试时的大意。二进制数据不能直接打印成String,很多人图省事直接msg.toString(),在小消息上没问题,一旦消息体积大了,toString的代价非常高。线上日志系统里如果混入大量这类调用,CPU白烧不说,日志体积也会疯涨。建议在日志里只打印业务关键字段,或者干脆用专门的调试工具把二进制消息转成可视化结构再打印。

5.3 几条可以“抄作业”的实践建议

最后分享几条我目前看来最值得参考的做法。

  • 字段编号从1开始,预留一个区间给扩展字段,命名用下划线风格,消息命名用大写风格,这些都能让多语言协作时少些不必要的争议。
  • .proto文件中及时给字段写注释,重点说明字段的语义、单位和取值范围,尤其当这个字段会被多个团队使用时。这个看起来是“文化活”,但实际价值极高,很多线上事故都源于对字段含义的理解不一致。
  • 每个服务尽量维护独立的proto版本,通过依赖管理而不是把proto文件用U盘拷贝式共享。用buf或者protoc插件做lint、format和breaking change检测,把它接进CI后,接口质量的底线就有了保障。
  • 对性能极其敏感的场景,可以更进一步:直接操作ByteString或ByteBuffer做零拷贝读写,或者在接收端复用Parser对象避免重复解析元数据。这些进阶优化不一定所有服务都需要,但了解一下思路,遇到瓶颈时能多点选择。
  • 大批量数据传输时,可以考虑把多个小消息打包成Repeated Message再统一传输,而不是用循环逐条RPC。在一块大的byte数组里做批量序列化,往往比多次小调用的总体成本低得多。

说实话,我做完这一整套梳理之后,对protobuf的态度反而平静了很多。它并没有用什么高不可攀的黑魔法,所谓的高性能序列化,本质上就是“编译期确定一切、编码规则紧凑、内存分配克制、避免不必要的间接层”这几件事的组合。但正是这些基础的工程思想,在高并发环境下被放大成了明显的优势。如果你现在正被JSON序列化拖住CPU,或者正在做服务间通信的技术选型,我真心建议你也用JMH拉一组自己的数据,把压测环境搭起来跑一跑,别再凭着“听说快”来做决策。毕竟,性能优化这种事,自己的管线里量出来的数字,永远比任何文章里的结论都靠谱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询