Java 基础体系 · 第 50/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 序列化边界:原生序列化风险、JSON、Schema 与版本演进

“序列化”是把内存中的对象状态转换为可保存或传输的数据;“反序列化”则是根据这些数据重建对象或数据结构。两者之间形成了一条数据边界

对象模型
   │ 序列化
   ▼
字节或文本表示 ── 文件、缓存、消息、HTTP、RPC ──►
   ▲
   │ 反序列化
对象模型

这条边界不仅解决“如何传输对象”,还决定了几个更重要的问题:

  1. 接收方是否会执行发送方控制的数据;
  2. 数据格式是否跨语言、跨版本;
  3. 新旧生产者和消费者能否共同工作;
  4. 数据是否有可验证的结构和语义;
  5. 升级失败时能否诊断和恢复。

Java 平台中的原生序列化、JSON 和 Schema 解决的是不同层次的问题。原生序列化主要描述 Java 对象图;JSON 主要描述一种跨语言的数据表示;Schema 则描述数据表示必须满足的结构和约束。把三者当成同一种机制,通常会导致错误的安全和兼容性判断。


一、先区分三个层次:对象、表示和契约

1. Java 对象不是天然的数据契约

Java 对象包含的不只是业务数据,还包含:

  • 类名和继承关系;
  • 私有字段;
  • 对象之间的共享引用;
  • 循环引用;
  • transient 字段;
  • 构造器和方法;
  • 运行时类型;
  • 类加载器环境;
  • 反序列化期间可能执行的回调。

例如:

final class User {
    private String id;
    private String displayName;
    private transient String sessionToken;
}

这个类的“对象状态”与外部系统真正需要的数据契约可能不同:

  • sessionToken 可能不应被传输;
  • 私有字段名可能只是实现细节;
  • User 这个 Java 类名不应成为跨语言协议的一部分;
  • 外部系统可能需要 createdAt,但 Java 对象当前没有该字段。

因此,序列化不是简单地把所有字段“变成字符串”。它是在选择一种边界表示

2. 表示格式和契约不是一回事

JSON 是文本表示格式,例如:

{
  "id": "u-100",
  "displayName": "Ada"
}

这段 JSON 本身并没有说明:

  • id 是否必填;
  • displayName 是否允许为 null
  • 是否允许额外字段;
  • id 是否必须匹配某个正则;
  • 金额字段是否允许负数;
  • 字段在下一个版本中能否删除。

这些规则需要由 Schema 或应用代码补充。

可以把边界模型写成:

有效消息=表示格式正确满足 Schema满足业务语义\text{有效消息} = \text{表示格式正确} \land \text{满足 Schema} \land \text{满足业务语义}

其中:

  • “表示格式正确”表示 JSON 语法合法;
  • “满足 Schema”表示类型、字段、范围等结构约束成立;
  • “满足业务语义”表示例如余额不能透支、结束时间不能早于开始时间等规则成立。

JSON 解析器通常只能保证第一层。Schema 主要覆盖第二层,而第三层仍然需要领域逻辑。

3. 版本兼容性是两个方向的问题

设:

  • PP 是生产者;
  • CC 是消费者;
  • D(P)D(P) 是生产者产生的数据集合;
  • A(C)A(C) 是消费者能够正确接受的数据集合。

若要让生产者和消费者兼容,需要满足:

D(P)A(C)D(P) \subseteq A(C)

但升级时至少有两个方向:

  • 新生产者 → 旧消费者:新版本产生的数据是否仍属于旧消费者可接受集合;
  • 旧生产者 → 新消费者:新版本消费者是否仍能接受旧数据。

所以“向后兼容”不能脱离方向使用。一个变化可能对新消费者兼容,却破坏旧消费者。


二、Java 原生序列化到底保存了什么

Java 原生序列化由 ObjectOutputStreamObjectInputStream 提供。一个类通常通过实现 java.io.Serializable 表示它允许参与这种机制:

import java.io.Serializable;

public final class User implements Serializable {
    private static final long serialVersionUID = 1L;

    private final String id;
    private final String displayName;

    public User(String id, String displayName) {
        this.id = id;
        this.displayName = displayName;
    }

    @Override
    public String toString() {
        return id + ": " + displayName;
    }
}

这并不意味着 Java 把对象转换成某种通用字段格式。原生序列化保存的是一个对象图

1. 对象图而不是字段列表

假设两个字段指向同一个对象:

Address address = new Address("London");

User user = new User("u-1", address, address);

原生序列化会记录引用关系。反序列化后,两个字段仍然可能指向同一个 Address 实例:

user.getHomeAddress() == user.getBillingAddress()

结果可能为 true

对于循环引用也是如此:

Node a = new Node("a");
Node b = new Node("b");

a.next = b;
b.next = a;

对象图协议需要保存“这是已经出现过的对象”的句柄,而不是无限递归复制对象。

JSON 默认描述的是树状数据。若要表达共享引用或循环引用,必须依赖特定库的扩展约定;普通 JSON 文本本身没有共享对象身份这一概念。

2. 默认序列化的字段规则

对实现 Serializable 的普通类,默认机制大致遵循以下规则:

  • 非静态字段参与默认序列化;
  • transient 字段不参与默认序列化;
  • 静态字段属于类状态,不属于某个对象实例,不参与默认序列化;
  • 字段可以是私有字段;
  • 字段类型本身通常也必须可序列化,否则写出对象图时失败;
  • 类的序列化形式包含类描述信息,其中包括类名和 serialVersionUID

transient 不是绝对的安全边界。类可以定义自定义 writeObject,自行把原本 transient 的信息写入流中。因此,真正的边界规则还要检查自定义序列化代码。

3. 构造器不是反序列化的主要入口

对于一个可序列化类,反序列化通常不会像普通 new User(...) 那样调用该类的构造器。可序列化继承体系中,最靠近该类的、首个不可序列化父类的无参构造器会参与初始化;可序列化类自身的普通构造器不负责恢复字段状态。

这造成一个重要风险:

final class Account implements Serializable {
    private static final long serialVersionUID = 1L;

    private final String owner;

    Account(String owner) {
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("owner required");
        }
        this.owner = owner;
    }
}

如果代码只在构造器中保证 owner 非空,那么反序列化路径可能绕过这个检查。恢复对象后,owner 可能不满足构造器建立的假设。

可以使用 readObject 恢复后再次验证:

private void readObject(java.io.ObjectInputStream in)
        throws java.io.IOException, ClassNotFoundException {
    in.defaultReadObject();

    if (owner == null || owner.isBlank()) {
        throw new java.io.InvalidObjectException("invalid owner");
    }
}

readObject 不是普通的业务回调,而是反序列化协议的一部分。它会在接收输入的过程中执行,因此不应把不可信输入直接交给任意类的反序列化逻辑。


三、serialVersionUID 解决什么,不能解决什么

serialVersionUID 是序列化类的版本标识。示例中的:

private static final long serialVersionUID = 1L;

表示该类明确声明了一个序列化版本号。

反序列化时,流中的类描述符会与当前类的 serialVersionUID 比较。如果不一致,通常会抛出:

java.io.InvalidClassException:
local class incompatible:
stream classdesc serialVersionUID = ...,
local class serialVersionUID = ...

1. 显式声明比自动计算稳定

如果没有显式声明 serialVersionUID,Java 可以根据类的若干结构特征计算默认值。这个计算结果可能受到以下变化影响:

  • 字段变化;
  • 方法和构造器变化;
  • 接口变化;
  • 可见性变化;
  • 类结构变化。

因此,仅仅改动一个与业务数据无关的方法,也可能改变默认 UID,导致历史数据无法读取。

显式声明 UID 能避免这类“无意变化”,但它不是兼容性证明。把 UID 固定为 1L 后,以下问题仍然存在:

  • 新代码可能无法正确解释旧字段;
  • 旧代码无法理解新字段;
  • 类型变化可能直接失败;
  • 不变量可能被破坏;
  • 类的读取回调可能改变行为。

2. 一个完整的演进例子

第一版:

import java.io.Serializable;

public final class Profile implements Serializable {
    private static final long serialVersionUID = 1L;

    private String name;
}

第二版增加一个字段:

public final class Profile implements Serializable {
    private static final long serialVersionUID = 1L;

    private String name;
    private String email;
}

对于默认序列化,读取第一版数据时,第二版中的 email 通常得到默认值 null。这是一种常见的兼容变化,但前提是新代码允许 email == null

如果第二版的业务逻辑直接假设:

email.toLowerCase()

那么序列化层面“读成功”不等于业务层面“对象有效”。

再看删除字段:

// 第一版
private String nickname;

// 第二版删除 nickname

旧流中的 nickname 通常可以被忽略,新类不再保存它。可是如果旧消费者仍然需要 nickname,那么“新生产者 → 旧消费者”仍然可能出现数据语义缺失。

3. 类型变化通常不是兼容变化

例如:

// 旧版
private int retryCount;

// 新版
private long retryCount;

即使 int 的值都能放入 long,Java 原生序列化也不会自动把任意字段类型变化当成安全迁移。字段签名属于序列化描述的一部分,可能导致 InvalidClassException 或读取失败。

更可靠的迁移方式是保留旧字段并显式转换,或者通过自定义 readObject、外部迁移程序生成新格式。

4. defaultReadObject 与手动迁移

如果要在读取时设置新字段,可以这样写:

private void readObject(java.io.ObjectInputStream in)
        throws java.io.IOException, ClassNotFoundException {
    in.defaultReadObject();

    if (email == null) {
        email = "unknown@example.invalid";
    }

    if (name == null || name.isBlank()) {
        throw new java.io.InvalidObjectException("name required");
    }
}

步骤是:

  1. defaultReadObject() 读取默认字段;
  2. 对缺失字段应用迁移规则;
  3. 验证整个对象的不变量;
  4. 不满足条件时抛出 InvalidObjectException

默认值必须由业务决定。Java 语言层面的 null0false 只是缺省值,不等价于业务上的“未知”“没有配置”或“关闭”。


四、原生序列化的核心风险:输入会触发对象行为

原生反序列化最危险的误区是:

“我只是把字节还原成对象,所以它不会执行代码。”

实际过程更接近:

读取类描述
  → 分配对象
  → 恢复字段和对象引用
  → 调用可序列化机制中的回调
  → 触发对象图中相关类型的行为
  → 返回对象

可能参与该过程的机制包括:

  • readObject
  • readObjectNoData
  • readResolve
  • validateObject
  • 某些类库自定义的反序列化逻辑。

如果攻击者能够控制输入字节,并且运行环境中存在一组可组合的类,这些类的反序列化回调可能形成所谓的 gadget chain,即“利用链”。攻击者不一定需要直接提供一个恶意类,只需要构造一个能触发已有类行为的对象图。

因此,危险条件通常是:

不可信输入+原生反序列化+可利用的类路径高风险\text{不可信输入} + \text{原生反序列化} + \text{可利用的类路径} \Rightarrow \text{高风险}

这里的“类路径”包括应用自身依赖和传递依赖。只检查业务 DTO 是否安全是不够的,因为反序列化可以到达对象图中的其他类型。

1. 反序列化过滤器是限制器,不是格式升级方案

Java 提供 ObjectInputFilter,可以按深度、引用数量、字节数、数组长度和类型过滤输入。下面是一个完整的小例子:

import java.io.*;
import java.util.Objects;

public class NativeFilterDemo {
    static final class Invoice implements Serializable {
        private static final long serialVersionUID = 1L;

        private final String id;
        private final int cents;

        Invoice(String id, int cents) {
            this.id = Objects.requireNonNull(id);
            this.cents = cents;
        }

        @Override
        public String toString() {
            return "Invoice[id=%s, cents=%d]".formatted(id, cents);
        }
    }

    public static void main(String[] args) throws Exception {
        Invoice original = new Invoice("inv-1", 1299);

        byte[] bytes;
        try (var output = new ByteArrayOutputStream();
             var objectOutput = new ObjectOutputStream(output)) {
            objectOutput.writeObject(original);
            objectOutput.flush();
            bytes = output.toByteArray();
        }

        try (var input = new ObjectInputStream(
                new ByteArrayInputStream(bytes))) {

            input.setObjectInputFilter(info -> {
                if (info.depth() > 10
                        || info.references() > 100
                        || info.streamBytes() > 1_000_000
                        || info.arrayLength() > 10_000) {
                    return ObjectInputFilter.Status.REJECTED;
                }

                Class<?> type = info.serialClass();
                if (type == null
                        || type == Invoice.class
                        || type == String.class) {
                    return ObjectInputFilter.Status.ALLOWED;
                }

                return ObjectInputFilter.Status.REJECTED;
            });

            Invoice restored = (Invoice) input.readObject();
            System.out.println(restored);
        }
    }
}

预期输出:

Invoice[id=inv-1, cents=1299]

每一步的作用是:

  1. ObjectOutputStreamInvoice 对象图写入字节数组;
  2. ObjectInputStream 准备读取该字节流;
  3. 过滤器先限制资源规模,降低深度、引用爆炸和超大数组风险;
  4. 过滤器只允许示例需要的 InvoiceString 类型;
  5. 通过过滤后才执行对象恢复。

这个过滤器不能把原生序列化变成跨语言协议,也不能修复类自身的危险 readObject。它的作用是缩小可接受输入范围。生产系统还需要考虑:

  • 是否应允许某些集合类;
  • 模块化应用中的类名和类加载器;
  • 过滤器是否覆盖所有入口;
  • 是否存在旧接口或第三方库绕过该入口;
  • 被拒绝后的告警、指标和恢复策略。

2. 不要只靠“来源可信”判断

文件、缓存、消息队列和内部 RPC 都可能成为不可信来源:

  • 文件可能被低权限进程替换;
  • 缓存可能被其他租户或错误配置污染;
  • 消息可能来自已被入侵的生产者;
  • 内部服务可能存在 SSRF、权限错误或版本混用。

“来自内网”不是安全属性,“来自本公司服务”也不是输入验证。


五、JSON 解决了哪些原生序列化问题

JSON 通常以对象、数组、字符串、数字、布尔值和 null 表示数据。例如:

{
  "id": "p-7",
  "amount": 12.50,
  "currency": "USD"
}

与原生序列化相比,JSON 的边界更清晰:

  • 文本可读;
  • 多数语言都有解析器;
  • 表示通常以字段名为中心,而不是 Java 类名;
  • 默认不会恢复任意 Java 对象身份;
  • 可以用 Schema 单独描述结构。

但 JSON 不是自动安全的。以下情况仍然有风险:

  • 解析器或依赖存在漏洞;
  • 开启了基于输入类型名的多态反序列化;
  • 将 JSON 字段直接拼接为 SQL、命令或脚本;
  • 对数字、深度、字符串长度没有限制;
  • 只解析语法,不验证结构和业务语义。

尤其要避免将外部 JSON 中的类型名直接映射到任意 Java 类。安全的做法是使用显式类型映射,例如把 "kind": "payment" 映射到代码中预先允许的 Payment,而不是让输入提供完整类名。

1. Java SE 不提供通用 JSON 数据绑定框架

Java 25 标准库提供原生对象序列化相关 API,但没有一个等价于 ObjectMapper 的通用 JSON 数据绑定 API。工程中通常使用 Jackson、JSON-B、Gson 或其他第三方库。

以 Jackson 风格的代码为例,下面展示一个 DTO 边界:

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;

import java.math.BigDecimal;

public class JsonBoundaryDemo {
    public record Payment(
            String id,
            BigDecimal amount,
            String currency) {}

    public static void main(String[] args) throws Exception {
        var mapper = JsonMapper.builder()
                .configure(
                    DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
                    false)
                .build();

        String json = """
                {
                  "id": "pay-1",
                  "amount": 12.50,
                  "currency": "USD",
                  "traceId": "t-9"
                }
                """;

        Payment payment = mapper.readValue(json, Payment.class);
        System.out.println(payment);
        System.out.println(mapper.writeValueAsString(payment));
    }
}

如果依赖和导入配置正确,输出类似:

Payment[id=pay-1, amount=12.50, currency=USD]
{"id":"pay-1","amount":12.50,"currency":"USD"}

这里 traceId 被忽略,是因为配置关闭了未知字段失败。这个选择不是普遍正确的:

  • 对于需要平滑升级的事件消费者,忽略未知字段常常有利于兼容;
  • 对于安全敏感的配置文件,未知字段可能意味着拼写错误或攻击载荷,应拒绝;
  • 对于审计系统,静默丢字段可能造成数据缺失。

JSON 数据绑定还要明确几个语义:

缺失字段与 null 不一定相同

{}

和:

{
  "email": null
}

在业务上可能分别表示:

  • 没有修改 email
  • 明确清空 email

如果 DTO 直接使用 String,这两种情况可能都落成 null。需要补丁语义时,应显式建模“缺失、存在且为空、存在且有值”三种状态,而不能依赖普通字段自动推断。

数字精度必须由契约决定

金额不应使用二进制浮点数作为边界语义:

double amount = 0.1 + 0.2;
System.out.println(amount); // 可能输出 0.30000000000000004

JSON 的数字语法不规定业务精度。金额可以选择:

  • 用整数最小货币单位,例如 1299 表示 12.99;
  • 用十进制定点数,并在 Schema 和代码中限制小数位;
  • 用字符串传输十进制金额,再由接收方使用 BigDecimal 解析。

JSON 只是传输格式,不会替应用自动解决精度。

枚举扩展会影响旧消费者

旧代码可能只有:

enum Status {
    CREATED, PAID
}

新生产者发送:

{"status":"REFUNDED"}

如果旧消费者对未知枚举直接失败,那么“添加一个枚举值”就会破坏新生产者到旧消费者的兼容性。可以将未知值映射为 UNKNOWN,也可以规定消费者必须忽略未识别值,但这需要代码和契约共同支持。


六、Schema 是可验证的外部契约

Schema 是对数据集合的描述。以 JSON Schema 为例,下面的 Schema 规定了支付消息的基本结构:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schema/payment-v1.json",
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "minLength": 1
    },
    "amount": {
      "type": "integer",
      "minimum": 0
    },
    "currency": {
      "type": "string",
      "pattern": "^[A-Z]{3}$"
    }
  },
  "required": ["id", "amount", "currency"],
  "additionalProperties": false
}

它表达了:

  • 顶层必须是对象;
  • idamountcurrency 必须存在;
  • id 至少有一个字符;
  • amount 是不小于 0 的整数;
  • currency 必须是三个大写字母;
  • 不允许未声明字段。

但它仍然不能表达所有业务约束。例如:

amount == 0 时,currency 是否必须为 XXX?
同一个 id 是否只能出现一次?
退款金额是否不能超过原支付金额?

这些需要业务代码、数据库约束或跨字段校验。

1. additionalProperties: false 的兼容性代价

假设旧消费者使用上面的闭合 Schema。新生产者增加字段:

{
  "id": "pay-1",
  "amount": 1250,
  "currency": "USD",
  "channel": "MOBILE"
}

旧消费者会因为 channel 是额外字段而拒绝整个消息。于是:

  • 对“允许未知字段”的消费者,新增可选字段通常较安全;
  • additionalProperties: false 的消费者,新增字段会破坏新生产者到旧消费者的兼容性。

这不是 JSON Schema 的错误,而是契约策略的结果。开放对象和闭合对象分别适合不同场景:

  • 开放对象更利于演进;
  • 闭合对象更利于检测拼写错误和阻止未声明数据;
  • 安全配置往往偏向闭合;
  • 长期演进的事件通常需要开放或显式扩展区。

2. Schema 校验应位于边界

推荐的处理顺序是:

接收原始字节
   │
   ▼
限制消息大小和解析资源
   │
   ▼
解析 JSON 语法
   │
   ▼
校验 Schema
   │
   ▼
映射到 DTO
   │
   ▼
执行业务规则
   │
   ▼
写入状态或发布下一个事件

顺序的因果关系很重要:

  • 在 JSON 解析前限制大小,可以避免解析器处理无限大的输入;
  • 在业务代码前校验 Schema,可以避免大量 null、错误类型进入业务层;
  • Schema 校验通过不代表业务操作一定成功;
  • 业务失败不能被简单归类为“格式错误”。

生产系统应记录至少以下信息:

  • 消息类型;
  • Schema 标识或版本;
  • 解析失败还是 Schema 失败;
  • 业务校验失败原因;
  • 原始消息的安全摘要或关联 ID。

通常不应在日志中无条件记录完整原始消息,因为其中可能包含密码、令牌或个人数据。


七、版本演进:先定义兼容方向,再决定字段变化

1. 生产者与消费者的兼容矩阵

设旧版本为 V1,新版本为 V2:

变化 V2 生产者 → V1 消费者 V1 生产者 → V2 消费者
增加可选字段,旧消费者忽略未知字段 通常兼容 通常兼容
增加必填字段 通常不兼容 新消费者必须提供默认或迁移
删除旧字段 旧消费者可能失败 新消费者必须允许缺失
改变字段类型 通常不兼容 通常不兼容
增加枚举值 取决于旧消费者是否允许未知值 通常可兼容
把字段改为 null 取决于旧消费者是否接受 null 取决于新消费者是否区分缺失
收紧数值范围 可能拒绝旧生产者产生的数据 可能拒绝历史数据
放宽数值范围 旧消费者可能无法处理新值 通常更容易兼容

“增加可选字段通常兼容”隐含两个条件:

  1. 旧消费者必须忽略未知字段;
  2. 新字段不能改变旧字段的解释。

如果旧消费者使用闭合 Schema,第一条不成立;如果新字段改变了 status 的业务含义,第二条也不成立。

2. 字段添加的完整例子

V1:

{
  "id": "pay-1",
  "amount": 1250
}

V2 增加可选的 currency

{
  "id": "pay-1",
  "amount": 1250,
  "currency": "USD"
}

要让旧消费者继续工作:

  • 旧消费者需要忽略 currency
  • amount 的含义必须保持不变;
  • 新生产者在与旧消费者通信时不能把 currency 变成必需前置条件。

要让新消费者处理 V1 数据:

{
  "id": "pay-1",
  "amount": 1250
}

新消费者必须定义缺失 currency 的行为,例如:

  • 采用协议外层已经约定的默认货币;
  • 将消息送入迁移队列;
  • 标记为无法结算;
  • 拒绝处理。

Schema 中写 default: "USD" 不一定会让所有验证器或数据绑定库自动填充字段。default 常常只是描述信息,是否真正注入默认值取决于具体工具。默认值必须在应用层有明确实现。

3. 删除字段前必须先停止依赖

删除字段不是一次代码提交就完成的。安全的演进通常至少需要两个阶段:

阶段 A:生产者停止依赖旧字段,但仍可发送它
阶段 B:消费者不再需要旧字段
阶段 C:才从协议中删除旧字段

如果生产者先删除字段,而某个旧消费者仍在使用,就会出现:

  • Schema 校验失败;
  • 映射得到 null
  • 业务规则失败;
  • 更隐蔽的默认值误用。

数据库迁移中的“先扩展、后收缩”原则同样适用于消息和文件格式。


八、原生序列化、JSON 和 Schema 的边界比较

维度 Java 原生序列化 JSON JSON + Schema
主要目标 保存和恢复 Java 对象图 跨语言文本表示 表示加结构契约
是否保存 Java 类型信息 是,格式与类描述相关 默认不保存 Java 类型 Schema 描述数据,不等于 Java 类
是否支持共享引用 支持对象句柄 普通 JSON 不支持 Schema 通常也不描述对象身份
可读性
跨语言能力 取决于各语言实现
版本演进 依赖类结构和 UID 依赖消费者容错 可通过契约检查变化
不可信输入风险 高,可能触发反序列化回调 解析和绑定层风险 仍需限制资源和业务校验
Java SE 原生支持 无通用数据绑定 API 无通用校验器

因此,选择不是“哪一种序列化绝对更好”,而是边界目标不同:

  • 进程内部、短期、完全受控的 Java 对象缓存,原生序列化仍可能存在使用场景,但必须明确输入来源和版本策略;
  • HTTP、消息队列、跨语言服务通常更适合 JSON 或其他明确的协议格式;
  • 需要长期保存、多人协作或独立部署的消息,应使用外部 Schema 或等价契约;
  • 需要高吞吐和严格二进制契约时,也可以选择带 Schema 的其他协议,但那属于另一种编码格式,不应误认为 JSON。

九、常见失败表现与诊断路径

1. InvalidClassException

常见原因:

  • 流中的 serialVersionUID 与当前类不一致;
  • 字段类型发生不兼容变化;
  • 当前类不再满足序列化要求;
  • 读取了错误版本或错误类型的数据。

诊断时应先确认:

数据产生时间
生产者构建版本
消费者构建版本
类的完整名称
流中的 serialVersionUID
当前类的 serialVersionUID

不要通过随意修改 UID 来“修复”问题。这样可能让读取继续,却把不兼容数据伪装成兼容数据,最终在业务层产生错误状态。

2. ClassNotFoundException

当前 JVM 找不到流中记录的类。原因可能是:

  • 依赖被删除;
  • 类移动了包名;
  • 类加载器不同;
  • 反序列化发生在不包含原生产者依赖的服务中。

这正是原生序列化不适合作为跨服务长期协议的原因之一:接收方不仅要理解数据,还要能加载发送方的 Java 类型。

3. StreamCorruptedExceptionOptionalDataException

这些异常通常指向:

  • 输入不是合法的 Java 序列化流;
  • 写入和读取顺序不一致;
  • 同一个流混用了对象和原始数据,但读取顺序不同;
  • 数据被截断或拼接;
  • 传输层修改了字节。

排查时要验证完整字节边界。特别是在网络和消息系统中,不能假设一次读取就得到完整消息,必须依赖明确的长度、帧或消息边界。

4. JSON 解析成功但业务失败

下面的 JSON 可能语法完全正确:

{
  "id": "",
  "amount": -1,
  "currency": "usd"
}

但它可能分别违反:

  • id 非空约束;
  • 金额不小于零;
  • 货币代码必须大写。

因此诊断日志应区分:

语法解析失败
Schema 校验失败
DTO 映射失败
业务规则失败
外部依赖失败

把所有错误都记录为“JSON 解析失败”,会直接损失定位信息。


十、版本化不应只靠一个整数

在消息或持久化格式中,版本信息可以通过多种方式表达:

1. 类型名或媒体类型

例如 HTTP 使用:

Content-Type: application/vnd.example.payment+json

也可以在消息信封中携带:

{
  "type": "payment",
  "schema": "payment.v2",
  "payload": {
    "id": "pay-1",
    "amount": 1250,
    "currency": "USD"
  }
}

信封版本用于选择解析器,Payload Schema 用于校验字段。这两者职责不同:

  • 信封回答“这是什么消息、应该用哪个契约”;
  • Schema 回答“消息内部是否满足该契约”。

2. 显式版本字段不是万能开关

{
  "version": 2,
  "id": "pay-1",
  "amount": 1250
}

版本字段只能帮助选择处理逻辑,不能自动保证兼容。如果 V2 的 amount 从“分”为单位改成“元”,即使版本字段正确,接收方仍然必须执行语义迁移。

3. 迁移应尽量在边界完成

可以把多版本输入统一转换为内部模型:

V1 JSON ──解析器 V1──┐
                      ├── InternalPayment ──业务逻辑
V2 JSON ──解析器 V2──┘

业务层不应到处出现:

if (version == 1) { ... }
else if (version == 2) { ... }

版本分支越靠近输入边界,内部模型越稳定,测试范围也越清晰。输出时同样应根据目标消费者或协议版本生成对应表示,而不是直接序列化内部对象。


十一、生产取舍:何时使用哪种边界

适合考虑 Java 原生序列化的场景

必须同时满足较强条件:

  • 数据只在受控 Java 环境中使用;
  • 输入来源可信且边界封闭;
  • 生命周期短;
  • 生产者和消费者版本可协同升级;
  • 已明确处理 serialVersionUID 和迁移;
  • 已配置反序列化过滤;
  • 不要求跨语言或长期可读。

即便如此,也应把它看成 Java 内部实现格式,而不是公共协议。

更适合使用 JSON 的场景

  • HTTP API;
  • 跨语言服务;
  • 运维和人工排查需要可读性;
  • 数据字段希望与 Java 类解耦;
  • 消费者需要渐进式升级。

但 JSON 仍需要:

  • 明确字段语义;
  • 限制输入规模;
  • 避免任意类型绑定;
  • 处理缺失、null、数字精度和未知字段;
  • 必要时配套 Schema。

更适合使用 Schema 的场景

  • 消息队列和事件流;
  • 长期保存的文件;
  • 多团队共同维护的接口;
  • 需要在发布前检查兼容性;
  • 需要生成文档、代码或测试数据。

Schema Registry 或兼容性检查工具可以自动比较 Schema,但它们不能推导所有业务兼容性。例如,以下变化可能在结构上合法,却在业务上破坏兼容:

amount 的单位从“分”改成“元”
status 的含义从“已支付”改成“已完成”
timestamp 的时区解释改变
id 的生成规则改变

结构兼容不等于语义兼容。


十二、最终边界原则

Java 原生序列化把 Java 类结构和对象图带入了数据边界,因此便利性伴随着强耦合和反序列化风险。固定 serialVersionUID 能控制一部分版本行为,但不能替代迁移设计,也不能降低不可信输入的本质风险。

JSON 把数据表示从 Java 类中分离出来,改善了可读性和跨语言能力,但 JSON 本身只有语法,不提供完整契约。Schema 为字段、类型、范围和额外属性提供可验证约束,却仍然不能独立表达全部业务语义。

一个可靠的序列化边界应明确回答:

  1. 输入是否可信;
  2. 是否允许恢复任意对象类型;
  3. 数据是短期缓存还是长期协议;
  4. 新生产者和旧消费者是否需要并存;
  5. 缺失字段、null、未知字段和新枚举值如何处理;
  6. 结构校验和业务校验分别在哪里执行;
  7. 失败后如何记录、隔离、重试和迁移。

当边界是公共接口、跨服务消息或长期存储时,通常应传输显式的数据模型,并用 Schema 管理演进;当边界只是受控 JVM 内部的短期实现细节时,原生序列化才可能成为可接受的工具。关键不在于“能不能序列化”,而在于是否清楚地控制了表示、执行、契约和版本之间的边界。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。