尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

报文小了 62%,兼容性问题却修了 3 周:Protobuf、JSON、Hessian 的 5 笔真实账 title: 报文小了 62%兼容性问题却修了 3 周Protobuf、JSON、Hessian 的 5 笔真实账tags: 序列化,Protobuf,Hessian,JSON,RPCcategory: 后端从一次 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 number1、2、3、4。long 用 varint 编码1234567890123 需要 6 字节。boolean 占 1 字节。Protobuf 的两个关键设计Tag-Length-Value 结构tag 里塞了两个信息tag (field_number 3) | wire_typefield number 1、wire_type 0varinttag 就是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%。三者的完整对比维度JSONJacksonHessian2Protobuf是否传字段名是每个对象都传首次传后续引用否传数字编号需要 IDL否否是.proto跨语言极好一般Java 为主极好可读性好差差循环引用不支持会栈溢出支持不支持字段增删兼容好较好好有规则字段改名破坏兼容破坏兼容无影响只看编号反射依赖有可优化重度依赖无生成代码实测数字不同数据形状下差异很大我用三种典型数据形状做了压测JDK 17.0.9JMH单线程protobuf-java 3.25.1hessian 4.0.66jackson 2.16.0BenchmarkMode(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 B148Jackson Afterburner6.11 μs9.3 μs3184 B121Hessian211.8 μs15.2 μs1926 B2103Protobuf2.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 的兼容性完全建立在字段编号永不复用上。删掉一个字段后如果新字段复用了它的编号老版本客户端会把新数据按老类型解析结果是静默的数据错乱——不报错但值是错的。规范做法是用reservedmessage 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()); // 坑 1protobuf 的 string 字段不能设 null会抛 NullPointerException if (pojo.getUserName() ! null) { b.setUserName(pojo.getUserName()); } // 坑 2BigDecimal 没有对应类型我们统一转成分的 int64 if (pojo.getAmount() ! null) { b.setAmountCent(pojo.getAmount().movePointRight(2).longValueExact()); } // 坑 3Date/LocalDateTime 也没有原生类型用 epoch millis if (pojo.getCreateTime() ! null) { b.setCreateTimeMs(pojo.getCreateTime().toInstant(ZoneOffset.UTC).toEpochMilli()); } // 坑 4List 不能 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精度完全保留体积大解析慢容易忘记校验格式自定义 messageunscaled scale精确且紧凑转换代码多跨语言时对端要实现同样逻辑我们最后是混合的金额类字段用 int64 存分汇率、费率这种高精度的用自定义 messagemessage Decimal { sint64 unscaled_value 1; // 无标度值用 sint64 因为可能为负 int32 scale 2; // 小数位数 } // BigDecimal(123.45) - { unscaled_value: 12345, scale: 2 }我们最终的技术选型迁移完成后整个公司的序列化选型收敛成这样场景选择理由内部核心链路 RPC高 QPSProtobuf体积、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%单机峰值 QPS3200580081%内网带宽占用4.1 Gbps1.6 Gbps-61%我的取舍判断Protobuf 不是更好的序列化是用开发体验换运行时性能的交易。你要付出的是写 IDL、管编号、维护转换层、失去可读性、调试时要用工具解码。这些成本是持续的不是一次性的。QPS 不上千、报文不上 KB 的服务我不建议迁——你省下的那点 CPU 还不够开会讨论的工时。Hessian2 我认为已经到了该退休的阶段。它的定位是不用 IDL 的二进制序列化但这个定位现在很尴尬要性能就上 Protobuf要方便就用 JSONJackson 性能已经比它好了。它唯一还站得住的场景是老 Dubbo 系统的存量兼容。新项目我不会选它。这个判断可能有人不同意欢迎讨论。JSON 被低估了。很多人觉得 JSON 慢、大、low但 Jackson Afterburner 的性能已经很不错而它带来的可调试性是无价的。线上出问题时能直接从日志里读懂报文这在半夜排查时能省掉大量时间。我现在的默认选择是 JSON只有在压测数据证明序列化是瓶颈时才换。关于跨语言如果你的链路里有 Go、Python、Node几乎没得选只能 Protobuf 或 JSON。Hessian 的非 Java 实现质量参差不齐我们试过 Go 的 hessian 库在处理嵌套泛型集合时直接崩了。最后一个经验做序列化迁移前先用 profiler 确认它真的是瓶颈。我见过团队花两个月换成 Protobuf结果性能只提升 4%因为他们真正的瓶颈在数据库连接池。序列化优化在性能优化清单里通常排在数据库、缓存、网络之后。留个问题如果你的接口返回一个字段数很多比如 200 个但大部分请求只用其中 10 个的大对象你会怎么优化Protobuf 3 不传默认值这个特性在这里其实很有用——不需要的字段不设值就不占字节。但这要求上游知道下游需要什么。你会用 FieldMask还是拆成多个细粒度接口评论区聊聊你的做法。
返回列表