1. 事故现场:主播还在喊"3、2,1",后台已经一片 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; map<string, 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 文件多,强烈建议引入 Buf(buf.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(未压缩) | JSON(gzip) | Protobuf |
|---|---|---|---|
| 单条响应体大小 | 46 KB | 12 KB | 6.8 KB |
| 服务端 CPU 平均占用 | 78% | 83% | 34% |
| p99 延迟(含跨境链路) | 336 ms | 354 ms | 96 ms |
| Young GC 次数 / 15min | 1874 | 2106 | 420 |
| 单机 QPS 上限 | 812 | 640 | 2750 |
表格里的数字是真实压测结果,有几个点值得展开。
体积差距不是线性,是数量级。同样的业务对象,Protobuf 只有 JSON 的 1/7 左右。原因不复杂:JSON 每个字段都要带名字,字符串 name 字段本身就是"苍蝇肉",加上嵌套层级越多,括号引号逗号的固定成本越离谱。而 Protobuf 里字段名在传输时根本不存在,键全部用 tag 编号替代,数字编码还自带压缩。这就是"物理级"的差别,不是换了个更聪明的压缩算法,是压根少传了绝大部分字节。
CPU 的差距可能比你想象的还大。有人会反问:JSON 加 gzip 压缩后,体积也不大,是不是就够了?答案是否定的。gzip 的压缩是 CPU 换带宽,压测中 JSON+gzip 的 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 API | JSON | 调试方便、通用性最好、生态完整 |
| 内部 RPC 主链路 | Protobuf | 体积小、速度快、强类型约束、跨语言兼容 |
| 事件消息/消息队列 | Protobuf | 生产消费双方共享 schema,支持演进 |
| 配置文件 | JSON/YAML | 可读性优先,修改频率低 |
| 离线数仓存储 | JSON/Parquet | 列式存储与 JSON 文本可交互性更好 |
有人会问:那内部链路里所有服务都必须上 Protobuf 吗?我的经验是:凡是请求量超过每秒几百次、消息体超过 10KB、或者处于高延迟跨地域链路上的内部调用,都值得换。低频管理接口、内部后台查询,继续用 JSON 完全没问题,还能省去维护 proto 的成本。
换完之后,我还做了一件事:把序列化格式的决策纳入"链路设计评审"。新接口在架构设计阶段就要明确"内外协议分离"的方案——对外暴露什么格式、内部走什么格式、网关适配层怎么划分。只要这一步不再凭惯性决定,宕机的概率会小很多。
最后补一个运维层面的建议:序列化协议的切换不要和大促活动压在同一天发布,哪怕你对自己的压测数据很有信心。最好选择一个业务低峰期窗口,灰度链路全部走完、监控观察满 24 小时之后,再评估是否全量。那次大促宕机给我们的教训很深——在高并发场景下,技术选型的错误会被放大成"生死级别"的问题,而反过来,一个正确的选型,也可以让系统在同样的物理资源下多扛好几倍的流量。