news 2026/8/7 17:58:06

报文小了 62%,兼容性问题却修了 3 周:Protobuf、JSON、Hessian 的 5 笔真实账

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
报文小了 62%,兼容性问题却修了 3 周:Protobuf、JSON、Hessian 的 5 笔真实账

title: 报文小了 62%,兼容性问题却修了 3 周:Protobuf、JSON、Hessian 的 5 笔真实账
tags: 序列化,Protobuf,Hessian,JSON,RPC
category: 后端


从一次 GC 异常说起

我们的商品聚合服务有个接口,一次请求要调 7 个下游 RPC,把结果拼成一个大对象返回。上线两年,一直用 Dubbo 默认的 Hessian2 序列化。

问题出在一次大促前的压测。QPS 压到 3200 的时候,服务开始频繁 Young GC——每分钟 180 次,每次 25-40ms。P99 从 45ms 涨到 210ms。堆是 8G,年轻代 3G,按理说不该这么频繁。

用 async-profiler 抓了一次内存分配火焰图,结果很直白:

com.alibaba.com.caucho.hessian.io.Hessian2Output.writeObject 38.2% com.alibaba.com.caucho.hessian.io.Hessian2Input.readObject 21.7% java.util.HashMap.<init> 11.3%

序列化和反序列化占了 60% 的对象分配。

再细看,Hessian2 在序列化一个对象时会做这些事:为每个对象创建ObjectDefinition、为字段名创建字符串引用表、为集合类型创建临时 List。我们那个返回对象嵌套了 4 层,含 3 个 List,每个 List 里 20-50 个元素,单次序列化产生的临时对象超过 2000 个。

于是就有了那次序列化框架的迁移。三个月后回头看,收益是真的,坑也是真的。

三种格式在字节层面到底差在哪

先把最本质的东西说清楚:序列化格式的体积差异,主要来自"要不要带字段名"和"数字怎么编码"。

拿一个简单对象举例:

public class Order { private long orderId = 1234567890123L; private int status = 3; private String userName = "zhangsan"; private boolean paid = true; }

JSON{"orderId":1234567890123,"status":3,"userName":"zhangsan","paid":true}):74 字节。字段名占了 34 字节(46%),数字用十进制文本表示,1234567890123占 13 字节。

Hessian2:约 42 字节。字段名只在第一次出现时写完整,后续同类型对象用引用(这就是它的 class definition 机制)。long 用二进制,1234567890123 占 9 字节(1 字节标识 + 8 字节值)。

Protobuf:约 21 字节。字段名完全不传,只传 field number(1、2、3、4)。long 用 varint 编码,1234567890123 需要 6 字节。boolean 占 1 字节。

Protobuf 的两个关键设计:

Tag-Length-Value 结构,tag 里塞了两个信息

tag = (field_number << 3) | wire_type

field number 1、wire_type 0(varint),tag 就是0x08,一个字节。这意味着 field number 1-15 的字段 tag 只占 1 字节,16-2047 占 2 字节。所以 proto 定义里,高频字段要占用 1-15 的编号,这是个很实用但常被忽略的优化点。

Varint 变长整数编码:每字节用 7 位存数据,最高位标识"还有没有后续字节"。小数字省空间,但负数是灾难——-1会被当成0xFFFFFFFFFFFFFFFF处理,占满 10 字节。这就是为什么 protobuf 提供了sint32/sint64,它们用 ZigZag 编码把负数映射成正数:

ZigZag(n) = (n << 1) ^ (n >> 31) // 32 位 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3, 2 -> 4 ...

我们踩过这个坑:有个字段存的是"库存变化量",可正可负,用了int64。线上发现这个字段的报文比预期大很多,一查是负数占了 30%,每个都是 10 字节。改成sint64之后单条消息小了 8%。

三者的完整对比:

维度JSON(Jackson)Hessian2Protobuf
是否传字段名是,每个对象都传首次传,后续引用否,传数字编号
需要 IDL是(.proto)
跨语言极好一般(Java 为主)极好
可读性
循环引用不支持(会栈溢出)支持不支持
字段增删兼容较好好(有规则)
字段改名破坏兼容破坏兼容无影响(只看编号)
反射依赖有(可优化)重度依赖无(生成代码)

实测数字:不同数据形状下差异很大

我用三种典型数据形状做了压测(JDK 17.0.9,JMH,单线程,protobuf-java 3.25.1,hessian 4.0.66,jackson 2.16.0):

@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Thread) @Warmup(iterations = 5, time = 2) @Measurement(iterations = 10, time = 2) @Fork(2) public class SerializationBench { private OrderDetail pojo; // 普通 POJO private OrderDetailProto protoObj; // protobuf 生成的对象 private ObjectMapper mapper; private SerializerFactory hessianFactory; @Setup public void setup() { pojo = buildSampleOrder(); protoObj = buildSampleOrderProto(); mapper = new ObjectMapper(); // afterburner 模块用字节码生成替代反射,Jackson 性能能提升 20-30% mapper.registerModule(new AfterburnerModule()); hessianFactory = new SerializerFactory(); } @Benchmark public byte[] jacksonSerialize() throws Exception { return mapper.writeValueAsBytes(pojo); } @Benchmark public byte[] hessianSerialize() throws Exception { ByteArrayOutputStream bos = new ByteArrayOutputStream(512); Hessian2Output out = new Hessian2Output(bos); out.setSerializerFactory(hessianFactory); out.writeObject(pojo); // 这一行不能少,Hessian2Output 内部有 buffer,不 flush 会丢数据 out.flushBuffer(); return bos.toByteArray(); } @Benchmark public byte[] protobufSerialize() { // 生成代码里的 toByteArray 会先算一遍 size 再一次性分配,没有扩容拷贝 return protoObj.toByteArray(); } }

跑出来的结果(对象含 3 层嵌套、1 个 30 元素的 List、共 42 个字段):

框架序列化耗时反序列化耗时字节大小单次分配对象数
Jackson(默认)8.42 μs12.7 μs3184 B148
Jackson + Afterburner6.11 μs9.3 μs3184 B121
Hessian211.8 μs15.2 μs1926 B2103
Protobuf2.31 μs3.04 μs1208 B34

几个值得注意的地方:

Hessian2 的分配对象数是 protobuf 的 62 倍。这就是我们那次 GC 问题的根源。Hessian 为了支持任意 Java 对象(不需要 IDL),必须重度依赖反射,每次都要构建字段元信息、创建 Field 数组、装箱基本类型。虽然它有缓存,但每次调用仍然产生大量临时对象。

Jackson 比 Hessian 快,这个结果让当时团队里不少人意外。大家的直觉是"二进制肯定比文本快",但 Jackson 经过多年优化,有专门的字符串写入路径、有 UTF-8 快速编码、有 afterburner 字节码增强。而 Hessian2 从 2008 年之后基本没大改过。

Protobuf 快的根本原因不是格式,是它不用反射。protoc 生成的 Java 代码里,每个字段的读写都是硬编码的:

// protoc 生成代码片段(简化) public void writeTo(com.google.protobuf.CodedOutputStream output) throws IOException { if (orderId_ != 0L) { output.writeInt64(1, orderId_); // 直接调用,没有反射,JIT 可以内联 } if (status_ != 0) { output.writeInt32(2, status_); } if (!getUserNameBytes().isEmpty()) { com.google.protobuf.GeneratedMessageV3.writeString(output, 3, userName_); } unknownFields.writeTo(output); // 未知字段原样写回,这是兼容性的关键 } public int getSerializedSize() { int size = memoizedSize; if (size != -1) return size; // 缓存,同一对象多次序列化不重算 size = 0; if (orderId_ != 0L) { size += com.google.protobuf.CodedOutputStream.computeInt64Size(1, orderId_); } // ... 其余字段 memoizedSize = size; return size; }

注意if (orderId_ != 0L)这个判断——protobuf 3 不序列化默认值字段。这是省空间的重要手段,但也是一个语义陷阱:你无法区分"这个字段没设置"和"这个字段被显式设为 0"。我们有个"折扣率"字段,0 是合法业务值(不打折),结果这个字段在传输中消失了,下游收到的是默认值 0,恰好业务上也解释成 0,侥幸没出事。后来统一改成用google.protobuf.Int32Value包装类型,或者加一个has_discount布尔字段。

unknownFields.writeTo(output)这行是兼容性的核心:当新版本加了字段,老版本客户端解析时认不出,会把这些字节存进 unknownFields,转发时原样写回。这让 A→B→C 的链路上,即使 B 是老版本,也不会丢掉 A 传给 C 的新字段。Hessian 和 JSON 在这一点上做不到(Jackson 默认会直接丢弃未知字段)。

迁移过程中真正花时间的三件事

体积和性能的收益是立竿见影的,报文平均小了 62%,序列化 CPU 占比从 18% 降到 5%。但真正的成本在别处。

第一件:字段编号管理。

Protobuf 的兼容性完全建立在"字段编号永不复用"上。删掉一个字段后,如果新字段复用了它的编号,老版本客户端会把新数据按老类型解析,结果是静默的数据错乱——不报错,但值是错的。

规范做法是用reserved

message OrderDetail { reserved 4, 7, 12 to 15; // 这些编号永久不能再用 reserved "old_price", "legacy_flag"; // 这些字段名也不能再用 int64 order_id = 1; int32 status = 2; string user_name = 3; // 4 号是删掉的 old_price,不能再用 int64 create_time = 5; // ... int32 new_field = 16; // 新字段从没用过的编号开始 }

我们的问题是有 40 多个 message,分散在 6 个仓库,早期没人管这个规范。做迁移时发现有 3 处编号复用,全靠人工比对 git 历史找出来的。后来强制要求所有 .proto 放在一个独立仓库,加了 CI 检查(用buf breaking对比上一个版本),才算管住。

第二件:Java POJO 和 Protobuf Message 之间的转换。

Protobuf 生成的类是 immutable 的,要用 Builder 构造,跟业务代码里满地的 setter POJO 格格不入。我们不可能把所有业务代码都改成用 protobuf 对象(那样就跟传输层绑死了),只能写转换层。

public final class OrderConverter { public static OrderDetailProto toProto(OrderDetail pojo) { OrderDetailProto.Builder b = OrderDetailProto.newBuilder() .setOrderId(pojo.getOrderId()) .setStatus(pojo.getStatus()); // 坑 1:protobuf 的 string 字段不能设 null,会抛 NullPointerException if (pojo.getUserName() != null) { b.setUserName(pojo.getUserName()); } // 坑 2:BigDecimal 没有对应类型,我们统一转成"分"的 int64 if (pojo.getAmount() != null) { b.setAmountCent(pojo.getAmount().movePointRight(2).longValueExact()); } // 坑 3:Date/LocalDateTime 也没有原生类型,用 epoch millis if (pojo.getCreateTime() != null) { b.setCreateTimeMs(pojo.getCreateTime().toInstant(ZoneOffset.UTC).toEpochMilli()); } // 坑 4:List 不能 addAll(null) if (pojo.getItems() != null) { for (OrderItem item : pojo.getItems()) { b.addItems(toProto(item)); } } return b.build(); } public static OrderDetail fromProto(OrderDetailProto p) { OrderDetail pojo = new OrderDetail(); pojo.setOrderId(p.getOrderId()); pojo.setStatus(p.getStatus()); // 反向转换时,空字符串要不要还原成 null?这个决定要全团队统一 pojo.setUserName(p.getUserName().isEmpty() ? null : p.getUserName()); pojo.setAmount(BigDecimal.valueOf(p.getAmountCent()).movePointLeft(2)); return pojo; } }

这段转换代码,40 个 message 写下来大概 3000 行。我们试过用 MapStruct 自动生成,但 protobuf 的 Builder 模式和空值语义让映射规则很难统一,最后还是手写 + 单元测试覆盖。

这 3000 行转换代码,是整个迁移里最没有技术含量但最耗时的部分,占了总工时的一半。如果重来一次,我会先做一个内部约定:所有需要走 RPC 的 DTO 从设计之初就用 protobuf 定义,业务对象和 DTO 严格分离,而不是等到迁移时再补。

第三件:BigDecimal 和金额精度。

Protobuf 没有 decimal 类型。常见的三种做法:

方案优点缺点
转成 int64 存"分"简单、精确、体积小超过 2 位小数的场景不适用(比如汇率、单价)
存 string,两端 new BigDecimal精度完全保留体积大,解析慢,容易忘记校验格式
自定义 message(unscaled + scale)精确且紧凑转换代码多,跨语言时对端要实现同样逻辑

我们最后是混合的:金额类字段用 int64 存分;汇率、费率这种高精度的用自定义 message:

message Decimal { sint64 unscaled_value = 1; // 无标度值,用 sint64 因为可能为负 int32 scale = 2; // 小数位数 } // BigDecimal("123.45") -> { unscaled_value: 12345, scale: 2 }

我们最终的技术选型

迁移完成后,整个公司的序列化选型收敛成这样:

场景选择理由
内部核心链路 RPC(高 QPS)Protobuf体积、CPU、GC 全面占优,值得付 IDL 的成本
内部低频 RPC / 管理接口Hessian2(保持原样)QPS 几十,收益覆盖不了迁移成本
对外开放 APIJSON可读、调试方便、生态无门槛
前后端交互JSON同上,浏览器原生支持
消息队列(埋点、日志)Protobuf + Zstd量大,体积就是钱
消息队列(业务事件)JSON需要人工排查、需要被多方消费,可读性优先
缓存 valueProtobuf 或 JSON看 value 大小,超过 2KB 用 Protobuf
配置文件 / 元数据JSON / YAML人要看要改

核心链路迁移后的实际数据(大促压测,QPS 3200):

指标Hessian2Protobuf变化
平均报文大小3.2 KB1.2 KB-62.5%
序列化 CPU 占比18.3%5.1%-72%
Young GC 频率180 次/分47 次/分-74%
接口 P99210 ms68 ms-67.6%
单机峰值 QPS32005800+81%
内网带宽占用4.1 Gbps1.6 Gbps-61%

我的取舍判断

Protobuf 不是"更好的序列化",是"用开发体验换运行时性能"的交易。你要付出的是:写 IDL、管编号、维护转换层、失去可读性、调试时要用工具解码。这些成本是持续的,不是一次性的。QPS 不上千、报文不上 KB 的服务,我不建议迁——你省下的那点 CPU 还不够开会讨论的工时。

Hessian2 我认为已经到了该退休的阶段。它的定位是"不用 IDL 的二进制序列化",但这个定位现在很尴尬:要性能就上 Protobuf,要方便就用 JSON(Jackson 性能已经比它好了)。它唯一还站得住的场景是老 Dubbo 系统的存量兼容。新项目我不会选它。这个判断可能有人不同意,欢迎讨论。

JSON 被低估了。很多人觉得 JSON "慢、大、low",但 Jackson + Afterburner 的性能已经很不错,而它带来的可调试性是无价的。线上出问题时能直接从日志里读懂报文,这在半夜排查时能省掉大量时间。我现在的默认选择是 JSON,只有在压测数据证明序列化是瓶颈时才换。

关于跨语言:如果你的链路里有 Go、Python、Node,几乎没得选,只能 Protobuf 或 JSON。Hessian 的非 Java 实现质量参差不齐,我们试过 Go 的 hessian 库,在处理嵌套泛型集合时直接崩了。

最后一个经验:做序列化迁移前,先用 profiler 确认它真的是瓶颈。我见过团队花两个月换成 Protobuf,结果性能只提升 4%,因为他们真正的瓶颈在数据库连接池。序列化优化在性能优化清单里通常排在数据库、缓存、网络之后。

留个问题

如果你的接口返回一个字段数很多(比如 200 个)但大部分请求只用其中 10 个的大对象,你会怎么优化?

Protobuf 3 不传默认值这个特性在这里其实很有用——不需要的字段不设值就不占字节。但这要求上游知道下游需要什么。你会用 FieldMask,还是拆成多个细粒度接口?评论区聊聊你的做法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/7 17:55:48

《居家办公效率提升远程协作 线上高并发排障实战》

《居家办公效率提升远程协作 线上高并发排障实战》 作者: 白泠钰 (Bi Lng Y) (泠不丁)技术方向: AI 生活化应用、AI 情感陪伴、AI 创意生成工具 &#x1f4a1; 导语与现场排障背景 在最近一次线上压测复盘中&#xff0c;我们的 AI 智能服务集群触发了 P99 延迟陡增告警。基于 …

作者头像 李华
网站建设 2026/8/7 17:55:38

Mortar依赖注入揭秘:Fx框架使用技巧与最佳实践

Mortar依赖注入揭秘&#xff1a;Fx框架使用技巧与最佳实践 【免费下载链接】mortar Mortar is a GO framework/library for building gRPC (and REST) web services. 项目地址: https://gitcode.com/gh_mirrors/mortar2/mortar Mortar是一个基于Go语言的框架/库&#xf…

作者头像 李华
网站建设 2026/8/7 17:55:16

《Ethereum/Solana 生态 DeFi 协议分析 线上高并发排障实战》

《Ethereum/Solana 生态 DeFi 协议分析 线上高并发排障实战》 作者: 欧阳瑞 (Rich Own) (瑞瑞)技术方向: AI Web3 应用、智能合约辅助开发、去中心化 AI 产品、全栈 AI 原型构建 &#x1f4a1; 导语与现场排障背景 在生产环境重构 Ethereum/Solana 生态与 DeFi 协议分析 时&a…

作者头像 李华
网站建设 2026/8/7 17:54:45

Table Transformer终极指南:如何从文档中智能提取表格数据

Table Transformer终极指南&#xff1a;如何从文档中智能提取表格数据 【免费下载链接】table-transformer Table Transformer (TATR) is a deep learning model for extracting tables from unstructured documents (PDFs and images). This is also the official repository …

作者头像 李华
网站建设 2026/8/7 17:53:42

5分钟实现Windows系统macOS鼠标指针终极美化方案

5分钟实现Windows系统macOS鼠标指针终极美化方案 【免费下载链接】macOS-cursors-for-Windows Tested in Windows 10 & 11, 4K (125%, 150%, 200%). With 2 versions, 2 types and 3 different sizes! 项目地址: https://gitcode.com/gh_mirrors/ma/macOS-cursors-for-Wi…

作者头像 李华
网站建设 2026/8/7 17:53:16

CmzPrep_Rev2 | 免U盘重装!

链接: https://pan.baidu.com/s/1u6UVoiSMfqLDnPWHOH2XwA 提取码: 916wCmzPrep 是一款免费的 Windows 系统重装与部署工具&#xff0c;支持 WIM、ESD、ISO 等多种映像格式&#xff0c;兼容 Windows 7 至 11。它采用轻量 PE 环境&#xff0c;提供快速重装和保留数据功能&#xf…

作者头像 李华