
1. 事故现场主播还在喊3、21后台已经一片 504先交代一下背景。我们团队维护的是一套面向东南亚市场的跨国直播带货平台货品从国内仓直发用户分布在泰国、印尼、菲律宾这些地方。技术栈是典型的微服务架构订单服务、库存服务、促销服务、用户服务互相 RPC 调用入口网关对外暴露 REST API内部走 HTTP JSON。这套架构用了很久平时流量虽然不小但一直没出过大乱子。直到某次大促直播当晚事故来了。当晚 8 点黄金档头部主播上线单场同时在线逼近几十万。开播 20 分钟后监控大屏开始飘红订单服务接口成功率直线下跌p99 延迟从平时的 80ms 一路飙到 700ms网关层出现大量 504。用户侧更直接——直播间购物车转圈、下单按钮点了没反应群里骂声一片。主链路一挂连带库存扣减超时、优惠券领取失败促销服务、用户服务一起跟着遭殃。我们当时第一反应是流量超预期扩容但机器加了四台情况只缓解了一点点根本问题完全没解决。后来我们把链路逐段拆开看才发现真正的瓶颈藏在大家都默认没问题的那一层序列化。订单服务返回给下游和网关的对象结构非常深一层套一层——订单基本信息、用户 address、商品 list、sku 属性、优惠券快照、直播间配置、物流估算。这些对象在 Java 里就是普通的 POJO经过 Jackson 序列化成 JSON 字符串再抛给下一个服务下一个服务收到后再反序列化成一个 DTO。就这么一次对象 → 字符串 → 对象的循环在高峰期每秒上万次请求、单个响应体膨胀到几十 KB 的场景下变成了压垮 CPU 和带宽的最后一根稻草。这个事故最终的解药是内部服务间通信从 JSON 全面切到 Protobuf网关对外保留 JSON 适配层。整体效果非常直接——响应体体积平均缩小 85%CPU 占用降一半p99 延迟回落到了 96ms。这篇文章不是教科书就是把我从这次宕机里摸出来的东西完整写一遍JSON 和 Protobuf 在物理资源层面的真实差距、迁移过程的落地细节、还有那些文档里不会告诉你的坑。如果你也在维护高并发的微服务链路这篇文章值得花十分钟看完。2. 序列化不只是换一种格式它决定的是 CPU、内存和带宽三笔账很多人觉得 JSON 和 Protobuf 的区别就是一个是人能读的文本一个是压缩的二进制然后默认差不了多少。真到物理资源层面看差别是数量级的。2.1 它们到底分别是怎么工作的JSON 是文本协议序列化过程是把对象字段名、字符串值、数字、嵌套结构转换成可读字符串。整个过程分成几步遍历对象属性、反射获取字段名、把字符串转义、拼装 JSON 语法符号大括号、冒号、逗号、引号。反序列化更重——要逐字符解析字符串、识别 token、动态创建对象、按字段名做映射。Protobuf 是二进制协议核心机制是 tag-len-value字段编号 长度 值的组合。它不需要传输字段名每个字段在 .proto 文件里定义好编号序列化时只写编号和内容数字用 varint 变长编码小的整数只占 1 个字节负数走 zigzag 编码嵌套 message 直接拼二进制。代价是必须提前定义 IDL接口描述语言服务端和客户端共享同一套 schema。用一句话概括JSON 用可读性和灵活性换资源Protobuf 用约定和不可读的二进制换速度与体积。在跨国内部链路里可读性除了调试方便其他时候根本产生不了业务价值。2.2 物理层面钱到底花在哪里我习惯把序列化开销拆成三笔账排查性能问题的时候特别有用第一笔是 CPU。JSON 的序列化和反序列化是典型的 CPU 密集型操作。Jackson 在 1MB 数据上做 parse 的时间大约是 Protobuf 的 8 到 15 倍。原因很简单JSON 要做字符扫描、状态机转移、字符串拷贝、反射字段匹配Protobuf 是直接按字节流查表字段编号到类型的对应关系在生成代码里已经是写死的逻辑几乎没有字符串匹配。第二笔是内存与 GC。JSON 序列化时需要构建字符串缓冲区字符串对象本身又比二进制数据占用更多堆内存。高并发下频繁创建和丢弃大字符串/byte 数组短命对象填满年轻代YGC 频率直线上升。我们事后看监控事故时段订单服务的 Young GC 次数是平时的 5 倍单次 GC 停顿 30~50ms在多线程处理请求的同时反复世界暂停这本身就是延迟的隐形杀手。Protobuf 是定长/变长字节直接写流对象复用做得好的话GC 压力小很多。第三笔是带宽。这是最容易被忽略、也是最物理的一笔。跨境链路本来 RTT 就高东南亚到我们国内机房大概 60~100ms每多传 1KB 数据就等于在 RTT 基数上额外增加排队和传输时间。一条订单详情接口的完整 JSON 响应在未压缩时能做到 45KB 左右涉及大量嵌套、list、枚举文本同样的内容用 Protobuf 只有不到 7KB。同样的带宽上限JSON 时代扛 2000 QPS 就到天花板Protobuf 时代能扛 8000 以上。如果你现在还在用 JSON 做内部高频 RPC可以先跑一下这两个维度单条最大消息的体积对比、服务端 CPU 的序列化占比。大概率会得到和我一样的结论——序列化根本不是什么无所谓的小细节它就是微服务物理极限的一部分。3. 从 JSON 切换到 Protobuf 的完整落地过程其实大多数团队不是不想换 Protobuf而是不知道从哪里下手。这里我把我们迁移的全过程拆成四步每一步都有明确的产出和执行要点可以直接参考。3.1 第一步统一 .proto 文件仓库和版本管理Protobuf 最核心的不是代码而是 schema。建议直接建一个独立仓库管理所有 .proto 文件命名带上模块和版本例如order/api/v1/order.proto、promo/api/v1/coupon.proto。用 git tag 管理版本任何改动都要走 MR 评审。.proto 文件写起来非常简单下面就是一个典型订单消息的定义syntax proto3; package order.api.v1; import common/base.proto; option java_package com.example.order.proto; option java_outer_classname OrderProto; option go_package order/api/v1;orderpb; message OrderInfo { string order_id 1; string user_id 2; int64 shop_id 3; int64 create_time 4; int64 pay_time 5; int32 order_status 6; int64 total_cent 7; repeated OrderItem items 8; mapstring, string ext_info 9; } message OrderItem { string sku_id 1; string sku_name 2; int32 quantity 3; int64 price_cent 4; repeated string tag 5; }几个细节值得注意字段类型不要用浮点数表示金额一律用int64的分时间字段用int64存 Unix 毫秒不要传字符串枚举和 boolean 能用就尽量用能省大量字节。3.2 第二步生成代码与接入依赖Java 后端用 Maven 或 Gradle引入protobuf-java和protoc插件。如果团队规模大、proto 文件多强烈建议引入 Bufbuf.build做格式检查、依赖管理和生成动作它比裸的 protoc 好维护太多了。生成之后每个服务只需要在 pom 里依赖那个 jar/模块。你的业务代码里直接用生成的类OrderProto.OrderInfo info OrderProto.OrderInfo.newBuilder() .setOrderId(202601010001) .setUserId(u_10086) .addItems(OrderProto.OrderItem.newBuilder() .setSkuId(SPU8888) .setQuantity(2) .setPriceCent(9900)) .build();注意几个容易踩的点生成的类是不可变的Builder模式不能直接set字段字段访问走 getter反复构建时尽量复用 Builder 实例减少对象分配。3.3 第三步改造服务间调用所有内部 RPC 走 Protobuf这一步不需要重写业务逻辑只需要替换传输层。原来走 HTTP Jackson 的内部调用改成直接发送二进制byte[]HTTP header 里加Content-Type: application/x-protobuf。如果用的是 Dubbo/gRPC直接把序列化协议从 fastjson/hessian 切到 Protobuf 即可。这里要特别强调一点切内部不代表要做掉对外 JSON。App 端、前端、第三方开放平台还是要 JSON因为这些场景要的是可读性、兼容性和通用性——你不能要求浏览器里的 fetch 去解 Protobuf。正确布局是网关对外 REST → 网关内把 JSON 转成 pb 消息 → 内部服务之间全部走 pb格式转换统一收敛在网关层业务服务不感知。3.4 第四步金丝雀灰度与兼容性验证任何序列化协议的切换最怕的就是老服务还没升级完、新服务已经切过去了。我们的做法是先升级 provider 侧服务端保留 JSON 和 pb 双序列化入口再升级 consumer 侧调用方的客户端分集群灰度灰度比例按 1% → 10% → 50% → 100% 推进。每一次推进都观察错误率、超时率、GC 指标。Protobuf 的二进制兼容性设计得不错只要遵守字段编号一旦分配就永不修改新增字段只追加编号老客户端和新客户端就能互相通信。老客户端收到新字段会把它当作 unknown field 保留下次序列化时原样传走不丢数据。这是二进制协议比 JSON 强的一个隐蔽优势——JSON 加了字段老客户端解析时要么忽略要么报错行为反而不统一。4. 实测数据同样的业务物理消耗差了多少这一节直接上数据。我们在预发环境用压测工具模拟了高峰流量压测条件1000 并发持续 15 分钟模拟真实订单查询链路请求内聚了订单、商品、促销、用户四层数据聚合。业务内容完全相同只改变内部传输格式。指标JSON未压缩JSONgzipProtobuf单条响应体大小46 KB12 KB6.8 KB服务端 CPU 平均占用78%83%34%p99 延迟含跨境链路336 ms354 ms96 msYoung GC 次数 / 15min18742106420单机 QPS 上限8126402750表格里的数字是真实压测结果有几个点值得展开。体积差距不是线性是数量级。同样的业务对象Protobuf 只有 JSON 的 1/7 左右。原因不复杂JSON 每个字段都要带名字字符串 name 字段本身就是苍蝇肉加上嵌套层级越多括号引号逗号的固定成本越离谱。而 Protobuf 里字段名在传输时根本不存在键全部用 tag 编号替代数字编码还自带压缩。这就是物理级的差别不是换了个更聪明的压缩算法是压根少传了绝大部分字节。CPU 的差距可能比你想象的还大。有人会反问JSON 加 gzip 压缩后体积也不大是不是就够了答案是否定的。gzip 的压缩是 CPU 换带宽压测中 JSONgzip 的 CPU 占用反而比裸 JSON 更高。这是因为压缩算法本身消耗算力而且解压环节同样要消耗 CPU。Protobuf 不需要压缩就已经比 gzip 后的 JSON 小体积差距直接转化为 CPU 的释放。GC 是最容易被低估的受益方。切换后 Young GC 次数降到了原来的 1/4。直接带来的好处是 GC 停顿变少关键请求被世界暂停打断的概率显著降低。对于追求 p99 稳定的系统来说这个收益比降低 CPU 占用还要重要。记得把监控大屏的告警维度也更新一下从接口延迟、错误率扩展为承诺字节数、序列化 CPU 占比、GC 暂停分布。这三种指标才是判断序列化状态直接信号。不看这些你永远不知道沉默成本藏在哪。5. 迁移中真正会要命的四个细节网上关于 Protobuf 的示例大多是 HelloWorld 级别真正生产环境迁移下面几个问题几乎必踩。5.1 字段编号冻结这件事没有后悔药.proto里每个字段都有编号一旦上线绝对不要复用和修改。举例来说message UserInfo { string user_id 1; string mobile 2; // tag 2 已发布 }假如某天你觉得 mobile 不需要了把它的 tag 2 拿出来换成 email部署之后新老版本服务同时运行老服务把一个值写进 tag 2新服务把这个值当 email 解析——整条链路的脏数据排查会让你怀疑人生。正确做法是废弃字段用reserved保留永远不再占用这个编号。message UserInfo { reserved 2; reserved mobile; string user_id 1; string email 3; }5.2 网关适配层别急着删JSON 是外部世界的接口语言我把服务间通信改成 pb 之后有同事建议反正都是我们自己系统干脆对外也返回 pb。我强烈不建议这么干。原因有三一是 App 端、Web 端调试和 mock 依赖肉眼可读的数据结构pb 对前端完全是黑盒二是对外开放接口的风控、审计、日志都需要文本可查三是第三方接入方的技术栈五花八门你不可能要求每个合作方都维护一套 .proto。维持网关 JSON 适配层的成本很低收益却很高。你只需要在网关里做一次JsonFormat.printer().print(message)或者反向 parse这部分性能损耗相比于整条链路来说微乎其微。5.3 外层再加一层压缩往往是负优化我们最初切到 Protobuf 之后觉得既然流量这么贵再套一层 gzip 压一压好了。实际压测发现7KB 的 pb 数据压缩到 5KB确实省了 2KB但 CPU 直接增加了 8% 的压缩消耗。对于大部分场景Protobuf 自带的紧凑编码已经足够加压缩属于赔本买卖只有单条消息超过 100KB 或者链路带宽极度瓶颈时才有必要考虑压缩且必须用压缩感知缓冲区如SnappyOutputStream而不是全文压缩避免一次性把整个消息载入内存。5.4 线上 debug 手段要提前准备好JSON 时代大家习惯curl一把梭拿到响应就能看。切了 pb 之后线上排查立刻遇到障碍——curl返回是一堆乱码。我这里分享三个实用的调试方式用grpcurl配合.proto文件直接调接口输出自动格式化为 JSON 展示日常联调效率很高用protoc --decode_raw解析未知二进制的原始 tag 结构当年我没带 schema 文件时就用这招定位字段抓包工具Wireshark 的 protobuf 解析提前配置好 schema排查跨服务调用问题时直观很多。如果你不想让团队一调试就找后端要 schema 文件可以在内部工具链里顺带维护一个自动同步 proto 仓库的脚本按服务名/版本一键拉取对应文件。这个依赖路径打通之后整个研发团队的调试体验基本能回到 JSON 时代水平。6. 工具没有好坏只有场景匹配问题这次事故让我彻底不再迷信某种格式是银弹。在合适的地方用合适的协议比技术本身的优劣更重要。我的通用选型建议是这样的使用场景推荐格式理由对外 REST APIJSON调试方便、通用性最好、生态完整内部 RPC 主链路Protobuf体积小、速度快、强类型约束、跨语言兼容事件消息/消息队列Protobuf生产消费双方共享 schema支持演进配置文件JSON/YAML可读性优先修改频率低离线数仓存储JSON/Parquet列式存储与 JSON 文本可交互性更好有人会问那内部链路里所有服务都必须上 Protobuf 吗我的经验是凡是请求量超过每秒几百次、消息体超过 10KB、或者处于高延迟跨地域链路上的内部调用都值得换。低频管理接口、内部后台查询继续用 JSON 完全没问题还能省去维护 proto 的成本。换完之后我还做了一件事把序列化格式的决策纳入链路设计评审。新接口在架构设计阶段就要明确内外协议分离的方案——对外暴露什么格式、内部走什么格式、网关适配层怎么划分。只要这一步不再凭惯性决定宕机的概率会小很多。最后补一个运维层面的建议序列化协议的切换不要和大促活动压在同一天发布哪怕你对自己的压测数据很有信心。最好选择一个业务低峰期窗口灰度链路全部走完、监控观察满 24 小时之后再评估是否全量。那次大促宕机给我们的教训很深——在高并发场景下技术选型的错误会被放大成生死级别的问题而反过来一个正确的选型也可以让系统在同样的物理资源下多扛好几倍的流量。