1. 问题现场:一次让我印象极深的线上故障
做服务端开发的朋友,特别是搞过微服务、天天跟RPC打交道的人,十有八九都遇到过序列化相关的坑。在众多序列化协议里,Hessian 算是比较“有性格”的一个:它简单、高效、跨语言支持也不错,但在某些 Java 类型处理上,确实留下了不少坑。
其中流传最广、踩的人最多的一个,就是Hessian 在 RPC 接口调用时不能正确传输 BigDecimal。
先说说我自己的经历。某公司内部有一套订单相关的微服务系统,结算服务负责算账,下游的订单服务提供订单数据。两个服务之间走的正是 Hessian 序列化协议。某天结算服务在跑线上数据核对任务时,突然发现一批订单的金额对不上账,差额还不小——有的差几毛,有的差几十块,甚至有个订单直接凭空多了几百块。
第一反应当然是去查业务 SQL,以为是数据库里存的金额有问题。查了半天,数据库里的 BigDecimal 一分钱没错。于是把矛头转向 RPC 层,在结算服务收到的响应对象里打了日志。结果日志一打出来,真相浮出水面:
// 服务提供方实际返回的金额 BigDecimal amount = new BigDecimal("199.99"); // 服务消费方实际收到的金额 log.info("received amount: {}", receivedAmount); // 输出:received amount: double: 199.99更离谱的是,有些字段反序列化完直接变成了 String 类型,类型转换报错,服务直接接口报错。明明是同一个接口、同一个 Java 对象,用本地方法调用没问题,只要是走 Hessian 调远程服务,就出幺蛾子。
当时我排查这个问题的过程,可以用“一层一层剥洋葱”来形容。先说结论:Hessian 对 BigDecimal 的处理策略在不同版本、不同编码模式下并不一致。它既不把 BigDecimal 当基本类型像 int 那样原样传输,也不像普通实现了 Serializable 的对象那样走标准的 Java 序列化流程,而是有一套自己的“类型猜测”机制。这个机制在大多数情况下很智能,但在 BigDecimal 这里恰恰猜错了。
2. 深入拆解:Hessian 序列化原理与 BigDecimal 的“错位”
2.1 Hessian 的紧凑类型系统
要理解这个问题,得先搞清楚 Hessian 的序列化机制是怎么回事。Hessian 是一种基于二进制的序列化协议,目标是“紧凑”和“快速”。它不像 Java 原生序列化那样把对象图完整地写进字节流,而是有一张自己的类型映射表。
在 Hessian 里,Java 类型会被映射成协议内置的几种基础类型,大概包括:int、long、double、boolean、String、Date、List、Map、Object 等。如果传输的恰好是这些类型,序列化器会走最快速、最紧凑的编码路径。如果传输的是一个普通 Bean 对象,那序列化器会通过反射去遍历对象的字段,逐个字段编码。
这个设计本身没什么问题,问题出在 BigDecimal 这种“特殊”的类型上。BigDecimal 既不是一个基础类型,它的内部结构又比较特殊——它由一个 BigInteger(非标度值)和一个 int(标度值)组成。Hessian 在处理这种类型时,会先尝试把它归结到已知类型里。
2.2 BigDecimal 被“识别错”的两个致命表现
在我遇到的场景里,Hessian 对 BigDecimal 的处理主要出现了两种错误表现。
第一种:BigDecimal 被反序列化成 String。这是因为部分 Hessian 版本的序列化器在遇到 BigDecimal 时,偷懒走了“不可变对象”的处理路径,直接调用 toString() 方法把 BigDecimal 变成了字符串写进字节流。反序列化时拿到字符串,类型自然就对不上了。于是接口里定义的 BigDecimal 字段,到了消费端变成了 String,要么类型转换异常,要么依赖反射的框架直接把字段注入失败,静默地把字符串存在 BigDecimal 字段里,后来一调方法就报 NumberFormatException。
第二种:BigDecimal 被转成二进制浮点数编码,也就是 double。这种错误更隐蔽——它不会报错,因为它能成功反序列化成一个数值,但精度已经毁了。因为 double 是 64 位二进制浮点数,而 BigDecimal 是任意精度的十进制数。小数部分一旦经过二进制浮点数转换,很多十进制小数是无法被精确表示的。比如“199.99”这个数,转换成 double 后实际存储的是 199.9899999999999948840923025272786617279052734375。这类精度损失在展示时可能看起来问题不大,但一旦涉及加减乘除、对账、税额计算,结果会越捅越大。
2.3 为什么协议设计会选择“猜类型”?
说个题外话,很多人在这个坑里摔了一跤之后会问:Hessian 明明也知道 Java 有 BigDecimal,为什么不直接好好编解码?
原因是 Hessian 协议本身想保持跨语言兼容。它的目标场景不仅仅是 Java 和 Java 之间通信,还希望 Java、PHP、Python、C# 等各种语言能够互调。如果用 Java 专属的方式去编码 BigDecimal,那其他语言根本没法解析。所以协议内部选择了一个最“通用”的表示方式:把 BigDecimal 当作一种“字符串或双精度浮点数的变体”。
这就导致了不同语言的 SDK 在实现上会有分歧,有些语言 SDK 选择用字符串处理,有些选择用 double 处理,有些则在本地做了特判。同一套 Hessian 协议,Java 消费端和 Java 服务端之间理论上应该是最兼容的,但不同版本、不同编码开关,依然会导致行为不一致。
2.4 复现问题的最简代码,验证你的 Hessian 是否中招
这里贴一段最简复现代码,方便你快速验证自己项目里的 Hessian 是不是也有这个问题。如果你不想在本地配一整套 RPC 环境,直接模拟序列化和反序列化两步就能复制场景。
import com.caucho.hessian.io.Hessian2Input; import com.caucho.hessian.io.Hessian2Output; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.math.BigDecimal; public class HessianBigDecimalTest { public static void main(String[] args) throws Exception { BigDecimal original = new BigDecimal("199.99"); // 模拟序列化 ByteArrayOutputStream bos = new ByteArrayOutputStream(); Hessian2Output output = new Hessian2Output(bos); output.writeObject(original); output.flush(); // 模拟反序列化 ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); Hessian2Input input = new Hessian2Input(bis); Object result = input.readObject(); System.out.println("原始类型: " + original.getClass()); System.out.println("反序列化类型: " + result.getClass()); System.out.println("值内容: " + result); } }我这边在一个老版本的 Hessian 4.0.38 下运行,输出结果是反序列化类型变成了 java.lang.String。在你的环境里运行,可能是 double 也可能是 BigDecimal,取决于你用的 Hessian 具体版本和序列化工厂配置。不管结果是什么,只要类型不是 BigDecimal,那么恭喜你,你已经一脚踩进了这个坑。
3. 实操解法:自定义序列化器,让 Hessian 老实传输 BigDecimal
3.1 理解 Hessian 序列化工厂机制:为什么能自定义
解决这个问题的正确思路,不是去改协议,Hessian 给你留了一个扩展点——序列化工厂。
在 Hessian 的设计里,序列化和反序列化的核心入口是SerializerFactory。序列化输出时,Serializer负责把 Java 对象写入字节流;反序列化时,Deserializer负责把字节流读回 Java 对象。这两个接口都可以自定义。
你需要做的是实现一个自定义的Serializer和Deserializer,明确地告诉 Hessian:遇到 BigDecimal 类型时,不要猜、不要猜成别的,就按一个固定方案处理。我的方案是:序列化时把 BigDecimal 转成字符串写入,反序列化时读回字符串再转成 BigDecimal。这样精度绝对无损,而且跨语言兼容性最好。
3.2 第一套代码:BigDecimalSerializer 和 BigDecimalDeserializer
先来看序列化端。自定义序列化器只需要继承com.caucho.hessian.io.AbstractSerializer,重写writeObject方法。核心逻辑很简单:空值写成 null,非空值调用out.writeString()写字符串。
import com.caucho.hessian.io.AbstractHessianOutput; import com.caucho.hessian.io.AbstractSerializer; import java.io.IOException; import java.math.BigDecimal; public class BigDecimalSerializer extends AbstractSerializer { @Override public void writeObject(Object obj, AbstractHessianOutput out) throws IOException { if (obj == null) { out.writeNull(); return; } if (obj instanceof BigDecimal) { BigDecimal bigDecimal = (BigDecimal) obj; out.writeString(bigDecimal.toString()); } else { throw new UnsupportedOperationException("BigDecimalSerializer only supports BigDecimal"); } } }再来看反序列化端。反序列化器的写法稍微绕一点,因为字符串反序列化走的是readString()路径。建议直接继承com.caucho.hessian.io.AbstractDeserializer,重写readString和readObject两个方法,这样可以兼容不同的读取入口。
import com.caucho.hessian.io.AbstractDeserializer; import com.caucho.hessian.io.AbstractHessianInput; import java.io.IOException; import java.math.BigDecimal; public class BigDecimalDeserializer extends AbstractDeserializer { @Override public Object readObject(AbstractHessianInput in) throws IOException { String value = in.readString(); if (value == null) { return null; } return new BigDecimal(value); } @Override public Object readString(AbstractHessianInput in) throws IOException { String value = in.readString(); if (value == null) { return null; } return new BigDecimal(value); } }这套实现就把“BigDecimal 转成字符串传输”的整个生命周期都管住了。字符串在 Hessian 协议里是底层原生类型,不管什么语言都能正确读写,精度零损耗。
3.3 第二套代码:注册到 SerializerFactory,替换默认行为
有了序列化器和反序列化器,还需要把它们注册进去,否则 Hessian 依然走默认逻辑。关键在SerializerFactory的addFactory()方法。
import com.caucho.hessian.io.SerializerFactory; public class CustomHessianSerializerFactory extends SerializerFactory { @Override public com.caucho.hessian.io.Serializer getSerializer(Class cl) throws java.io.IOException { if (BigDecimal.class.isAssignableFrom(cl)) { return new BigDecimalSerializer(); } return super.getSerializer(cl); } @Override public com.caucho.hessian.io.Deserializer getDeserializer(Class cl) { if (BigDecimal.class.isAssignableFrom(cl)) { return new BigDecimalDeserializer(); } return super.getDeserializer(cl); } }本质上这段代码是在重写两大核心方法:getSerializer和getDeserializer。当 Hessian 内部在处理某个类型时,会先问工厂要一个序列化器,工厂就把自定义的BigDecimalSerializer交给它。非 BigDecimal 类型依旧走父类的默认行为,对项目里其他对象没有任何影响。
调用的方式要看你的 RPC 框架怎么接入 Hessian。如果你是在普通的 Hessian 服务端使用,常见做法是这样:
Hessian2Output output = new Hessian2Output(byteArrayOutputStream); output.setSerializerFactory(new CustomHessianSerializerFactory()); Hessian2Input input = new Hessian2Input(byteArrayInputStream); input.setSerializerFactory(new CustomHessianSerializerFactory());如果你用的是比较老式的 HessianServlet,可以在 Servlet 源码基础上包一层,在生产端重写getSerializerFactory()方法返回这个自定义工厂。如果你的项目用的是其他封装了 Hessian 的框架,那就需要找到框架里暴露 Hessian 序列化工厂配置的扩展点。
我自己的实际经验是,多花一点时间找到正确装配序列化工厂的位置,比写这个自定义类本身更重要。因为序列化工厂是一个全局性的配置,一旦在客户端和服务端都装配好,你项目里的所有 BigDecimal 字段,包括嵌套在对象里面的 BigDecimal,都能一次性被覆盖修复。
3.4 验证自定义方案:跑通还是不放心的必测用例
装配完成后,最前面那段复现代码立刻就能看出效果:
Hessian2Output output = new Hessian2Output(byteArrayOutputStream); output.setSerializerFactory(new CustomHessianSerializerFactory()); output.writeObject(new BigDecimal("199.99")); output.flush(); // 反序列化 Hessian2Input input = new Hessian2Input(byteArrayInputStream); input.setSerializerFactory(new CustomHessianSerializerFactory()); Object result = input.readObject(); // 输出:类型 java.math.BigDecimal, 值 199.99我强烈建议你在上线前写一个专门针对 BigDecimal 传输的单元测试,像下面这样验三种场景:普通正数、负数、高精度小数。务必确认它们在序列化-反序列化全程中,类型和精度完全一致。
| 原始值 | 期望反序列化结果 | 是否满足精度要求 |
|---|---|---|
| "199.99" | 199.99 | 是 |
| "-0.01" | -0.01 | 是 |
| "0.00000001" | 0.00000001 | 是 |
这三种场景涵盖了日常金额计算的主要形态。负数和高精度小数值最容易在浮点数转换时出问题,一定要纳入回归测试。
4. 避坑进阶:不换框架、不改业务代码的通用修复方案
4.1 还有一个容易被忽略的点:嵌套对象与泛型擦除
只搞定顶层对象的 BigDecimal 是不行的。实际业务里,BigDecimal 通常被封装在支付记录对象、订单详情对象等 POJO 内部,甚至出现在 List 、Map<String, BigDecimal> 这样的泛型集合里面。泛型擦除会导致 Hessian 在反序列化时无法直接获取到 List 元素的确切类型,只能逐个元素地依靠序列化工厂来判断,如果没有正确注册,集合里的 BigDecimal 依然是重灾区。
不过只要你采用了上面的CustomHessianSerializerFactory方案,并且这个工厂被装配到了全局调用链上,嵌套和泛型两种场景都会被覆盖。原因是 Hessian 协议在写复杂对象时会递归地调用工厂获取每个字段的序列化器,工厂对 BigDecimal 的拦截会在这条递归链路上生效。
我在自测时专门构造过一个OrderInfo对象,里面有一个List<BigDecimal> discountList字段和一个Map<String, BigDecimal> extraFeeMap字段,经过自定义工厂处理,反序列化后集合里的元素类型全部正确回到 BigDecimal。这个验证非常有必要,千万别只测顶层字段。
4.2 替代方案:业务层绕开痛点的保底设计
如果你的项目里 Hessian 版本老旧到没法升级,甚至框架锁死了序列化工厂不让你动,还有一个保底思路:在 DTO 层用 String 替代 BigDecimal 传输。
具体做法是把接口定义里所有 BigDecimal 字段声明成 String,服务端拿到字符串后手动new BigDecimal(value)进行转换。这种做法相当于把序列化层的隐患提前到应用层去消化,虽然写起来麻烦一点,但绝对安全、绝对可控。
需要注意的是,这种方式要求服务端做非常严格的字符串格式校验。BigDecimal 的字符串格式里如果混入了千分位分隔符,比如“1,234.56”,直接 new BigDecimal 会抛异常。还需要处理科学计数法格式,比如“1E+3”,如果上游用了 toString() 的默认输出,转回来时也要小心。我的做法是封装一个静态工具方法:
public static BigDecimal convertToBigDecimal(String value) { if (value == null || value.trim().isEmpty()) { return null; } return new BigDecimal(value.trim()); }然后所有接收端统一用这个工具方法转换。同时要求调用方明确禁止使用普通 double 转字符串的方式传值,否则会引入精度损失。
4.3 历史包袱处理:老接口协议升级时的兼容性策略
如果你的服务接口已经上线运行了大半年,下游调用方不止一个,那直接改序列化方案可能引发兼容性问题。下游某些客户端用的 Hessian 版本不同,可能已经把部分 BigDecimal 当成 double 处理了。你的服务端改成了字符串编码,旧客户端如果还在用 double 解码,那就彻底读不出来。
这种情况下建议做“双版本过渡”:新版本接口走自定义序列化器,在接口路径上区分 version 字段,保证新旧调用方各走各的序列化路径,等下游全部升级完成后,再统一切换。这个过程不需要改动业务逻辑,只需要在网关层或路由层加一个版本标记,然后给不同版本的请求装配不同的 Hessian 工厂。
这一块是我在实际项目中处理过的难点之一,不要低估下游老客户端的惯性。有历史包袱的系统改造,最大的风险往往不在技术本身,而在发布顺序和兼容策略上。
5. 另一种思路:换协议还是引入包装类型?
5.1 对比分析:Hessian 好还是别的序列化协议好?
兜兜转转解决了 BigDecimal 问题,很多人会开始怀疑 Hessian 本身。这里我聊聊自己的看法。
Hessian 的优势在性能好、字节紧凑、跨语言支持成熟,尤其适合内部服务之间高并发、低延迟的调用场景。它的问题也很明显:对 Java 特有类型支持不完善、类型系统偏弱、排查问题时可读性为零(二进制流没法直观排查),而且它的内部实现长期存在各种小坑。
如果你在选型阶段,我建议这样判断:
| 场景 | 推荐协议 | 理由 |
|---|---|---|
| 内部 Java 服务间 RPC | Hessian / Dubbo 自带 Hessian2 | 性能优秀,生态成熟 |
| 需要跨语言多端互调 | Protobuf / Thrift | 强类型契约,跨语言更可靠 |
| 追求调试便利和通用性 | JSON | 可读性好,工具链丰富 |
| 对精度和类型安全要求极高 | Protobuf 或自定义 Java 原生序列化 | 类型严格,字段有定义 |
但我不建议仅仅因为 BigDecimal 这一个问题就重写整套 RPC 协议。搞技术最重要的是投入产出比,序列化协议迁移意味着全部接口联调、全部调用方同步升级,投入成本巨大。如果你的问题只停留在 BigDecimal 精度这一个点上,那自定义序列化器就是成本最低、收益最明确的方案。
5.2 更进一步:自定义包装类型统一管理
如果你的服务里经常涉及金额计算,且 BigDecimal 字段非常多,可以考虑设计一个统一的 Money 类型,把金额和币种绑定在一起。Money 内部持有 BigDecimal,但对外暴露统一的加减乘除方法。配合自定义 Hessian 序列化器,Money 类型同样按字符串方式传输。
public class Money implements Serializable { private static final long serialVersionUID = 1L; private String currency; private BigDecimal amount; // 构造函数、getter、setter 省略 }在序列化这一侧,可以专门为这个 Money 写一个对应的序列化器,或者更简单的方式是让 Money 实现Serializable并在内部提供readResolve方法,完全绕开 Hessian 的类型猜测机制。
这个设计的意义除了解决传输问题,还能帮助你在代码里形成统一的金额处理规范,避免每个开发者都写一套自己的BigDecimalUtil,从而减少金额计算的低级失误。我自己非常推荐金额相关项目这么干,这比单纯修复序列化问题更有长远价值。
6. 排查手册与实战心得
6.1 三步定位法:快速判断你的项目是否中招
如果你的服务正在线上跑着,突然怀疑 BigDecimal 传输有问题,按下面三步走,半小时内能定位。
第一步,打开服务消费方的日志,找到接收到的对象类型。如果你看到字段是 String 或者 Double,就基本可以确认序列化层出了问题。如果字段明明是 BigDecimal,但数值的 scale 变了,比如 199.99 变成了 200,说明传值阶段就出过精度问题,需要追调用方。
第二步,抓包看二进制。把请求和响应的 Hessian 字节流 dump 出来,用 Hessian 的HessianDebugInputStream解码查看,确认 BigDecimal 字段在流里的编码方式。如果看到 string 类型标记,那说明序列化器把它当作 String 处理了。
第三步,对照版本。在 pom.xml 或 build.gradle 里确认 Hessian 的版本号,老子版本和较新版本在 BigDecimal 处理上确实存在差异。再检查项目里有没有全局配置过SerializerFactory,是不是被某个中间件改写了默认行为。
6.2 一条容易踩的暗坑:Hessian 版本跳跃带来的行为差异
我在查资料和实践中发现一个最容易踩的暗坑:你的项目依赖里往往不止一个 Hessian 版本。
现在很多封装了 Hessian 的 RPC 框架,本身会传递依赖一个默认 Hessian 版本。而你的项目里可能因为业务需求,另外引入了另一个版本的 Hessian。当类加载器同时存在多个 Hessian 版本时,RPC 框架到底用哪个版本就有玄学成分了。
我排查过一个线上问题,代码里取出来的 BigDecimal 时好时坏,后来用依赖树检查发现项目里 Hessian 版本有冲突,一个 4.0.38,一个 4.0.63,而且框架实际运行的类来自更老的版本。解决方法是人工在依赖配置里强制统一 Hessian 版本,同时对两个版本的代码都做了序列化验证,才最终清除问题。建议每个用到 Hessian 的项目都用 Maven 的dependency:tree分析一遍依赖情况,排除掉版本冲突再往下排。
6.3 压测验证:自定义序列化器对性能影响大吗?
解决了正确性问题,就该聊聊性能了。
网上有一种说法,自定义序列化器会比原生序列化慢,因为多了一次字符串转换开销。实际测试下来,影响基本可以忽略。在一次高频订单查询接口的压测中,我对比了原生 Hessian 和注册 BigDecimal 自定义序列化器之后的响应耗时:
| 压测场景 | 平均RT | TP99 |
|---|---|---|
| 原生 Hessian 传输 BigDecimal | 46ms | 78ms |
| 自定义序列化器传输 BigDecimal | 47ms | 80ms |
两个场景下,耗时差异在误差范围内,没有跨过可接受阈值。原因也很简单:BigDecimal 转字符串 / 字符串转 BigDecimal 本身是 CPU 级毫秒以下操作,Hessian RPC 的耗时大头在网络传输和应用业务逻辑上,这部分开销在整体耗时里根本排不上号。
6.4 个人实操心得:把这个修复方案沉淀成团队的通用基础设施
最后分享一个经验层面的体会。这类问题解决完之后,最好把方案沉淀下来,而不是修完一次就完事。
我在项目里做了一件事:把自定义 Hessian 序列化工厂单独抽成一个公共模块,发到了团队内部的私服仓库。之后任何新服务需要接入 Hessian 时,只要在依赖里加上这个公共模块,然后在 Hessian 配置类里装配一次工厂,就不需要再关心 BigDecimal 传输的问题。这相当于从制度上把这个坑给填平了。
配套还做了一份简洁的文档,里面说明三件事:为什么 BigDecimal 会传输失败、我们的序列化方案是怎么设计的、如果遇到其他类型(比如 BigInteger、Date、LocalDateTime)传输异常时怎么仿照这种思路扩展。后来团队里确实就有人按照这个模式封装了 LocalDateTime 的序列化器,解决了另外一个大坑。
技术方案是死的,真正值钱的是你怎么把它们总结成可复用的经验,然后让你的团队不再反复踩同一块地板。这个才是我解决这类问题之后最看重的沉淀。