很多团队在做技术选型的时候都有一个默认动作高并发服务里凡是涉及序列化传输的地方能上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表达下来可能1KBprotobuf的二进制表达可能只有700字节甚至更少。别小看这几百字节在带宽受限、消息量上亿的场景里这直接影响成本和网络I/O。第二是解析成本。JSON要按字符逐个解析处理字符串转义、数值转换、类型推断每一步都是CPU指令而且没有强类型约束反序列化时还得做一堆类型判断和转换。二进制协议则不需要这些字段用编号标识类型用Wire Type标识数据按规则紧凑排布解析的过程基本就是“照着模板填充结构体”。第三是类型安全。JSON和字段名绑定字段拼错、类型变化往往要到运行时才暴露protobuf靠.proto文件定义强类型结构编译期就把结构固定下来生成代码自带类型信息跨语言传输时不容易出现“类型悄悄变了”的闹剧。所以当你的系统进入高并发阶段“文本协议还是二进制协议”就不再是口味问题而是性能和可靠性的实打实差异。protobuf正是这个分水岭上最主流的二进制方案之一。1.3 高并发系统到底需要序列化方案有什么把需求列清楚后面分析原理才有参照物。在我看来高并发系统对序列化方案的硬性要求有三条足够的吞吐能力序列化和反序列化的CPU开销要低足够的压缩率线上传输带宽和存储成本要考虑以及强约束的Schema多语言协作时接口不容易跑偏。protobuf在这三条上都踩对了节奏编码紧凑、解析高效、Schema先行。但这个结论是怎么来的接下来我们从协议层看起。2. protobuf编码原理拆解又小又快的底气在哪2.1 每个字段都自带说明书Wire Type和Tagprotobuf能“小”第一层原因是它的二进制编码格式设计得极其紧凑。每个字段在编码时并不会把字段名写成字符串而是用一个数字编号加上一个类型标识来代替。这个组合叫Tag也叫key。它的计算公式很简单tag (field_number 3) | wire_typefield_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是2Tag就是 (2 3) | 2 18也就是0x12。字段3是repeated int64proto3里默认用packed编码wire_type也是2Tag是 (3 3) | 2 26即0x1A。所以protobuf的编码根本不存字段名它只存字段编号和类型。你看到0x08就知道是“第1个字段Varint类型”至于这个字段叫什么那是由.proto和生成代码决定的。省掉的不仅是字段名字符串本身的字节还省掉了解析器在匹配字段名时的字符串比较开销。Wire Type一共有以下几类Wire Type用途对应的原型字段类型0Varint变长整数int32、int64、uint32、uint64、sint32、sint64、bool、enum164-bit定长fixed64、sfixed64、double2Length-delimited长度前缀string、bytes、嵌套消息、packed repeated532-bit定长fixed32、sfixed32、float2.2 Varint让整数该省则省Tag解决了“字段名太长”的问题但整数本身的存储还大有文章可做。protobuf对整数类型采用了一种叫Varint的变长编码值越小占用的字节数越少。规则是每个字节的最高位作为是否“还有后续字节”的标记低7位存放真实数据数据按小端序从低到高排列。举个例子整数150。它的二进制是10010110一共8位。以7位为一组从低到高切分可以得到两组低位组0010110高位组0000001。第一组加最高位标记1变成100101100x96表示“后面还有字节”第二组加最高位标记0变成000000010x01表示“到这里结束”。所以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_id7namea。那UserInfo内部编码是user_id字段Tag(13)|00x08值是7的Varint就是07name字段Tag(23)|20x12长度是1内容是a的ASCII码0x61。整个UserInfo字节序列就是08 07 12 01 61共5个字节。OrderExt外层字段buyer的Tag是(13)|20x0A长度是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_id150buyer.user_id7、buyer.user_nameasku_ids[5, 6]paidtrue。逐字段编码如下。TradeOrder.order_id字段1Tag(13)|08即08150的Varint是96 01。TradeOrder.buyer字段2Tag(23)|218即12buyer内部的UserInfo编码是08 07 12 01 61长度5所以继续写05 08 07 12 01 61。TradeOrder.sku_ids字段3Tag(33)|226即1A长度2元素5和6的Varint分别是05、06所以写02 05 06。TradeOrder.paid字段4Tag(43)|032即20true的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、ThriftTBinaryProtocol做序列化和反序列化。这里必须先说明一点在Java里protobuf和Jackson的性能对比很大程度上取决于你用的是哪个底层实现。Jackson建议用最新的jackson-databind加jackson-module-afterburnerAfterburner能生成字节码直接访问字段比标准反射快很多protobuf也有生成代码和DynamicMessage两种模式前者才是正常用法。我用的是各自最常规的推荐配置。4.2 三组对照数据方案序列化耗时μs反序列化耗时μs序列化后体积字节Jackson标准databind25~4030~45约1100JacksonAfterburner15~2518~30约1100protobuf生成代码3~64~8约720ThriftTBinaryProtocol5~96~10约850补充说明一下这些数字来自我自己的压测环境8核16G的云主机JDK 17JMH单线程模式字段结构固定后才有对比意义。不同结构的消息尤其字符串数量和长度的变化会影响相对差距但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拉一组自己的数据把压测环境搭起来跑一跑别再凭着“听说快”来做决策。毕竟性能优化这种事自己的管线里量出来的数字永远比任何文章里的结论都靠谱。
