MingJunDuan的博客
热爱可抵一切,探索未知之境
全站访问量

Dubbo RPC 中 Hessian 序列化协议深度剖析 —— 从原理到线上问题

在 Dubbo RPC 调用中,对象是怎么从 Consumer 的内存变成 Provider 的内存里的?中间那一段二进制字节流到底长什么样?当你面对 “Cannot deserialize”、”enum 反序列化后变成 null”、”泛型集合类型丢失” 这些线上问题时,答案都藏在序列化层。

本文从 Dubbo 源码层面,拆解 Hessian 2.0 序列化协议的全链路,覆盖对象/集合/枚举/异常/引用的编解码细节,并汇集生产环境中最常见的 8 个典型问题及排查方案。


一、为什么 Dubbo 选择 Hessian

1.1 Dubbo 序列化层的定位

在 Dubbo 的架构中,序列化层处于 Codec 层 的核心位置:

┌─────────────────────────────────────────────────────────┐
│                     Dubbo RPC 调用链路                      │
│                                                         │
│  Consumer                              Provider         │
│  ┌─────────┐    serialize     ┌─────────┐              │
│  │ Request │ ───────────────► │  bytes  │              │
│  │ Object  │                  │ (网络传输)│              │
│  └─────────┘                  └────┬────┘              │
│                                    │ deserialize       │
│                                    ▼                    │
│                              ┌─────────┐              │
│                              │ Request │              │
│                              │ Object  │              │
│                              └─────────┘              │
│                                                         │
│  Serialization 接口 (SPI, name="serialization")          │
│  ┌──────────────┬──────────────┬────────────────────┐  │
│  │  hessian2    │   fastjson2  │     protobuf       │  │
│  │  (默认)       │              │                    │  │
│  └──────────────┴──────────────┴────────────────────┘  │
└─────────────────────────────────────────────────────────┘

Dubbo 通过 SPI 机制加载序列化实现:

// Dubbo 源码: org.apache.dubbo.common.serialize.Serialization
@SPI("hessian2")  // 默认扩展名为 hessian2
public interface Serialization {
    byte getContentTypeId();
    ObjectOutput serialize(URL url, OutputStream output) throws IOException;
    ObjectInput deserialize(URL url, InputStream input) throws IOException;
}

@SPI("hessian2") 意味着 —— 如果没人显式配置序列化协议,Dubbo 默认就用 Hessian 2。

1.2 Hessian 的设计哲学

Hessian 是 Caucho 公司(Resin 应用服务器的开发商)设计的一种二进制 Web 服务协议。它的核心设计目标有三个:

设计目标 含义 实现方式
紧凑二进制 比 XML/SOAP 小得多 tag-byte + 变长整数编码
自描述 二进制流包含类型信息,不需要预先编译 IDL 类名、字段名写入流中
跨语言 理论上有 Java/PHP/Python/C++ 等多语言实现 定义了语言无关的二进制格式规范

与常见序列化方案的横向对比:

┌──────────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
│              │ Hessian2 │ Fastjson │ Kryo     │ Protobuf │ Java原生  │
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ 格式         │ 二进制    │ 文本      │ 二进制    │ 二进制    │ 二进制    │
│ 自描述       │ ✅       │ ✅       │ ❌        │ ❌        │ ✅       │
│ 跨语言       │ ✅       │ ❌        │ ❌        │ ✅       │ ❌        │
│ 无需IDL      │ ✅       │ ✅       │ ✅       │ ❌        │ ✅       │
│ 序列化体积   │ 小       │ 大       │ 很小      │ 最小      │ 大       │
│ 序列化速度   │ 中等     │ 中等     │ 快        │ 很快      │ 慢       │
│ 字段增删兼容  │ 较好     │ 较好     │ 差        │ 好        │ 差       │
└──────────────┴──────────┴──────────┴──────────┴──────────┴──────────┘

Hessian 在 自描述 + 紧凑 + 兼容性 三者之间取了一个折中,这是 Dubbo 选择它作为默认方案的根本原因:RPC 场景下,你不可能要求所有微服务都预先协商好一份 .proto 文件。

1.3 Hessian 1.x vs Hessian 2.0

Dubbo 使用的是 Hessian 2.0(Dubbo 2.x 早期曾用过 Hessian 1.x,但早已废弃)。两者的关键差异:

特性 Hessian 1.x Hessian 2.0
紧凑性 一般 更好(引入压缩整数、引用机制)
对象编码 类似 XML 结构的二进制映射 类定义 + 对象实例分离(C + O
引用机制 不支持 支持值引用和类引用
字符串编码 定长 分块(chunked)
Map/Object 区分 严格区分 M tag 具有双重语义

核心升级:Hessian 2.0 引入了类似数据库中”表结构 + 数据行”的分离思想——先发类定义(字段名列表),再发对象实例(字段值),避免了每个对象都重复发送字段名。这是它比 1.x 紧凑得多的根本原因。


二、Hessian 协议基础:二进制格式全景

2.1 Tag-Byte 驱动的编码模型

Hessian 的二进制格式是地图式编码:先写 tag(标记这是什么类型),再写数据。反序列化时按 tag 分派到对应的解析器。

核心 tag 一览:

┌──────┬──────┬────────────────────────────────┐
│ Tag  │ 值   │ 含义                           │
├──────┼──────┼────────────────────────────────┤
│ N    │ 0x4e │ null                           │
│ T    │ 0x54 │ true                           │
│ F    │ 0x46 │ false                          │
│ I    │ 0x49 │ int (定长4字节)                 │
│ L    │ 0x4c │ long (定长8字节)                │
│ D    │ 0x44 │ double (定长8字节)              │
│ S    │ 0x53 │ String (定长或分块)             │
│ B    │ 0x42 │ binary 字节数组                │
│ M    │ 0x4d │ Map (type 可能为空)            │
│ H    │ 0x48 │ Map 的 key-value 对结束标记     │
│ C    │ 0x43 │ 类定义(字段名列表)            │
│ O    │ 0x4f │ 对象实例(按字段顺序放值)       │
│ R    │ 0x52 │ 引用(ref,指向已出现的对象)     │
│ 0x55-5f │    │ 紧凑 int/long(单字节表示)     │
│ 0x80-0xbf │  │ 紧凑 long(2字节表示)         │
│ 0xc0-0xcf │  │ 紧凑 int(3字节表示)          │
│ 0xd0-0xd7 │  │ 紧凑 int(4字节表示)          │
└──────┴──────┴────────────────────────────────┘

整数的紧凑编码是 Hessian 压缩体积的关键手段之一。它根据数值大小使用 1~5 个字节 — 这比 Java 原生的 writeInt() 固定 4 字节要高效得多。

2.2 一个直观的对比:Hessian vs JSON

Person { name: "张三", age: 25 } 为例,看看两种编码的本质区别:

JSON (文本, 31 bytes):
  {"name":"张三","age":25}

Hessian 2.0 (二进制, ~20 bytes):
  C                       ← 类定义开始
    \x06 com.Person        ← 类名
    \x92                   ← 字段数量 = 2
    \x04 name              ← 字段1名
    \x03 age               ← 字段2名
  O                       ← 对象实例开始
    \x91                   ← 类定义引用 #1
    \x06 张三              ← name 字段值
    \x5a                   ← age 字段值 (紧凑int = 25)

Hessian 的”类定义 + 对象实例”分离设计意味着:同一个类的 N 个实例,类定义只发一次,后面都是引用。


三、序列化过程:Java 对象 → 二进制流

3.1 Hessian2Output 整体架构

┌────────────────────────────────────────────────────────────┐
│                 Hessian2Output 架构                         │
│                                                            │
│  调用入口 writeObject(obj)                                  │
│       │                                                    │
│       ▼                                                    │
│  ┌─────────────────┐                                       │
│  │ SerializerFactory │ ──► 查找 Serializer                  │
│  └────────┬────────┘                                       │
│           │ 根据类型分发                                     │
│           ▼                                                │
│  ┌──────────────────────────────────────────────────┐     │
│  │ UnsafeSerializer    (普通 POJO)                   │     │
│  │ BeanSerializer      (有 getter 的 JavaBean)       │     │
│  │ StringSerializer    (String)                      │     │
│  │ MapSerializer       (Map/EnumMap/Properties)      │     │
│  │ CollectionSerializer(List/Set/Array)              │     │
│  │ EnumSerializer      (enum)                       │     │
│  │ ThrowableSerializer (Exception)                  │     │
│  │ BigDecimalSerializer, DateSerializer, ...        │     │
│  └──────────────────────────────────────────────────┘     │
│           │                                                │
│           ▼                                                │
│  ┌──────────────────────────────────────────────┐         │
│  │ _refs: IdentityHashMap  (引用追踪)            │         │
│  │ _classRefs: HashMap     (类定义引用追踪)       │         │
│  │ _os: OutputStream        (底层输出)            │         │
│  └──────────────────────────────────────────────┘         │
└────────────────────────────────────────────────────────────┘

SerializerFactory 是核心调度器,它根据 Java 类型选择合适的 Serializer。工厂内部维护了一份类型到序列化器的映射表,首字母大写保证了线程查找的安全性。

3.2 基本类型序列化

int / long —— 紧凑编码

// Hessian2Output 源码简化
public void writeInt(int value) throws IOException {
    if (INT_DIRECT_MIN <= value && value <= INT_DIRECT_MAX) {
        // -16 ~ 47: 单字节编码 [0x80, 0xbf]
        _os.write(value + 0x90);
    } else if (INT_SHORT_MIN <= value && value <= INT_SHORT_MAX) {
        // -0x800 ~ 0x7ff: 2字节编码 [0xc0, 0xcf] + byte
        _os.write(0xc8 + (value >> 8));
        _os.write(value);
    } else if (INT_3BYTE_MIN <= value && value <= INT_3BYTE_MAX) {
        // -0x40000 ~ 0x3ffff: 3字节编码 [0xd0, 0xd7] + 2 bytes
        _os.write(0xd4 + (value >> 16));
        _os.write(value >> 8);
        _os.write(value);
    } else {
        // 其他: 5字节 'I' + 4 bytes (大端)
        _os.write('I');
        _os.write(value >> 24);
        _os.write(value >> 16);
        _os.write(value >> 8);
        _os.write(value);
    }
}

关键示例:

值 = 0      →  0x90  (1字节)       // 比 Java 节省 3 字节
值 = 25     →  0xa9  (1字节)       // 0x90 + 25
值 = 100    →  0xf4  (1字节)
值 = 0x800  →  [0xd0, 0x08, 0x00]  (3字节)
值 = Integer.MAX_VALUE → [0x49, 0x7f, 0xff, 0xff, 0xff] (5字节)

Dubbo RPC 中的方法参数大多是 0~100 范围内的 int 值,紧凑编码能节省大量空间。

String —— Chunked 分块编码

Hessian 2.0 对字符串采用分块传输,每块最大 65535 字节(16 位长度前缀):

// Hessian2Output 源码简化
public void writeString(String value, int offset, int length) {
    // 小字符串:直接写
    if (length <= SIZE) {
        _os.write('S');          // tag: String 定长模式
        _os.write(length >> 8);  // 长度高字节
        _os.write(length);       // 长度低字节
    } else {
        // 大字符串:分块写
        while (length > 0) {
            int sublen = Math.min(length, SIZE);
            _os.write('s');      // tag: String 分块模式  (小写 s!)
            _os.write(sublen >> 8);
            _os.write(sublen);
            _os.write(value, offset, sublen);
            length -= sublen;
            offset += sublen;
        }
        _os.write('S');          // 结束标记
    }
}

这里有一个容易忽略的细节:大写 S 和小写 s 是不同的 tagS(0x53)是完整的定长字符串或结束标记,s(0x73)是分块片段。反序列化时如果混了这两个 tag 的处理逻辑就会出 bug。

null / boolean / Date / BigDecimal

// null
writeNull()  _os.write('N');       // 永远 1 字节

// boolean
writeBoolean(true)   _os.write('T');  // 1 字节
writeBoolean(false)  _os.write('F');  // 1 字节

// java.util.Date —— 序列化为 UTC 毫秒时间戳
writeObject(new Date()) 
  'M'           // Map tag(Hessian 将 Date 当 Map 序列化)
  '\x04' + type // "java.util.Date"
  ':'           // 毫秒值(紧凑 long)
  结束 'Z'

// BigDecimal —— 序列化为字符串表示
writeObject(new BigDecimal("123.45")) 
  'S'           // String tag
  '\x00\x06'    // 长度 6
  "123.45"      // 字符串内容

BigDecimal 序列化为 String 这一点在 Hessian 社区中引发过争议——如果 BigDecimal 来自一个高精度计算结果(比如 0.1 + 0.2 的内部表示),写成 String 时可能丢失精度。Dubbo 通过特殊处理规避了这个问题。

3.3 对象序列化:类定义 + 实例分离

这是 Hessian 2.0 最核心的设计。以这个类为例:

public class User {
    private String name;
    private int age;
    // getter/setter 省略
}

User user = new User("张三", 25);

序列化输出(十六进制 + 注释):

C                         ← 类定义开始 (Class Definition)
  0x0b                    ← 类名长度 11
  "com.example.User"       ← 全限定类名
  0x92                    ← 字段数量 = 2
  0x04 "name"             ← 字段1: 长度4, 名称"name"
  0x03 "age"              ← 字段2: 长度3, 名称"age"

O                         ← 对象实例开始 (Object Instance)
  0x91                    ← 类定义引用编号 #1 (紧凑int = 1)
  0x06 "张三"             ← 字段1的值 (String, 长度6)
  0xa9                    ← 字段2的值 (紧凑int = 25)

类定义引用0x91)是关键优化:指向之前发出的第 1 个类定义。如果流中再次出现 com.example.User 实例,就不需要再发 C ... 了,直接 O 0x91 ...

Serializer 的核心代码(简化):

// UnsafeSerializer.writeObject()
public void writeObject(Object obj, AbstractHessianOutput out) {
    // 1. 写入类定义 (C tag),如果已经写过了就跳过
    int ref = out.writeObjectBegin(cl.getName());
    if (ref < -1) {
        // 首次:写入类定义
        for (Field field : fields) {
            out.writeString(field.getName());
        }
        out.writeMapEnd();  // H tag
        ref = out.writeObjectBegin(cl.getName());
        // 写字段值
        for (Field field : fields) {
            out.writeObject(field.get(obj));
        }
    } else if (ref == -1) {
        // 已写过类定义,直接写实例数据
        for (Field field : fields) {
            out.writeObject(field.get(obj));
        }
    }
    // ref >= 0: 已写过完全相同的对象实例,写引用
}

这里 writeObjectBegin 的返回值含义:

返回值 含义
< -1 类定义未出现,需要先发 C 再发 O
-1 类定义已出现,直接发 O(无需再发字段名)
>= 0 同一对象已出现过,直接发 R 引用

3.4 引用追踪机制

Hessian 通过 IdentityHashMap 追踪已序列化的对象:

// Hessian2Output 源码
private IdentityHashMap<Object, Integer> _refs = new IdentityHashMap<>();

public boolean addRef(Object object) {
    if (_refs == null) _refs = new IdentityHashMap<>();
    Integer newRef = _refs.size();
    Integer ref = _refs.put(object, newRef);
    if (ref != null) {
        // 这个对象之前已经被序列化过
        writeRef(ref);  // 写 R tag + 引用编号
        return true;    // 上层不再序列化字段值
    }
    return false;
}

示例:循环引用

class Node {
    String name;
    Node next;
}

Node a = new Node("A");
Node b = new Node("B");
a.next = b;
b.next = a;  // 环形引用!

序列化输出:

C "Node" 0x02  "name" "next"     ← 类定义
O 0x91                            ← a 实例
  S "A"                            ← a.name
  O 0x91                           ← b 实例 (嵌套在 a.next)
    S "B"                           ← b.name
    R 0x90                          ★ 引用回 a! (指向流中第 0 个实例)

如果没有引用机制,环形引用会死循环。Hessian 的 R tag 完美解决了这个问题,反序列化时能正确还原环。

3.5 集合序列化

List

List<String> list = Arrays.asList("A", "B");

序列化输出:

V                           ← 类型定义开始(V = 0x56)
  M                         ← Map tag,因为 Hessian 用 Map 表示 type
    S "type"                ← key: "type"
    S "[string"             ← value: 类型描述
    Z                       ← end of map
  M "java.util.ArrayList"  ← Map 表示 java.util.ArrayList 类型
    Z

  S "A"                      ← 元素0
  S "B"                      ← 元素1
Z                          ← end of list

关键发现:Hessian 用 V(Variable List,未定型列表)tag 开始列表,用 Z 结束。中间的类型信息也用 Map 来传。

Map

Map<String, Integer> map = new HashMap<>();
map.put("key1", 1);

序列化输出:

H                           ← Map 开始(H = 0x48,等同于 M)
  S "key1"                   ← key
  0x91                        ← value (紧凑int = 1)
Z                           ← Map 结束

3.6 枚举序列化

enum Color { RED, GREEN, BLUE }
Color c = Color.GREEN;

序列化输出:

M                           ← Map tag
  S "name"                   ← key
  S "GREEN"                  ← value (枚举常量名)
  Z

Hessian 按 name() 序列化枚举,不是 ordinal()。这意味着:

  • JDK 的 Enum.name() 返回源码中的常量名
  • 如果常量改名(如 REDRED_COLOR),反序列化端按新 name 找不到旧值,返回 null

这是生产环境中枚举反序列化的头号陷阱。

3.7 异常序列化

Hessian 对 Throwable 有特殊处理:

// ThrowableSerializer
public void writeObject(Object obj, AbstractHessianOutput out) {
    Throwable e = (Throwable) obj;
    // 把异常像普通对象一样序列化其字段(message, cause, stackTrace...)
    // 序列化器强行把异常的字段提取出来,逐个写
}

关键:Hessian 不保留异常的完整栈信息。它序列化 detailMessagecausestackTrace 这三个字段,但 stackTrace 经过跨语言处理后可能丢失部分精度。


四、反序列化过程:二进制流 → Java 对象

4.1 Hessian2Input 整体架构

反序列化是序列化的逆过程,核心是一个按 tag 分派的状态机

┌─────────────────────────────────────────────────────────────┐
│                   Hessian2Input 反序列化流程                   │
│                                                             │
│  readObject()                                               │
│      │                                                      │
│      ▼                                                      │
│  读取 1 byte → tag                                           │
│      │                                                      │
│      ├─ 'N' → 返回 null                                     │
│      ├─ 'T'/'F' → 返回 Boolean                              │
│      ├─ 'I' → readInt()                                     │
│      ├─ 'L' → readLong()                                    │
│      ├─ 'D' → readDouble()                                  │
│      ├─ 'S'/'s' → readString()                              │
│      ├─ 'C' → readObjectDefinition() → 下一步继续读          │
│      ├─ 'O' → readObjectInstance()                          │
│      ├─ 'R' → readRef() → 返回已缓存的引用对象               │
│      ├─ 'M'/'H' → readMap()                                 │
│      ├─ 'V' → readList()                                    │
│      ├─ 0x80-0xbf → 紧凑 int/long                           │
│      ├─ 0xc0-0xcf → 紧凑 int (2-3字节)                       │
│      ├─ 0xd0-0xd7 → 紧凑 int (2-4字节)                       │
│      └─ ...                                                 │
│                                                             │
│  辅助结构:                                                    │
│  _refs: ArrayList<Object>     (值引用列表)                    │
│  _classDefs: ArrayList<ClassDef> (类定义列表)                 │
│  _types: ArrayList<String>     (类型名列表)                   │
└─────────────────────────────────────────────────────────────┘

4.2 基本类型反序列化

紧凑整数的反序列化需要精确理解编码规则:

// Hessian2Input 源码简化
public int readInt() throws IOException {
    int tag = read();
    switch (tag) {
        case 'I':  // 0x49: 定长 int
            return (read() << 24) + (read() << 16) + (read() << 8) + read();

        case 'L':  // 0x4c: 实际上是 long,但适合 int 时直接转
            return (int) ((long) read() << 56 + ...)

        case 0xd8: case 0xd9: case 0xda: case 0xdb:
        case 0xdc: case 0xdd: case 0xde: case 0xdf:
            // [0xd8, 0xdf]: 3字节 int
            is = read();  // 第二个字节
            return ((tag - 0xd4) << 16) + (is << 8) + read();

        case 0xc0: ... case 0xcf:
            // [0xc0, 0xcf]: 2字节 int
            return ((tag - 0xc8) << 8) + read();

        case 0x80: ... case 0xbf:
            // [0x80, 0xbf]: 单字节 int
            return tag - 0x90;

        default:
            throw error("expected integer at " + ...);
    }
}

4.3 对象反序列化:类定义解析

// readObjectDefinition() 核心逻辑
private void readObjectDefinition() {
    String type = readString();        // 类名,如 "com.example.User"
    int len = readInt();              // 字段数量

    String[] fieldNames = new String[len];
    for (int i = 0; i < len; i++) {
        fieldNames[i] = readString();  // 逐个读字段名
    }

    ClassDef def = new ClassDef(type, fieldNames);
    _classDefs.add(def);              // 缓存类定义
}

ClassDef 存储了类名和字段名列表,后续每个 O tag 的对象实例都引用它:

// readObjectInstance() 核心逻辑
private Object readObjectInstance(ClassDef def) throws IOException {
    String type = def.getType();        // "com.example.User"
    Class<?> cl = loadClass(type);     // 通过 Class.forName 加载

    // 实例化策略选择
    Object obj;
    if (UnsafeAllocator.isAvailable()) {
        obj = UnsafeAllocator.create().newInstance(cl);  // 绕过构造器
    } else {
        obj = cl.newInstance();  // 调用无参构造器
    }

    _refs.add(obj);  // 加入引用表,支持后续 R tag 引用

    // 按字段顺序反序列化
    Serializer ser = findSerializer(cl);
    for (int i = 0; i < def.getFields().length; i++) {
        String fieldName = def.getFields()[i];
        Object value = readObject();   // 递归读字段值
        ser.setField(obj, fieldName, value);
    }

    return obj;
}

4.4 实例化策略:UnsafeAllocator vs 构造器

这是一个非常值得关注的细节。Hessian 反序列化时有两个实例化路径:

// 策略选择逻辑
if (UnsafeAllocator.isAvailable()) {
    // 路径1: 使用 sun.misc.Unsafe 直接分配内存
    // 优点: 不依赖无参构造器,类没有无参构造器也能反序列化
    // 缺点: 可能绕过构造器中的初始化逻辑
    obj = UnsafeAllocator.create().newInstance(cl);
} else {
    // 路径2: 通过反射调用无参构造器
    // 要求: 类必须有 public/protected 的无参构造器
    obj = cl.getDeclaredConstructor().newInstance();
}

UnsafeAllocator 的隐患:如果你的类在构造器中做了关键初始化(比如给一个字段赋默认值),反序列化后这个初始化可能没有执行。举个例子:

public class Config {
    private Map<String, String> props;

    public Config() {
        this.props = new ConcurrentHashMap<>();  // 构造器中初始化
        this.props.put("timeout", "3000");       // 设置默认值
    }
}

// 如果 Hessian 通过 Unsafe 实例化 Config,
// 构造器不会执行!props 为 null!
// 后续 setField("props", someMap) 没问题,
// 但如果流中没有 props 字段的数据(版本不兼容),props 就一直是 null

4.5 引用解析

// readRef() 核心逻辑
private Object readRef() {
    int ref = readInt();          // 引用编号
    return _refs.get(ref);        // 从引用表中取出之前反序列化的对象
}

环形引用的还原:反序列化端首先创建 a 对象放入 _refs[0],然后在反序列化 b 时发现 R 0x90(引用编号 0),直接从 _refs[0] 取回 a 对象赋给 b.next。最后 a.next = b,环就还原了。

4.6 集合反序列化

// readList() 核心逻辑
private Object readList() {
    int tag = read();
    if (tag == 'M') readType();     // 读类型描述
    if (tag == 't') readString();   // "type"
    if (tag == 'V') readString();   // value
    if (tag == 'z') readMapEnd();

    // 确定目标集合类
    String type = ...;  // 从流中解析出的类型名
    Collection list;
    if (type.equals("java.util.ArrayList")) {
        list = new ArrayList();
    } else {
        list = (Collection) Class.forName(type).newInstance();
    }

    _refs.add(list);

    // 逐个读元素
    while ((tag = read()) != 'Z') {
        list.add(readObject());
    }
    return list;
}

4.7 泛型擦除带来的挑战

这是 Dubbo + Hessian 组合中最大的坑之一。Hessian 协议 不携带 Java 泛型信息

// Provider 端
public List<User> getUsers() {
    return Arrays.asList(new User("张三", 25));
}

// Hessian 序列化: V [M [S "type" S "[com.example.User" Z] ... Z]
// 类型信息 "[com.example.User" 在流中

// 但 Consumer 端收到的 Response 中,方法返回值类型是
// java.lang.reflect.Type → ParameterizedType → List<User>
// 而 Hessian 反序列化出来的实际对象是
// java.util.ArrayList<Map> !!!

为什么变成 ArrayList<Map> 了? 因为 Hessian 先把对象反序列化到中间格式(list 元素是 Map),然后 Dubbo 根据方法签名的泛型信息再做一次类型转换:

// Dubbo 源码: DecodeableRpcResult
// 反序列化后,对返回值做类型兼容转换
Object value = Hessian2ObjectInput.readObject();
// value 实际类型可能是 ArrayList<HashMap>
// 方法签名期望 List<User>

// Dubbo 的修复: 使用 TypeUtils 做强制类型转换
value = TypeUtils.cast(value, returnType);
// 将 HashMap 转为 User 对象(反射 setter)

五、Hessian 的高级特性

5.1 引用(Ref)机制深入

Hessian 有三种引用:

引用类型 Tag 含义 示例场景
值引用 R 引用之前出现过的 Java 对象实例 环形引用 a.next = b; b.next = a
类定义引用 紧凑 int 引用之前发送过的类定义 同一类的第 2 个实例不需要再发 C
类型引用 紧凑 int 引用之前出现过的类型名 List<User>User 出现多次

三种引用的存储结构是分离的:

// Hessian2Output 中
_refs       // IdentityHashMap<Object, Integer> — 值引用
_classRefs  // HashMap<String, Integer> — 类定义引用
_typeRefs   // HashMap<String, Integer> — 类型名引用

5.2 类定义缓存与线程安全

SerializerFactory 内部维护了类定义的缓存,但这里的线程安全策略值得注意:

// SerializerFactory 的关键缓存
private ConcurrentHashMap<String, Serializer> _cachedSerializerMap
    = new ConcurrentHashMap<>();

private ConcurrentHashMap<String, Deserializer> _cachedDeserializerMap
    = new ConcurrentHashMap<>();

private WeakHashMap<ClassLoader, HashMap<String, ClassDef>> _classDefs;

_classDefs 使用 WeakHashMap —— key 是 ClassLoader,当 ClassLoader 被 GC 回收时,对应的类定义缓存自动清理。这对 Dubbo 这种有自定义 ClassLoader 的场景非常重要。

5.3 压缩整数编码深度解析

Hessian 的整数编码是一种变长编码(Variable Length Encoding),类似于 Protocol Buffers 的 Varint,但编码表不同:

数值范围                             编码形式
─────────────────────────────────────────────────────
[-16, 47]                           [0x80, 0xbf] 即 0x90 + value
[-2048, 2047]                       [0xc0, 0xcf] + 1 byte
[-262144, 262143]                   [0xd0, 0xd7] + 2 bytes
[-536870912, 536870911]             0x49('I') + 4 bytes
其他 long                           0x4c('L') + 8 bytes

具体编码公式:

// 编码规则实现
if (-16 <= v && v <= 47) {
    write(0x90 + v);           // 单字节
} else if (-0x800 <= v && v <= 0x7ff) {
    write(0xc8 + (v >> 8));    // tag + 高字节
    write(v);                   // 低字节
} else if (-0x40000 <= v && v <= 0x3ffff) {
    write(0xd4 + (v >> 16));   // tag + 高字节
    write(v >> 8);              // 中字节
    write(v);                   // 低字节
} else {
    write('I');                 // 定长 int tag
    write(v >> 24);
    write(v >> 16);
    write(v >> 8);
    write(v);
}

为什么 0x90 是基准值? 因为 Hessian 的 tag 空间需要避免与已有 tag 冲突。0x80 是所有紧凑 int 的起始区间,0x90 恰好让编码后的最小结果 0x80(-16),最大单字节结果 0xbf(47),完美位于区间内。

5.4 Chunked String

大字符串(超过 32768 字节)被分成多个 chunk:

s 0x80 0x00 {前 32768 字节数据}   ← chunk 1
s 0x80 0x00 {后 32768 字节数据}   ← chunk 2
s 0x00 0x10 {最后 16 字节数据}    ← chunk 3
S                                  ← 结束标记 (大写 S)

每个 chunk 的前缀是 s(0x73)+ 2 字节长度。最后以大写 S(0x53)收尾。

这种设计的好处是:反序列化端不需要预先知道字符串总长就可以逐块分配 buffer,适合内存受限的场景。

5.5 Map/Object 类型混淆

这是 Hessian 2.0 的一个设计”特性”——M tag 既用于 Map 也用于封装类型信息:

普通 Map:  H {key-value pairs} Z
类型化 Map: M {type info} Z {key-value pairs} Z
Date 序列化: M {type info} Z {millis value} Z

反序列化时,Hessian 根据 type 信息判断最终要创建什么对象:如果 type 是 java.util.Date,就解析 key-value 中的毫秒值创建 Date;如果 type 是 java.util.HashMap,就创建 HashMap。

这种”一 tag 多义”的设计增加了反序列化器的复杂度,但换来了协议的紧凑性——不需要为每种带类型的值单独设计 tag。

5.6 Whitelist/Blacklist 安全机制

反序列化安全是 Dubbo 的重中之重。如果攻击者构造恶意字节流,可能触发任意代码执行(gadget chain)。Dubbo 的防线:

// Dubbo 源码: ClassUtils
// 反序列化前检查类名是否在白名单/黑名单中
private static boolean isAllowClass(Class<?> cl) {
    // 1. 检查系统黑名单(已知危险类)
    if (CLASS_BLACKLIST.contains(cl.getName())) return false;

    // 2. 检查用户配置的白名单
    String whitelist = System.getProperty("dubbo.security.serialization.whitelist");
    if (whitelist != null) {
        return whitelist.contains(cl.getName());
    }

    // 3. 默认允许常见 JDK 类和用户类
    return cl.getName().startsWith("java.")
        || cl.getName().startsWith("javax.")
        || cl.getName().startsWith("com.example.");
}

// 配置方式
// dubbo.properties
// dubbo.security.serialization.whitelist=com.example.dto.*,com.example.model.*

六、典型问题 & 排查实战

问题 1:Cannot deserialize XXX

症状

com.alibaba.com.caucho.hessian.io.HessianProtocolException:
  unknown code for readObject at 0x50 (...)

根因:序列化端和反序列化端的类结构不一致。

最常见的场景:

// Provider 端(新版本)
public class User {
    private String name;
    private int age;
    private String email;  // ← 新增字段
}

// Consumer 端(旧版本)
public class User {
    private String name;
    private int age;
    // 没有 email 字段
}

当 Provider 返回的 User 包含 email 字段时,Consumer 端的类定义只有 nameage。Hessian 按类定义的字段数读值,读到 email 这个多出来的字段时,字节流的位置会对不齐。

排查思路

  1. 对比两端类的字段列表差异:diff <(javap -p com.example.User) <(ssh remote "javap -p com.example.User")
  2. 检查是否缺少 serialVersionUID
  3. 查看 Dubbo 日志中是否打印了 "deserialize error" 的堆栈

解决方案

public class User implements Serializable {
    private static final long serialVersionUID = 1L;  // ← 必须加

    private String name;
    private int age;
    // 新增字段放最后,Hessian 按顺序读,多余的会跳过的
}

注意:Hessian 实际上不完全依赖 serialVersionUID(这是 Java 原生序列化的概念),但它按字段顺序匹配,新增字段放在类定义的最后可以减少版本兼容问题。

问题 2:枚举反序列化返回 null

症状

// 序列化时 Color.GREEN → 字节流 → 反序列化后变成 null
Color color = rpcService.getColor();  // 返回 null !!

根因:Hessian 按 Enum.name() 序列化枚举常量,反序列化时调用 Enum.valueOf(Class, name)。如果枚举常量改了名字:

// 旧版本
enum Color { RED, GREEN, BLUE }

// 新版本(GREEN → GREEN_COLOR)
enum Color { RED, GREEN_COLOR, BLUE }
// 序列化发的是 "GREEN",反序列化端找不到 "GREEN",返回 null

排查思路

  1. 确认枚举类的变更历史(git log --oneline -- Color.java
  2. 在反序列化处打断点,看 Enum.valueOf() 的返回值
  3. 确认是否修改过常量名

解决方案

方案一:保留旧常量名 + @Deprecated

enum Color {
    RED,
    @Deprecated
    GREEN,        // 保留旧名称
    GREEN_COLOR,  //  新名称(但内部用这个)
    BLUE
}

方案二:自定义 Dubbo 序列化器:

public class ColorSerializer extends EnumSerializer {
    @Override
    public void writeObject(Object obj, AbstractHessianOutput out) {
        // 按 ordinal 序列化,而不是 name
        Enum<?> e = (Enum<?>) obj;
        out.writeInt(e.ordinal());
    }
}
// 注册: SerializerFactory.addFactory(...)

问题 3:泛型集合反序列化类型丢失

症状

// Provider 返回 List<User>
public List<User> getUsers() { ... }

// Consumer 收到后
List<User> users = rpcService.getUsers();
User first = users.get(0);  // ClassCastException: HashMap cannot be cast to User!

根因:Hessian 不携带 Java 泛型信息。反序列化后 List 的元素实际上是 HashMap,Dubbo 需要用方法签名的泛型信息做 TypeUtils.cast() 转换,但这个转换过程可能失败。

排查思路

  1. 打印 Hessian 反序列化后的中间对象类型:
    // Dubbo Filter 中
    Object raw = invocation.getArguments()[0];
    System.out.println(raw.getClass());  // 看看实际类型是什么
    
  2. 检查泛型是否嵌套太深(如 Map<String, List<User>>),Hessian 处理多层泛型容易出问题

  3. 确认是否开启了泛化调用(generic=true

解决方案

方案一:Dubbo 3.x 中配置泛化调用:

<dubbo:reference id="userService"
    interface="com.example.UserService"
    generic="true" />

GenericService 方式调用,自己处理类型转换。

方案二:避免复杂泛型嵌套,改用确定的 DTO 封装:

// 不要这样
Map<String, List<User>> getUsersByDept();

// 改为这样
UserListResponse getUsersByDept();  // UserListResponse 内部封装 Map

方案三:升级序列化协议(如使用 Protobuf)。

问题 4:BigDecimal 精度丢失

症状

// Provider 端
BigDecimal amount = new BigDecimal("123.45");
// 到了 Consumer 端
// amount.toString() → "123.45000000000000001"

根因:低版本 Hessian(如 Dubbo 2.5.x 内置的版本)将 BigDecimal 序列化为 double(使用 D tag),而不是 Stringdouble 类型无法精确表示 123.45

新版本 Hessian 修复了这个问题,改用 String 序列化 BigDecimal。但如果 Consumer 和 Provider 的 Dubbo 版本不一致,可能一边用 double 一边用 String

排查思路

  1. 检查两端的 Dubbo 版本:grep 'dubbo' pom.xml
  2. 抓包看 Hessian 字节流中 BigDecimal 的 tag 是 D 还是 S
  3. 确认 BigDecimal 在小数点后很多位的场景

解决方案

// 业务代码中显式控制传输格式
public class AmountDTO implements Serializable {
    private String amountStr;  // 用 String 传金额,不用 BigDecimal

    @Transient  // 不序列化
    private BigDecimal amount;

    // 手动转换
    public BigDecimal getAmount() {
        if (amount == null && amountStr != null) {
            amount = new BigDecimal(amountStr);
        }
        return amount;
    }
}

问题 5:反序列化安全漏洞

症状:服务被安全扫描发现有反序列化漏洞(CVE 相关)。

根因:恶意构造的 Hessian 字节流可以触发服务端的 gadget chain(如 Commons-Collections 的 InvokerTransformer)。

排查思路

  1. 检查 Dubbo 版本是否有已知反序列化漏洞
  2. 检查是否开启了安全白名单
  3. 扫描 classpath 中是否有已知危险库(如 Commons-Collections 3.x)

解决方案

# dubbo.properties
# 方案1: 开启白名单
dubbo.security.serialization.whitelist=com.example.dto.*
# 方案2: 开启黑名单
dubbo.security.serialization.blacklist=org.apache.commons.collections.*
// 方案3: 编程式配置
@Bean
public ProtocolConfig protocolConfig() {
    ProtocolConfig config = new ProtocolConfig();
    config.setSerialization("hessian2");
    config.setSerializationSecurityCheck(true);
    return config;
}

Dubbo 2.7.9+ 默认开启了反序列化安全检查,如果还在用旧版本请尽快升级。

问题 6:Hessian 与 Hessian2 不兼容

症状

com.alibaba.com.caucho.hessian.io.HessianProtocolException:
  expected hessian reply ('H' x48) but got 'c' (x63)

根因:Consumer 端配置了 hessian,Provider 端配置了 hessian2(或反过来)。Hessian 1.x 和 Hessian 2.0 的二进制格式完全不同。

排查思路

  1. 检查两端的 Dubbo 协议配置
  2. 抓包看前几个字节的 tag 是否匹配

解决方案

显式统一配置:

<!-- Provider -->
<dubbo:protocol name="dubbo" serialization="hessian2" />

<!-- Consumer -->
<dubbo:reference id="xxx" serialization="hessian2" />

Dubbo 2.6+ 版本默认使用 hessian2,但如果有老旧的消费者可能仍用 hessian

问题 7:大量对象序列化 OOM

症状

// 一次 RPC 调用返回 10 万条记录
List<Order> orders = orderService.queryAll();
// Consumer 端 OOM: Java heap space

根因:Hessian 的引用追踪机制(IdentityHashMap)会在序列化过程中持有所有已序列化对象的引用,直到 reset() 被调用。一次返回 10 万个对象,_refs Map 中就会累计 10 万个 entry。

排查思路

  1. 检查 Heap Dump 中 IdentityHashMap 的占用
  2. 检查是否有超大数据量的 RPC 调用
  3. 检查是否调用了 Hessian2Output.reset()

解决方案

// 方案1: 分页传输
List<Order> queryOrders(int pageNo, int pageSize);

// 方案2: 流式传输(Dubbo 3.x)
// 使用 StreamObserver / ServerStream

// 方案3: 手动 reset
Hessian2Output out = new Hessian2Output(outputStream);
for (int i = 0; i < orders.size(); i++) {
    out.writeObject(orders.get(i));
    if (i % 100 == 0) {
        out.reset();  // 每 100 个对象清理一次引用表
    }
}

问题 8:跨语言调用失败

症状:用 PHP/Python 写的 Hessian 客户端调用 Java Dubbo 服务,返回的数据解析失败。

根因:虽然 Hessian 号称跨语言,但不同语言的 Hessian 库实现质量参差不齐:

语言 问题
PHP hessianphp 不支持 Hessian 2.0 的 C+O 分离编码
Python python-hessian 不支持引用的环形对象
Go gohessian 不处理 Java Throwable 的特殊格式
C++ hessian-cpp 维护停滞,不支持 Java 8+ 的新类型

另外,Hessian 序列化的是 Java 全限定类名(如 com.example.User),其他语言无法直接加载 Java 类。所以”跨语言”实际上只是格式兼容,语义上并不互通。

解决方案

  • 如果确实有跨语言需求,切换到 Protobuf(有成熟的多语言支持)或者使用 JSON/HTTP 协议
  • 如果必须用 Hessian,在 Java 端把返回类型限定为基本类型 + String + Map/List

问题 9:Provider/Consumer 字段增删导致兼容性问题

症状

Provider 升级后 Consumer 调不通,或者 Consumer 拿到的对象某些字段为 null。
但诡异的是:不报错,不抛异常,数据静默丢失。

根因深度分析

先理解 Hessian 反序列化的核心循环:

// Hessian2Input.readObjectInstance() 核心逻辑
for (int i = 0; i < def.getFields().length; i++) {
    String fieldName = def.getFields()[i];  // ← 来自流的字段名
    Object value = readObject();            // ← 从流中消费一个值
    ser.setField(obj, fieldName, value);    // ← 按字段名匹配本地类的字段
}

关键:循环次数 = 流中的字段数,不是本地类的字段数。 每轮循环都从流中消费一个值,流位置绝对不会错位。匹配靠字段名(setField 内部按 name 查找),不以顺序为准。

以下逐一分析四种增删场景:


场景 A:Provider 多了字段,Consumer 还是旧类

流中: C "User" 3 fields: [name, age, email]     ← Provider 定义
本地: class User { name, age }                   ← Consumer 类定义

反序列化过程:

i=0: fieldName="name"  → readObject() = "张三"  → obj.setName("张三")   ✅ 匹配
i=1: fieldName="age"   → readObject() = 25      → obj.setAge(25)        ✅ 匹配
i=2: fieldName="email" → readObject() = "z@e"   → 本地类无 email 字段   ⚠️ 值被读出来但丢弃

协议安全。但 Consumer 拿不到 Provider 新增的 email 数据。


场景 B:Provider 少了字段,Consumer 还是旧类

流中: C "User" 2 fields: [name, age]            ← Provider 删了 email
本地: class User { name, age, email }            ← Consumer 旧类
i=0: fieldName="name"  → obj.setName("张三")     ✅
i=1: fieldName="age"   → obj.setAge(25)          ✅
循环结束 (流中只有2个字段)
obj.email = null  ← 没被赋值,保持默认值

协议安全。但 Consumer 代码如果调了 user.getEmail().isEmpty()NPE。这不是反序列化的错,是业务代码做了下游的 null 不安全操作。


场景 C:Consumer 发请求时字段比 Provider 多

Consumer 序列化: C "User" 3 fields [name, age, email]
Provider 反序列化: 本地只有 name, age

Provider 从流中读 3 个值但只填充前 2 个,多余字段丢弃。Provider 的 email 字段为 null。

协议安全。同样注意 Provider 业务代码的空指针保护。


场景 D:Consumer 发请求时字段比 Provider 少

Consumer 序列化: C "User" 2 fields [name, age]
Provider 反序列化: 本地有 name, age, email

Provider 从流中读 2 个值,email 保持默认值 null。

协议安全


汇总矩阵

┌────────────────────┬─────────────────────────┬──────────────────┐
│ 场景                │ Consumer 看到的效果       │ 是否抛异常        │
├────────────────────┼─────────────────────────┼──────────────────┤
│ Provider +字段      │ 新字段值为 null           │ ❌ 不抛           │
│ Provider -字段      │ 被删字段值为 null          │ ❌ 不抛,业务可能NPE│
│ Consumer 入参 +字段  │ Provider 拿不到新字段     │ ❌ 不抛           │
│ Consumer 入参 -字段  │ Provider 拿到 null       │ ❌ 不抛           │
└────────────────────┴─────────────────────────┴──────────────────┘

真正会炸的三个场景

⚠️ 改字段类型

// 旧: private int age;
// 新: private String age;  ← 类型变 String

流中是 S "25"(String tag),本地类是 int → Hessian 尝试 Integer.parseInt("25") 可能成功(小数字),但如果是 S "abc" 直接炸 NumberFormatException。类型不兼容时不保证成功

⚠️ 改字段名

// 旧: private String userName;
// 新: private String username;  ← 大小写变了

Hessian 按字段名精确匹配:userNameusername。旧 Consumer 的 userName 为 null,新 Provider 发的 username 数据被丢弃。数据静默丢失,最难排查!

⚠️ 枚举常量改名(见问题 2)


排查思路

  1. 对比类定义差异
    # 对比两端类的字段列表
    diff <(javap -p com.example.User) <(ssh provider-host "javap -p com.example.User")
    
  2. 在 Dubbo Filter 中打印反序列化后的对象
    @Activate(group = CONSUMER)
    public class DeserializeCheckFilter implements Filter {
        @Override
        public Result invoke(Invoker<?> invoker, Invocation invocation) {
            Result result = invoker.invoke(invocation);
            Object value = result.getValue();
            // 反射遍历所有字段,检查是否有意外 null
            for (Field f : value.getClass().getDeclaredFields()) {
                f.setAccessible(true);
                if (f.get(value) == null) {
                    log.warn("字段 {} 反序列化后为 null, 可能是版本不兼容", f.getName());
                }
            }
            return result;
        }
    }
    
  3. 加序列化版本号(非 serialVersionUID,而是业务字段):
    public class User implements Serializable {
        private int schemaVersion = 2;  // 手动维护版本号
        private String name;
        private int age;
        private String email;  // v2 新增
    }
    // Provider 端判断版本号,做兼容处理
    

最佳实践

新增字段:  末尾追加 → 不影响现有字段序号 → 两端按名字匹配 ✅
删除字段:  先 @Deprecated 过渡一个版本 → 确认无消费者使用 → 再物理删除
修改类型:  ❌ 禁止。新增字段用新类型,旧字段保留并 @Deprecated
修改名称:  ❌ 禁止。等于删了旧的+加了新的,数据静默丢失
枚举常量:  只增不删不改名,废弃的加 @Deprecated

一个安全的演进示例:

// === v1 ===
public class OrderDTO implements Serializable {
    private Long orderId;
    private BigDecimal amount;
}

// === v2 (安全演进) ===
public class OrderDTO implements Serializable {
    private Long orderId;
    private BigDecimal amount;

    // v2 新增: 都是末尾追加,不影响老消费者
    private String remark;           // 新增字段,老消费者忽略
    private Integer discountType;    // 新增字段,老消费者忽略
}

// === v3 (安全演进) ===
public class OrderDTO implements Serializable {
    private Long orderId;
    private BigDecimal amount;

    @Deprecated  // ← 标记废弃但不删除
    private String remark;          // 老消费者可能还在用

    private Integer discountType;

    // v3 新增: 替代 remark 的新字段
    private String orderNote;       // 新字段,老消费者拿 null
}

七、性能优化实战

7.1 使用 writeReplace / readResolve

这两个方法是 Java 原生序列化的钩子,Hessian 同样支持:

public class SingletonConfig implements Serializable {
    private static final SingletonConfig INSTANCE = new SingletonConfig();

    // 序列化时用代理对象代替
    private Object writeReplace() {
        return new ConfigProxy(this.name, this.value);
        // 代理对象更简单,字段更少
    }

    // 反序列化后还原为单例
    private Object readResolve() {
        return INSTANCE;  // 始终返回同一个实例
    }
}

这适用于单例对象、缓存对象等不需要完整传输的场景。

7.2 reset() 的正确时机

// 错误: 从不 reset,引用表无限增长
Hessian2Output out = ...;
for (Object item : hugeList) {
    out.writeObject(item);
}

// 正确: 定期 reset
Hessian2Output out = ...;
int count = 0;
for (Object item : hugeList) {
    out.writeObject(item);
    if (++count % 500 == 0) {
        out.reset();       // 清理引用表
        out.resetReferences(); // 清理类引用表  ← 容易遗漏!
    }
}

关键reset() 清的是值引用表,resetReferences() 清的是类定义引用表。如果只调用 reset(),类定义累积过多也会导致内存问题。

7.3 泛化调用 vs 强类型调用的开销

// 强类型调用: 序列化/反序列化直接到目标类型
User user = userService.getUser(1L);
// 序列化: User对象 → 二进制
// 反序列化: 二进制 → User对象 (一次完成)

// 泛化调用: 中间多一层 Map 转换
GenericService svc = (GenericService) userService;
Object result = svc.$invoke("getUser", new String[]{"long"}, new Object[]{1L});
// 序列化: Map → User(反射填充) → 二进制
// 反序列化: 二进制 → User → Map(反射提取)
// 多了两次反射操作!

泛化调用的额外开销大约在 10-20%,在频繁调用场景下应尽量用强类型调用。

7.4 序列化大小对比

实际测试(一个包含 10 个字段的简单 DTO):

┌──────────────────┬──────────────┬───────────────────┐
│ 序列化方式         │ 大小 (bytes) │ 相对 Hessian2     │
├──────────────────┼──────────────┼───────────────────┤
│ Java 原生         │ 892          │ 2.3x              │
│ JSON (Fastjson)   │ 365          │ 0.94x             │
│ Hessian2          │ 387          │ 1.0x (基线)       │
│ Kryo (优化后)     │ 178          │ 0.46x             │
│ Protobuf          │ 142          │ 0.37x             │
└──────────────────┴──────────────┴───────────────────┘

Hessian2 的体积大约是 Java 原生的 40%,但比 Kryo 和 Protobuf 大约一倍。如果你的服务对带宽敏感,可以考虑切换序列化协议。


八、总结与建议

8.1 Hessian 的适用场景

推荐使用 Hessian:                 考虑其他方案:
├─ 纯 Java 微服务集群             ├─ 有跨语言调用需求 → Protobuf
├─ 字段变更频繁(自描述)          ├─ 超高性能要求 → Kryo
├─ 不想维护 .proto 文件           ├─ 需要强 Schema 校验 → Protobuf
├─ Dubbo 默认,改动成本低          ├─ 数据量超大 → Kryo + 压缩
└─ 团队对二进制协议了解有限        └─ 安全敏感场景 → 需额外配置白名单

8.2 Dubbo 3.x 的演进方向

Dubbo 3.x 引入了 Triple 协议(基于 gRPC),默认使用 Protobuf 序列化。这标志着 Dubbo 的核心序列化战略正在从 Hessian 转向 Protobuf。但 Hessian 作为存量系统的默认方案,仍将长期存在。

8.3 生产环境 Checklist

在关键服务上线前,建议逐项确认:

□ 所有传输 DTO 实现了 Serializable 接口
□ 敏感类已加入反序列化白名单
□ 枚举类检查是否有常量名变更历史
□ BigDecimal 字段确认传输格式(String vs double)
□ Consumer 与 Provider 的 Dubbo 版本一致
□ 序列化协议显式配置为 hessian2(非 hessian)
□ 大数据量接口已做分页(单次不超过 1000 条)
□ 没有需要跨语言调用的场景使用 Hessian
□ 泛型集合类型已在方法签名中完整声明

参考资源

本文阅读量