从 Hessian 迁移到 protostuff:深嵌套模型反序列化耗时与高并发类加载锁的根治实战
一段 Redis 缓存序列化的迁移史。两个看似无关的症状------深嵌套 POJO 模型的反序列化耗时大且抖动、一次版本错位引发的高并发类加载锁雪崩打满线程池------根因是同一个设计选择:Hessian 把"说明书"写进了字节流。本文从两家源码机制讲起,解释为什么换成 protostuff 后两个症状同时消失、我们为此交了什么学费,以及 Seata 与 sofa-hessian 为同类问题打的补丁、补不到的地方,最后回到两种序列化设计哲学的分野。
先澄清一个名词,避免后文混淆。我们最终落地的是 protostuff,不是 Google protobuf------尽管团队口语里一直说"换成 protobuf"。 protostuff 是一个 Java 序列化库:wire format(线上字节格式)与 Google protobuf 兼容,但不需要写 .proto、不需要 protoc 代码生成,运行时用反射为普通 POJO 生成 Schema。Google protobuf 是"IDL + 编译期 codegen",protostuff 是"普通 POJO 直接序列化成 protobuf 字节"。本文机制讨论基于 protostuff 1.2.x 与 Hessian 4.0.x 源码。
技术词典
后文反复出现的术语,先用大白话说透:
- Schema(字段表):一张"这个类有哪些字段、每个字段编号是多少、读到值后怎么写回对象"的登记表;protostuff 靠反射生成一次并在进程内缓存,之后每次读写都只是查表。
- 自描述 vs schema 驱动:两种序列化流派。自描述 = 把类型说明(类名、字段名字符串)写进字节里,读方不需要任何先验知识(Hessian);schema 驱动 = 类型说明放在两端代码里,字节里只有字段编号(protostuff、protobuf)。
- wire format(线上字节格式):序列化后的字节在传输和存储中的排布格式。
- varint(变长整数编码):用 1 至 5 个字节表示一个整数的编码方式,小数字少占字节;每个字节的最高位当"后面还有没有"的标志位。
- tag(字段标签):protobuf 系格式里每个值前面的小头部,内容是"字段编号 + 值的编码类型";读方靠它知道"这是几号字段的值、该按什么方式解码"。
- 组界(group):protobuf 系格式给嵌套对象的字节包的一对"开始/结束"标记,作用像一对括号;读方即使不认识里面的内容,也能找到配对的右括号把整块跳过。
- 类定义块:Hessian 字节里的一段"类型说明"------类名字符串加全部字段名字符串;同一段字节里同一类型只写一次,之后用编号引用。
- 反序列器(Deserializer):Hessian 里每个类型对应的读取组件,负责"创建对象 + 按字段名匹配并写回";按类名查找、在工厂内缓存。
- SoftReference(软引用):JVM 内存不足时会被垃圾回收的一种引用;它指向的缓存一旦被回收,下次使用时就要整个重建。
- BLOCKED / monitor:monitor 即锁监视器;线程抢不到别人持有的锁时进入 BLOCKED 状态------不耗 CPU,但一步都走不了。
- golden bytes(黄金字节测试):把样例对象序列化后的字节固定进测试资源;任何会改变线上格式的改动都会让测试失败。
- ArchUnit:在单元测试里检查代码结构规则的库(如"这个包下的类必须加某注解"),接入 CI 自动拦截。
S --- Situation:一个存在了很久的隐患
系统背景很典型:业务服务读写 Redis,value 是业务缓存模型。这些模型的最大特征是对象图深 ------一个模型里内嵌 POJO,POJO 里再嵌 POJO,随着业务演进越套越多。模型通过高 QPS 的读接口 对外提供服务:每次 GET 都要做一次完整反序列化,单次反序列化成本直接乘以调用量------序列化层的每一个多余动作都在被流量放大。序列化框架用的是 Hessian。
然后出现了两个症状。
症状一(性能):内嵌 POJO 变多之后,Hessian 反序列化耗时明显上涨,且不稳定,成为接口毛刺的来源之一。
症状二(事故级) :一次版本错位后,字节流里出现了读方 classpath 里不存在的类型,又恰逢高并发------几十个请求线程同时阻塞在类加载相关代码上,服务线程池被打满,无法继续对外提供服务。事后看 thread dump,形态很有辨识度:CPU 几乎空闲、GC 平静,但大量线程 BLOCKED 在同一个 monitor,持锁线程停在 Class.forName 的 native 帧里------服务"安静地死了"。
这两个症状当时看毫无关联,事后看是同一个根。先用一张图把事故二的发生链路钉住:
这条链路里最毒的一环是"失败结果不进缓存":如果第一个线程加载成功 ,结果会进缓存,羊群瞬间散去,事故只是一次冷启动抖动;但失败不缓存,于是每个排队线程轮到时都要重新走一遍完整类加载、再抛一次异常。队列不是并行排空,是 N 倍时长串行排空------而上游还在按高并发速率继续投递。
T --- Task:编号目标与约束
- 降耗:深嵌套模型的反序列化耗时要降下来,且要平稳------不能随数据结构复杂度抖动;
- 除锁:彻底消除每次 GET 反序列化过程中的类加载与全局锁;
- 兼容存量:Redis 里的存量数据必须可平滑读取,不能停服清缓存;
- 控制迁移成本:团队多年 Hessian 习惯,改动要控制在序列化层。
约束里第 4 条后来被证明是最大的学费来源------往下看。
A --- Action:四步走,外加一个经典的坑
第一步:把两家的机制读透------先回答"说明书放在哪"
Hessian 的做法(自描述) :类型信息直接写进字节流。写对象时,先写一个类定义块(C 标记):类名字符串、字段数、逐个字段名字符串;之后同流内的实例只写值(按类定义里的顺序),并用索引引用类定义。读的时候:解析类定义 → 按类名加载 Class → 反射构建"字段名 → 反射写入器"的匹配表 → 按流里的字段名逐个查表写入。循环引用与共享引用用 R 标记加索引支持,这是它对 Java 对象模型覆盖完整的根基。
java
// Hessian 反序列化的关键调用链(源码摘录,Hessian 4.0.x)
// Hessian2Input.readObject → case 'C' → readObjectDefinition:
factory.getObjectDeserializer(type, null);
// → SerializerFactory.getDeserializer(type)
// → loadSerializedClass(type)
// → ClassFactory.load: Class.forName(className, false, loader) ← 加载前置
// 字段匹配(JavaDeserializer.readMap):
// FieldDeserializer2 deser = _fieldMap.get(key); ← 按名匹配
// if (deser != null) deser.deserialize(in, obj); else in.readObject(); ← 不认识则丢弃
protostuff 的做法(schema 驱动) :字节流里没有任何类型名字符串,只有"字段编号 + 值"。第一次用到某个类时,运行时反射生成一张 Schema(字段表:编号、反射 Field、该类型的读写逻辑),缓存在进程级 ConcurrentHashMap 里。之后每次读写都是纯查表:
java
// protostuff 反序列化主循环(MappedSchema.mergeFrom,源码摘录)
for (int number = input.readFieldNumber(this); number != 0;
number = input.readFieldNumber(this)) { // 循环由数据驱动
Field<T> field = number < fieldsByNumber.length
? fieldsByNumber[number] : null; // 数组直达,无字符串无反射查找
if (field == null)
input.handleUnknownField(number, this); // 未知字段:按 wireType 纯字节跳过
else
field.mergeFrom(input, message); // varint 解码后 f.set 直写
}
每个值在线上的形态是 tag 加 payload 两段:tag 的数值等于"字段编号左移 3 位,再按位或上 wireType";整数用 varint 压缩。字段编号来自声明顺序(无注解时)或 @Tag 注解显式指定。
两家的差异浓缩成一张表:
| 维度 | Hessian(自描述) | protostuff(schema 驱动) |
|---|---|---|
| 类型元数据位置 | 字节流里(类名+字段名字符串) | 两端代码里(Schema) |
| 字段匹配方式 | 字符串按名匹配 + 反射写入 | 编号查数组 + 直写 |
| 类加载 | 每读一个 key,字节里的新类型都可能触发 | 零(仅多态字段按需且受开关控制) |
| 兼容契约的"键" | 字段名(改名即断,增删重排安全) | 字段编号(改名安全,编号错位致命) |
| 未知数据的处理 | 必须先加载类才能跳过;类缺失则退化 | 按 wireType 跳字节,类不存在也无所谓 |
两条反序列化路径"每读一个 key 各要做什么"的对比图(这也是两个症状的共同答案,下一步展开):
第二步:为什么换成 protostuff,两个症状一起消失了
先算症状一的细账。 放大器是读接口的高 QPS:缓存里存的是序列化字节,每次 GET 都是一次完整反序列化,单次成本 × 调用量 = 序列化层持续烧掉的 CPU。拿一个三层嵌套的简化模型把两边的账单算细(4 个类型、42 个对象、约 100 个字段值):
java
class CacheModel { User user; List<Order> orders; } // orders 有 20 个
class Order { long orderId; int status; Sku sku; }
class Sku { long skuId; String title; int price; }
先走一遍 Hessian 读这个 key 的完整流程(后面所有成本项都挂在这条线上):
redis.get拿到字节数组,new Hessian2Input------内部类定义登记表为空,本次从零学起;- 读到第一个
C类定义块:把类名、字段名字符串逐个 UTF-8 解码并分配 String 对象; - 拿类名到共享工厂查反序列器:进入
synchronized缓存锁------命中则复用;未命中则Class.forName加载类、反射遍历全部字段构建"字段名到写入器"匹配表,再持锁写回缓存(进程首见或软引用被回收后走这条路); - 把流里的字段名逐个与本地反射字段表按名匹配(字符串 hash 加逐字符比较),生成"流字段到写入器"的绑定数组,登记为本段字节内部的类定义编号;
- 读实例标记,反射调用构造器创建对象,登记进对象引用表;
- 按绑定数组逐字段读值写回:标量直接解码;嵌套对象回到第 2 步递归------同类型的第二个起直接复用第 4 步的绑定数组,不再解析字符串;
- 整棵树展开完:4 个类型、42 次反射构造、约 100 次反射字段写入;
close()------读取器销毁,类定义登记表清零,全部解析记忆归零;下一个 key 从第 1 步完全重来。
Hessian 每读一个 key 反序列化时要做的事------即使所有缓存全部命中 :解析 4 个类定义块 (15 个字符串:4 个类名加 11 个字段名的 UTF-8 解码与分配,即上面第 2 步);4 次反序列器查找 (每次过 synchronized 缓存锁加字符串 hash 比较,即第 3 步);约 100 次反射字段写入 (第 5、6 步);42 次反射构造(第 5、6 步);外加读一个更大的字节数组(名字字符串本身占体积,Redis 传输时间同步变长)。
这份账单还能再往里拆一层。八步流程的动作按成本性质归并,是四段流水线:① 解码字符串 (字节还原成 String)、② 查表 (拿类名查共享工厂)、③ 绑定 (流字段名对上本地写入器)、④ 构造写回(实例化对象、逐字段写值),依次对应八步里的第 2 步、第 3 步、第 4 步、第 5、6 步。② 按缓存全命中的最好情况计,逐段看每段内部在烧什么:
① 解码字符串:三个动作。 读长度前缀定边界 → 按 UTF-8 逐字节拼出字符 → 分配 String 对象。第 2 步的 15 个名字全走这道工序------4 个类名是 ② 的钥匙、11 个字段名是 ③ 的原料;第 6 步里每个字符串字段值还要再各走一轮,本例是 20 个 title。
② 查表:双层成本。 拿 ① 产出的类名当钥匙:算字符串 hash → 进 synchronized 块 → HashMap 定位、逐字符 equals → 拿到缓存的反序列器,4 个类型各来一轮。无争抢时是百纳秒量级的轻量锁;几十个线程同时进,锁膨胀加 park 排队,一次排队微秒起步、直进 P99。至于未命中分支(类加载、重建反射表、失败不缓存),那是症状二已经演示过的另一个量级。
③ 绑定:每个读取器都重做。 ① 产出的 11 个字段名逐个到本地反射字段表里 hash 加 equals,各自找到反射写入器,再分配一份绑定数组把"流里的字段"与"本地写入器"钉在一起。这份绑定挂在读取器实例上、进程内不共享------每个 key 的每个类型都要重新来一遍。
④ 构造写回:不变量所在。 反射调用构造器创建对象、登记引用表,逐字段读值写回:标量解码后反射写入,嵌套对象递归回 ②。42 次构造、约 100 次写回全在这一段。
四段的成本类型与去留,一张表说清:
| 段 | CPU | 锁 | 内存分配 | 换成 protostuff 后 |
|---|---|---|---|---|
| ① 解码字符串(15 个名字) | UTF-8 逐字节解码 | --- | 15 个 String | 整段删除:字节里没有名字可解 |
| ② 查表(4 个类型) | hash 加 equals | synchronized 共 4 次 | --- | 整段删除:字段表数组直达,读不加锁 |
| ③ 绑定(11 个字段名) | 逐名 hash 加 equals | --- | 4 份绑定数组 | 整段删除:绑定在 Schema 里建一次、进程级共享 |
| ④ 构造写回(42 构造、约 100 写回) | 值解码(字符串值复用 ① 的工序)加反射调用 | --- | 42 个对象 | 保留:建对象、填值任何框架都要付;反射写换成 Unsafe 直写 |
| (四段之外)字节体积 | 传输与内存拷贝随字节数线性 | --- | 更大的响应缓冲 | 类定义块的体积整体省掉:传输时间、带宽、Redis 输出缓冲同步变小 |
①②③ 的存在理由只有一个------字节里写了名字,这就是"自描述税"的全部税目;④ 是不变量,谁来反序列化都要建对象、填值。
均值高的账记在 ①③④ 上,操作量随类型数 × 字段数线性放大;毛刺的账一半记在 ② 上(锁排队是常态项、未命中分支是事故项),一半记在 ①③④ 的分配压力上(推高 GC)------正是下文"三个长尾来源"的由来。字节体积则是四段之外的第五笔:它同时贡献均值(每次 GET 搬运的字节更少)与平稳(传输窗口更短,撞上网络抖动与 Redis 输出缓冲压力的概率更低),量级上属第二梯队------平稳的主要来源仍是 ② 的锁与未命中分支、①③ 的字符串垃圾被结构性消除。
其中最关键的一条:Redis 里每个 key 存的是一段互不相干的字节数组。Hessian 有个省钱机制------类名、字段名这些"类型说明"在同一段字节里只写一次、只解析一次,之后同类型用编号引用。在一个 value 内部它是生效的,比如模型里的 20 个 Order:
text
C "com.demo.Order" 3 "orderId" "status" "sku" ← 类定义:本段字节中只出现这一次
o 0 1001 2 【sku 的值是嵌套对象,展开略】 ← 第 1 个订单:引用 0 号定义,只写值
o 0 1002 2 【展开略】 ← 第 2 个订单同上......共 20 个
但这份"记忆"存在读取器实例的内部列表里,本次 GET 结束、实例销毁,记忆清零。下一个 key 的字节里,序列化端早已把同样的类定义原样又抄了一遍------引用编号只能指向本段字节自己登记过的定义,一段孤立字节必须自包含,这正是"自描述"的定义本身。于是读方只能重头再学一遍:
text
GET key=A → C "com.demo.Order" 3 "orderId" "status" "sku" o 0 ...(20 个订单)
GET key=B → C "com.demo.Order" 3 "orderId" "status" "sku" o 0 ...(一模一样的字符串,全部重新解析)
三个时点拉平对比(动作沿用前文 ①②③④ 的分段):
| 动作 | 第 1 个 Order | 第 2 至 20 个 Order | 下一个 value 的第 1 个 Order |
|---|---|---|---|
| ① 解码类名与字段名字符串 | ✅ 做(4 个字符串) | ❌ 不做 | ✅ 重做 |
| ② 拿类名查共享工厂(过锁) | ✅ 做 1 次 | ❌ 不做 | ✅ 重做(命中,不走类加载,但锁、hash、equals 一个不少) |
| ③ 字段名按名绑定写入器 | ✅ 做(3 个字段名逐名查找) | ❌ 不做,直接复用绑定数组 | ✅ 重做 |
| ④ 反射构造 + 读值 + 写回 | ✅ 做 | ✅ 做 | ✅ 做(不变量,本来就必须每次做) |
第 1 列与第 3 列几乎逐行相同------这就是缓存全命中、每个 key 还是慢的直观形态:② 的共享缓存命中只免掉了最重的未命中分支(类加载与建表),它自身的锁、hash、equals 每个 key 照付;①③ 根本不在任何共享缓存里,随读取器实例每个 key 生灭一次;④ 是所有框架共同的不变量。摊销的边界就到本段字节为止------20 个订单里真正省下的,只有中间 19 次的 ①②③。
所以那个模型里 4 个类型、15 个字符串的"每 key 固定税",每个 key 都重新解析一遍,假设 1 万个 key 就是 15 万次字符串解码,永远省不掉 。而 42 次对象构造是任何框架都省不掉的不变量成本;其余各项"自描述税"全部随类型数 × 字段数线性放大------嵌套越深税基越大,这就是内嵌 POJO 越多、Hessian 越慢的机理。对照之下,protostuff 的"类定义"等价物(Schema)按类型在进程里存一份、永不随读取销毁,字节里根本没有类定义可抄------这正是上一张对比图里"0 个字符串解析"的含义。
除了均值,"不稳定"另有三个长尾来源:高 QPS 下几十个读线程同时过那两把 synchronized(反序列化缓存锁、默认工厂查找的静态锁),排队直接进 P99;每次 GET 的字符串垃圾 × QPS 推高 Young GC 频率;以及一个隐藏最深的------Hessian 默认工厂按 ClassLoader 用 SoftReference 缓存 (createDefault()),内存压力下软引用被回收,下一个请求要全量重建反射字段表(getDeclaredFields 遍历加逐字段建表),表现为偶发、难复现、与内存压力相关而与代码路径无关的耗时尖刺------测试环境压不出来,线上内存紧张时才冒头。
同一份模型换 protostuff 来读,流程对称地短(与上面 Hessian 的八步对照着看):
redis.get拿到字节数组------比 Hessian 的短,因为没有类定义块;没有登记表要从零填起,直接开始读值;- 进程内首次用到某个类时(比如 Order):
RuntimeSchema.getSchema反射生成字段表(编号、Field、读写器),放进进程级 ConcurrentHashMap------这一步确实有锁,但每个类型整个进程生命周期只发生一次,与请求量无关;以下按进程已热、直接拿现成表继续; - 读 CacheModel 层:读 1 至 2 字节的 varint tag,字段编号 = tag 右移 3 位,
fieldsByNumber数组下标直达读写器,按 wireType 读值; - 读 orders 集合:同一个字段号连续出现 20 次,每来一个 tag 就是下一个元素------没有个数前缀,也没有元素类型说明;
- 进每个 Order:又是 tag → 数组 → 值;遇到 sku 就递归换一张进程内现成的 Sku 表------"换一层对象"只是换一张表:没有字符串解析、没有锁、没有类加载;
- 读字符串值(20 个 title):varint 长度加 UTF-8 解码------① 工序里"值"的那半边,两边共同支付;
- 42 次构造对象,每个值一次直写(Sun JRE 默认 Unsafe 按偏移写,无反射调用);
- 读到结束标记(tag 为 0)循环退出。没有记忆需要清零------从头到尾没为"类型说明"记过任何东西;下一个 key 从第 1 步重来,重来的只有数据本身,没有"重新学类型"的固定成本。
protostuff 读同一个 key 的账单:0 个字符串解析 (字节里只有 1 至 2 个字节的数字 tag,没有任何名字);0 次反序列器查找 (schema 按类在进程内缓存于 ConcurrentHashMap,读取不加锁,字段读写器经 fieldsByNumber 数组直达);约 100 次 varint 解码加字段直写(Sun JRE 默认走 Unsafe 按偏移写,无反射);42 次构造(与 Hessian 打平);字节数也更小------省的是每个 key 重复携带的类定义块,值本身的编码两家都紧凑(Hessian 实例块的值甚至不带 tag),所以体积差主要就是名字那部分:payload 越小、类型越多,比例差距越大,深嵌套小对象缓存恰好是差距最大的形态。两边的差就是"自描述税"全额免除,且税基不再随模型复杂度增长------不管模型嵌套多深,每读一个 key 的成本基本恒定,这同时解释了"耗时变小"与"平稳";在高 QPS 读接口下,这份收益再乘以调用量。
再说"意外"治好的症状二。把事故场景还原成最小例子:写方先升级,Order 按纪律在尾部追加了 @Tag(4) 的 coupon 字段,值是新类 Coupon 的对象;读方还是老版本------类上没有 4 号字段,classpath 里也没有 Coupon 这个类。
protostuff 这边,那个 key 的字节骨架长这样(嵌套对象用组界包住):
text
08 96 01 ← 字段 1(orderId):tag 08,varint 值 150
10 02 ← 字段 2(status):tag 10,varint 值 2
1B [sku 的内容] 1C ← 字段 3(sku):1B 组开始,1C 组结束
23 [coupon 的内容] 24 ← 字段 4(coupon):23 组开始,24 组结束
读方走到 23:fieldsByNumber 数组只到 3 号,4 号位是空 → 走未知字段分支 → 按 wireType"组"跳字节,循环读到配对的 24 为止,继续读下一个 tag。varint 靠续位自界定、长度前缀式靠 varint 长度、嵌套靠组界------跳过一整块未知数据,全程不需要 Coupon 这个类存在于读方。
同样的错位落在 Hessian 上,coupon 的值之前是一段类定义块,读方处理这段值的第一件事就是拿类名过工厂查反序列器:
text
C "com.demo.Coupon" 1 "amount" ← 类定义块:解析出类名是处理这段值的第一步
Class.forName("com.demo.Coupon") → 读方没有 → 抛异常
异常不进缓存 → 下一个请求重付全款(S 节的雪崩链路)
这里还叠着第二层问题:类加载失败的结果不进缓存,这笔无用的加载每个请求都要重付。工厂源码的收尾长这样(Hessian 4.0.60,SerializerFactory.getDeserializer):
java
// 前面:查缓存未命中(过 synchronized 锁)后走到这里
try {
Class cl = loadSerializedClass(type); // 内部 Class.forName:再试一次加载
deserializer = getDeserializer(cl);
} catch (Exception e) {
log.warning(type + " is an unknown class"); // 失败:只打一条 warning,deserializer 保持 null
}
if (deserializer != null) { // 只有非 null 才回写缓存
synchronized (_cachedTypeDeserializerMap) {
_cachedTypeDeserializerMap.put(type, deserializer);
}
}
return deserializer; // 失败路径返回 null,缓存里什么都没留下
三层含义:缓存只记成功 ------异常被捕获吞掉、deserializer 保持 null,回写被那个 if 跳过,"com.demo.Coupon" 这个键下次依然未命中;JVM 也不记失败 ------Class.forName 不会把 ClassNotFoundException 存成结果,每次调用都从头再走一遍查找,构造异常本身还带抓栈;失败另有副产品 ------每次尝试都打一条 warning 日志,高 QPS 下日志跟着刷屏;拿到 null 后调用方退回一个共享的 HashMap 反序列器,把这个对象读成 Map------静默退化,Seata 一节会回到这一点。叠加起来的效果是:一次性的版本错位,被"失败不缓存"变成了永久性的每请求重付------不止 S 节排队时的每个线程,排空之后的每一个后续请求、每一个含 Coupon 的 key,都要完整重跑锁、查找、类加载、异常、日志这一整套。
区别不在"谁跳得快",在资格:Hessian 的读取逻辑按"类定义 → 字段名匹配 → 写回"组织,它连"跳过"这个动作都要先完成类加载才有资格做------加载被设计成了前置步骤;protostuff 的读取逻辑按"编号 → 字节区间"组织,不认识的编号就是一个可以纯字节越过的区间。这就是同一场景下一家安静跳过、一家锁雪崩的全部差别。
⚠️ 踩坑一:测试环境反序列化失败------契约的责任悄悄换了主人
迁移初期,我们的缓存模型照着官方 README 的示例写成了裸 POJO------没有加任何 @Tag 注解 。不久之后,测试环境出现反序列化失败。根因很快定位:有同学按 Hessian 时代的习惯,在类中间插了一个新字段。
protostuff 在不写 @Tag 注解时,字段编号 = 声明顺序。类中间插入一个字段,后面所有字段的编号整体加一,存量字节按新编号表解读,全部错位。错位有两种结局:老数据的 varint 落到新表的 String 字段上,readString 把 varint 当长度,越界抛异常------这是报错型,其实算幸运 ;更危险的是 wire type 碰巧兼容的情况(比如 varint 落到数字字段、走 packed 兼容路径),数据是错的但不报错 ,静默错值,缓存里悄悄躺垃圾------报错型的 case 在测试环境拦住了,静默型的只会在线上 ambush。
坑的另一半来自文档:protostuff 官方 README 的示例就是裸 POJO,不加 @Tag。示例追求最小可运行,而"无注解模式"成立的前提------两端同版本、字段只增不改、只在尾部追加------没有前置成警告。作者写给新项目用户,读者却是从 Hessian 迁来的"难民",旧习惯(按名匹配、字段加哪都行)直接平移成了事故。
整改动作:
- 存量类 retrofit :
@Tag要求全有全无,允许把所有现存字段按声明顺序标上它们当前的隐式编号(1、2、3......)。编号不变,Redis 存量数据无缝可读,之后新增字段全部显式编号; - 纪律工具化 :ArchUnit 规则强制所有缓存模型类全员
@Tag,CI 拦截;删字段一律@Deprecated占号(protostuff 生成 schema 时跳过该字段但保留编号占位),物理删除在 code review 打回;字段类型不可改; - golden bytes 契约测试:每个模型类序列化一份样例对象,字节 hex 固化进测试资源。任何导致线上格式变化的改动(编号、类型、删除)测试秒红。
教训一句话:从自描述迁到 schema 驱动,"契约管理"的职责从序列化库转移到了团队------红利在切换当天到账,账单在第一次字段演进时寄到。
第三步:生态早把这条路上过的坑走了------Seata 与 sofa-hessian 的补丁史
我们的问题不是孤例,两个开源项目给出了两档不同力度的答案。
Seata(换模型档) 。它的 seata-serializer-hessian 模块做了三层递进:第一层,饿汉单例 SerializerFactory------Hessian 默认工厂按 ClassLoader 用 SoftReference 缓存,内存压力下会被回收,回收后下一个请求就要重建全部反射字段表(这正是偶发长尾的隐藏来源之一),显式单例彻底消除了这一不确定性;第二层,白名单------利用 Hessian ClassFactory 的判断顺序(名单判断在 Class.forName 之前 ),把名单外类型的加载直接短路成零;第三层也是终局:序列化器整个标记 @Deprecated,默认切换为自研 codec------每个协议消息类一个手写编解码器,线上字节里只有 type code。流里没有类名字符串,攻击面与类加载锁是结构性消失,不靠名单过滤。Deny 到的类型也不会像 Hessian 那样静默退化成 HashMap------对事务协调器来说,一条提交请求悄悄变成 Map 是正确性毒药,宁可失败。
sofa-hessian(打补丁档) 。蚂蚁维护的 fork 把反序列化缓存从 HashMap + synchronized 换成了 ConcurrentHashMap(读路径无锁,热点锁消失),加了自研的类名黑白名单体系、JDK8 日期类型预注册、默认启用 Unsafe 序列化。但有两个病灶没动:查找失败仍然不缓存 (类不存在时每个请求重新付加载失败的全款),拦截后仍然静默退化。
补不到的地方不是能力问题,是模型问题:"流里带类名"是自描述的前提,每次反序列化就都必须包含"名字到 Class"的解析步骤;"不认识也尽量读下去"是它的容错哲学,静默降级就是这哲学的产物。补丁能治症状的排列组合,治不了前提本身。 官方线也印证了这一点------Caucho 的 Hessian 停留在 4.0.66(2022 年),此后无功能演进;历史上唯一一次结构性回应(4.0.51 前后引入 ClassFactory 白名单)是被反序列化漏洞浪潮逼出来的安全补丁,不是性能补丁。它的原始世界观是 Resin 同版本两端通信,类型集合封闭且必然存在------"流里有类名而 classpath 没有"在它看来是部署事故,不是需要优化的场景。
第四步:把经验抽象成一张选型图
两个流派的所有差异,都可以还原成一个问题:这份字节要不要随身携带自己的说明书。前面的叙述容易读成"Hessian 全是坑"------收尾前把账拉平:它为"带说明书"付出的每一分性能,都换回了实打实的能力,而且每一样都能在本文的故事里找到反面证据:
- 零契约管理成本:不写 IDL、不加注解、不建兼容测试。对照我们的整改三件套(@Tag 纪律、ArchUnit、golden bytes)------那是在 protostuff 侧把"契约管理"这份保险重新买回来;用 Hessian 时,这份保险是框架送的;
- 按名兼容的演进自由:字段插在类中间、删除、重排顺序都安全------踩坑一里那套"字段加哪都行"的习惯能平安沿用多年,靠的就是它;代价是改名即断(字段名就是兼容键);
- 任意对象图:循环引用、共享引用(R 标记)原生支持,继承与字段多态天然工作;protostuff 普通模式遇到环无法终止,要专门的 Graph 模式,多态类型还要显式注册;
- 读方零先验、可泛化:任何拿到字节的进程,不依赖任何代码就能读出内容------网关透传、脚本消费、跨团队任意载荷都成立,这也是 Dubbo 默认序列化选 hessian2 的原因之一。
不带说明书(protostuff/protobuf/Seata codec)换来的则是:最小体积、每次调用成本最低、结构性零类加载------本文两个症状的消失,就是这三条的兑现。所以选型不是判优劣,是判断"读方零先验"这份保险你用不用得上:两端都归你管、消息集合封闭时,它永远用不上(我们的 Redis 缓存正是如此);载荷开放、两端无法同步演进时,它是刚需。按场景决策:
第五步:沉淀一套 protostuff 使用最佳实践
踩过的坑和读过的源码,最终沉淀成四个维度的清单。
字段编号(最重要,90% 的坑在这里)
- 第一天就全员
@Tag:注解全有全无(混用直接抛异常);存量无注解类按声明顺序补当前隐式编号(1、2、3......),编号不变即与存量数据无缝兼容; - 编号只追加、不复用 :删除字段用
@Tag(n) @Deprecated占号(schema 生成时跳过读写器但保留编号);@Exclude留给"从不参与序列化"的字段------注意它和@Deprecated语义相反,不占编号; - 字段类型永不可改 :改类型等于新编号 + 旧号
@Deprecated,没有第三条路; - 若坚持无注解模式:只允许尾部追加 、只允许删除末尾字段;且单类 pojo 字段数不超过 126------编号 127 被保留给多态类型 ID。
模型设计
- 字段声明类型尽量用具体类型或 final 类;声明为
Object、接口、抽象类会走多态路径------每次写多一段类名字符串,读侧还可能触发类加载; - 有循环引用、共享引用的对象图必须用
GraphIOUtil(普通模式遇到环无法终止);无此需求的模型保持树状,收益最大; - 所有字段都是 optional 语义:null 不写、缺省保持默认值,
isInitialized恒为 true------别在业务上依赖"必填校验",要校验就在反序列化后自己校; - 集合字段把元素类型钉死(声明成元素为具体 Item 类型的列表,而不是 Object 列表),避免逐元素写类型 ID。
性能
LinkedBuffer线程内复用:allocate 一次、每次序列化后clear(),不要在请求路径上反复分配;- Schema 交给
RuntimeSchema.getSchema()的进程级缓存,绝不要 在请求路径上调createFrom; - 格式选择:内部封闭系统用
ProtostuffIOUtil(group 定界、支持流式写出与 Graph 模式);只有需要与标准 Google protobuf 生态互操作时才用ProtobufIOUtil。
安全与测试
- 面向不可信来源的数据:关闭
protostuff.runtime.auto_load_polymorphic_classes,改用RuntimeSchema.register/RuntimeSchema.map显式注册允许的多态类型------多态字段是 protostuff 里唯一的按名加载点; - golden bytes 契约测试 + ArchUnit 强制
@Tag(整改动作里已落地); - 兼容性做双向验证:新代码读旧字节、旧代码读新字节,各跑一遍样例。
一个"标准姿势"的模型骨架:
java
public class CacheModel {
@Tag(1) private long id; // 全员显式编号
@Tag(2) private String bizNo;
@Tag(3) private List<Item> items; // 嵌套 POJO 同样全员 @Tag
@Tag(4) @Deprecated private String legacy; // 删除字段:占号不删号
@Tag(5) private int status;
@Exclude private MetricsCollector collector; // 从不序列化:不占号
}
示意:编号全显式、删除占号、排除不占号------这三条纪律覆盖了绝大多数线上事故形态。
R --- Result:验证与沉淀
| 验证项 | 迁移前(Hessian) | 迁移后(protostuff) | 结论 |
|---|---|---|---|
| 深嵌套模型反序列化耗时 | 随嵌套深度上涨且抖动 | 显著下降,且与嵌套深度基本解耦 | ✅ 达标 |
| 高并发版本错位场景 | 类加载锁串行化,线程池打满 | 反序列化全程零类加载,未知字段直接按字节跳过 | ✅ 根治 |
| 存量 Redis 数据 | --- | 按隐式编号补 @Tag 后无缝读取 |
✅ 兼容 |
| 字段演进自由度 | 随意插入字段 | 必须遵守编号纪律,靠 ArchUnit 与 golden bytes 兜底 | ⚠️ 新增约束 |
复盘沉淀的三条原则
- 封闭系统优先 schema 驱动:两端都归你管、消息集合有限时,自描述的保费每请求都在交,它保的险(读方无需先验知识)永远不会发生;
- 迁移序列化框架时,把"兼容键的切换"写进团队规范:从 Hessian 到 protostuff 是从"认字段名"切到"认字段编号",旧习惯(字段插哪都行)必须显式清零,用工具(CI 规则)而不是口头传承;
- 契约必须有测试兜底:golden bytes 把线上格式锁进版本库,任何破坏兼容的改动在 CI 就死掉------这比任何文档都硬。
遗留事项
- 若未来出现泛化调用、跨团队任意载荷的场景(如通用网关透传),schema 驱动方案需要额外的 schema registry 或切自描述格式,当前不在演进路线上;
- 持续关注 Fury(Apache 孵化)------它试图同时拿到自描述的对象图语义和 schema 驱动的性能,是这个方向上最新的工程答案;
- 存量类
@Tagretrofit 分批进行中,golden bytes 测试已覆盖核心缓存模型。
本文涉及的内部系统与业务模型已做通用化处理,代码片段为开源框架源码摘录或脱敏后的示意实现。