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_typefield 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) | Hessian2 | Protobuf |
|---|---|---|---|
| 是否传字段名 | 是,每个对象都传 | 首次传,后续引用 | 否,传数字编号 |
| 需要 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 μs | 12.7 μs | 3184 B | 148 |
| Jackson + Afterburner | 6.11 μs | 9.3 μs | 3184 B | 121 |
| Hessian2 | 11.8 μs | 15.2 μs | 1926 B | 2103 |
| Protobuf | 2.31 μs | 3.04 μs | 1208 B | 34 |
几个值得注意的地方:
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 几十,收益覆盖不了迁移成本 |
| 对外开放 API | JSON | 可读、调试方便、生态无门槛 |
| 前后端交互 | JSON | 同上,浏览器原生支持 |
| 消息队列(埋点、日志) | Protobuf + Zstd | 量大,体积就是钱 |
| 消息队列(业务事件) | JSON | 需要人工排查、需要被多方消费,可读性优先 |
| 缓存 value | Protobuf 或 JSON | 看 value 大小,超过 2KB 用 Protobuf |
| 配置文件 / 元数据 | JSON / YAML | 人要看要改 |
核心链路迁移后的实际数据(大促压测,QPS 3200):
| 指标 | Hessian2 | Protobuf | 变化 |
|---|---|---|---|
| 平均报文大小 | 3.2 KB | 1.2 KB | -62.5% |
| 序列化 CPU 占比 | 18.3% | 5.1% | -72% |
| Young GC 频率 | 180 次/分 | 47 次/分 | -74% |
| 接口 P99 | 210 ms | 68 ms | -67.6% |
| 单机峰值 QPS | 3200 | 5800 | +81% |
| 内网带宽占用 | 4.1 Gbps | 1.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,还是拆成多个细粒度接口?评论区聊聊你的做法。