第4章 序列化与反序列化踩坑:JDK序列化、JSON/ProtoBuf对比、类变更导致的兼容性

在缓存工程落地中,序列化与反序列化是连接对象世界与字节世界的桥梁。每一次缓存写入都是一次序列化,每次缓存读取都是一次反序列化。选择不当的序列化方案或忽略类变更兼容性,轻则导致性能锐减,重则引发全线反序列化失败。本章聚焦JDK原生序列化、JSON与ProtoBuf在缓存场景下的实战对比,以及类版本演进引发的兼容性陷阱。

核心对比:三大序列化方案

缓存序列化选型建议:外部缓存(Redis/Memcached)严禁使用JDK序列化。内部进程缓存(Caffeine)可根据场景选择JDK或JSON。跨服务、高吞吐缓存优先选用ProtoBuf。

一、JDK原生序列化:遗留系统的性能杀手

JDK序列化通过实现Serializable接口工作,其核心依赖serialVersionUID进行版本校验。

// 原始类 V1
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
}

// 类结构变更后 V2
public class User implements Serializable {
    private static final long serialVersionUID = 2L; // UID变更
    private String name;
    private int age;
}

// 反序列化V1写入的数据时会报错:
// java.io.InvalidClassException: User; local class incompatible:
// stream classdesc serialVersionUID = 1, local class serialVersionUID = 2

踩坑点:即使只是新增一个无参字段,如果没有显式声明serialVersionUID或忘记更新,就会导致整个缓存集群的数据不可用。此外,JDK序列化将完整的类描述写入流中,导致占用Redis带宽激增,且存在严重的安全缺陷(反序列化漏洞)。

二、JSON序列化:兼容性与可读性的平衡点

JSON在缓存工程中应用最广泛,以Jackson为例,其核心陷阱在于类型擦除和未知字段处理。

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;

ObjectMapper mapper = new ObjectMapper();
// 关键配置:反序列化时忽略未知字段,避免因类移除字段导致崩溃
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

// 序列化:不同微服务版本写入缓存
UserV1 v1 = new UserV1("张三");
mapper.writeValueAsString(v1);

// 反序列化:升级后的V2类无age字段即可被忽略
UserV2 v2 = mapper.readValue(cachedJson, UserV2.class);

踩坑点:泛型类型擦除导致反序列化失败(如List<User>变成List<LinkedHashMap>),必须使用TypeReference。日期格式不一致会导致解析异常,需全局统一DateFormat。

生产案例:某团队使用Redis缓存用户信息,类版本V1有字段phone,V2重命名phone为mobile。由于未配置FAIL_ON_UNKNOWN_PROPERTIES,反序列化V1数据时直接抛出UnrecognizedPropertyException,导致用户查询接口全量报错。

三、ProtoBuf序列化:高并发缓存的工业标准

ProtoBuf通过定义.proto文件生成代码,其兼容性依赖字段编号而非字段名。

// user.proto V1
message User {
    int32 id = 1;
    string name = 2;
}

// user.proto V2 (新增字段)
message User {
    int32 id = 1;
    string name = 2;
    string email = 3; // 新字段,旧版本反序列化自动忽略
}

兼容性原理:ProtoBuf按编号匹配字段,新增字段不影响旧版本。但删除字段时需保留编号(用reserved标记),否则会引发数据错乱。

// 正确的演进方式:保留已删除字段的编号
message User {
    reserved 3; // 原来email字段占用了编号3
    int32 id = 1;
    string name = 2;
}

四、类变更兼容性深度解析

缓存中的数据生命周期往往比应用实例更长,类结构的演进必须考虑已存在缓存的版本。

  • 新增字段:JSON需要配置忽略未知字段;ProtoBuf天然兼容,旧版读取新数据会忽略新字段。
  • 删除字段:JSON直接删除后,缓存中遗留的数据会导致反序列化抛异常(除非配置忽略);ProtoBuf必须使用reserved占位编号。
  • 修改字段类型:JSON和ProtoBuf都会失败,必须谨慎操作。
  • 改名字段:JSON完全无法兼容(除非自定义反序列化器);ProtoBuf依赖编号,名称仅作语义,改名称仍兼容。

最佳实践:

  1. 外部缓存(Redis)一律禁止使用JDK序列化。
  2. JSON序列化始终开启FAIL_ON_UNKNOWN_PROPERTIES = false。
  3. ProtoBuf使用reserved管理废弃字段,避免编号冲突。
  4. 对缓存键增加版本号(如user:v1:123),便于全量迁移。
维度JDK序列化JSON (Jackson)ProtoBuf
字节体积极大(包含类结构元数据)中等(文本格式,随字段名重复膨胀)极小(二进制,按编号映射字段)
序列化/反序列化速度慢(反射+递归遍历对象图)中等(反射生成/解析树)快(基于代码生成、直接读写字节)
人类可读性不可读(二进制乱码)可读(纯文本)不可读(二进制)
跨语言支持仅限Java全语言(JSON标准)全语言(需.proto定义)
向后/向前兼容极差(依赖serialVersionUID严格匹配)较好(配置忽略未知字段)极好(按字段编号兼容,天然支持演进)