☰
序列化与反序列化深度解析:从Java、JSON到Redis的工程实践与安全漏洞
2026/9/27 6:17:17 网站建设 项目流程

序列化和反序列化这两个词,很多人第一次听到是在面试题里,第二次听到是在安全漏洞通告里,第三次听到是在自己写的接口突然报错的时候。我带了几年后端团队,发现一个挺有意思的现象:几乎所有人都能背出“序列化是把对象变成字节流,反序列化是把字节流变回对象”这句话,但真到了线上出问题,能顺着这条链路把根因定位出来的人并不多。比如缓存里读出来的对象字段全是 null、RPC 调用报 InvalidClassException、接口返回的 JSON 里中文变成了问号、日志里出现一串看不懂的二进制——这些问题的背后,往往都站着序列化这个角色。

这篇内容我想把序列化这件事从头到尾讲透。不是停留在概念层面,而是把它在 Java、PHP、JSON、Redis、数据库这些真实场景里的表现一个个拆开,讲清楚它到底在做什么、为什么这么设计、哪里容易出坑、以及为什么它会成为安全领域一个绕不开的话题。不管你是刚接触后端的新手,还是已经写过几年业务代码、想补一补底层认知的老手,应该都能从里面找到对自己有用的部分。我会尽量用生活化的类比把抽象概念落地,同时把关键参数、代码示例、排查思路都给到位,让你看完能直接上手用。

1. 序列化到底在解决一个什么问题

1.1 从“对象活不过一次请求”说起

先抛开定义,想一个最朴素的场景。你在 Java 里写了一个User对象,里面有 id、name、age 三个字段,在内存里它活得好好的。但这个对象有个天生的局限:它只存在于当前这个进程的内存里,而且只存在于当前这次请求的生命周期内。请求一结束,对象就被垃圾回收了。如果我想把这个 User 存到硬盘上,下次启动还能读出来;或者想通过网络发给另一台机器上的服务;又或者想塞进 Redis 缓存里让别的请求也能用——这时候问题就来了:内存里的对象是一块带有类型信息、引用关系、甚至方法指针的复杂结构,硬盘和网络只认字节。你怎么把这块“活”的结构,变成一串可以搬运、可以存储的“死”字节,再在需要的时候把它“复活”?

序列化就是干这个的。它定义了一套规则,把内存中的对象状态转换成一个线性的字节序列,这个序列可以被写入文件、通过网络传输、存进缓存。反序列化则是逆过程,拿着这串字节,按照同样的规则,重新在内存里构造出一个等价的对象。你可以把它理解成“给对象拍一张可以邮寄的照片”:照片是扁平的、可运输的,收到照片的人按照片重新搭出一个一模一样的模型。

这里有个关键点很多人会忽略:序列化保存的是对象的“状态”,不是对象的“行为”。也就是说,对象的字段值会被保存,但对象的方法、类里的静态变量这些不会。反序列化出来的对象,方法是从类定义里来的,字段值才是从字节流里恢复的。理解这一点,后面很多坑就顺了。

1.2 序列化协议的三层结构

一个完整的序列化方案,其实包含三个层次,很多人把它们混在一起谈,导致概念模糊。

第一层是数据格式层,也就是字节流长什么样。比如 Java 原生序列化会写入魔数0xACED、版本号、类描述符、字段描述符、字段值等;JSON 则是一段文本,用花括号和引号组织;Protocol Buffers 是紧凑的二进制,用字段编号加类型加值的方式排列。这一层决定了序列化后的数据可读性、体积大小、跨语言能力。

第二层是类型映射层,解决“内存里的类型怎么对应到字节流里的类型”。Java 的int是 4 字节,long是 8 字节,String是变长的;而 JSON 里数字不区分 int 和 long,字符串统一用引号。这个映射关系如果设计得不好,就会出现精度丢失、类型不匹配的问题。比如一个long类型的雪花 ID 序列化成 JSON 数字,前端 JavaScript 的 Number 精度只有 53 位,超过就会丢精度,这就是为什么很多接口要把 ID 序列化成字符串。

第三层是对象图处理层,处理对象之间的引用关系。如果 A 对象引用了 B,B 又引用了 A,直接递归序列化就会无限循环。所以成熟的序列化框架都要处理循环引用、共享引用、父类字段、transient 字段这些问题。这一层是最容易出 bug 的地方,也是不同框架差异最大的地方。

把这三层分清楚,你在选型或者排查问题时就能快速定位:是格式不对、类型不对,还是对象图处理不对。

1.3 为什么不能“随便序列化一下就行”

有人会想,不就是把对象变成字节吗,我随便拼一拼不就行了。理论上可以,但实际工程里不行,原因有几个。

一是兼容性。你今天序列化了一个对象存进 Redis,明天给类加了一个字段,后天又删了一个字段。老数据还在缓存里,新代码去反序列化,如果协议不支持向前向后兼容,直接报错。生产环境里缓存和消息队列里的数据往往跨越多个版本,兼容性是刚需。

二是性能。序列化反序列化是高频操作,一次接口调用可能涉及多次序列化。如果每次都要反射遍历所有字段、拼字符串、做类型转换,开销很可观。这也是为什么像 Protobuf、FlatBuffers 这类二进制协议在高性能场景里受欢迎。

三是安全性。反序列化本质上是“根据外部输入的字节流,在内存里构造对象”。如果这个构造过程可以被攻击者控制,他就能构造出你意料之外的对象,触发意料之外的行为。这就是反序列化漏洞的根源,后面会专门讲。

四是跨语言。微服务架构下,Java 服务可能要和 Go 服务、Python 服务通信,Java 原生序列化的字节流别的语言根本读不懂。所以跨语言场景必须选 JSON、Protobuf 这类语言中立的协议。

2. Java 原生序列化:最经典也最容易踩坑的方案

2.1 Serializable 接口背后的机制

Java 里让一个类可序列化,最简单的方式就是实现java.io.Serializable接口。这个接口是个空接口,没有任何方法,它的作用只是给 JVM 打个标记:“这个类允许被序列化”。真正干活的是ObjectOutputStream和ObjectInputStream。

public class User implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String name; private transient String password; // getter/setter 省略 }

序列化的时候,ObjectOutputStream.writeObject(user)会做这么几件事:先写入魔数和版本号,然后写入类的描述信息(类名、serialVersionUID、字段列表),再写入各个字段的值。反序列化时,ObjectInputStream.readObject()读取这些信息,找到对应的类,创建实例,把字段值填回去。

这里有个细节:反序列化创建对象时,不会调用类的构造函数。JVM 通过一种叫“反射 + Unsafe”的机制直接分配内存并填充字段。这一点非常关键,因为它意味着构造函数里的校验逻辑、初始化逻辑在反序列化时全部被跳过。很多安全漏洞就是利用这一点,构造出一个字段值完全违反业务规则的对象。

2.2 serialVersionUID:那个被无数人忽略的字段

serialVersionUID是 Java 原生序列化里最重要的一个字段,也是最容易被忽略的。它的作用是标识类的版本。序列化时会把 serialVersionUID 写进字节流,反序列化时会拿字节流里的值和当前类的值比较,如果不一致就抛InvalidClassException。

如果你不显式声明,Java 会根据类的结构(类名、字段、方法等)自动生成一个。问题在于,你只要改一下类,比如加个字段、改个方法,自动生成的值就变了,之前序列化的数据全部反序列化失败。所以生产代码里,只要实现了 Serializable,就应该显式声明 serialVersionUID,并且把它当成一个需要维护的契约。

我见过一个真实的故障:某服务把用户会话对象序列化后存进 Redis,某次发版给这个类加了一个日志字段,没注意 serialVersionUID,结果上线后所有用户的会话全部失效,被迫紧急回滚。这个坑的代价是实打实的。

2.3 transient 与静态字段的处理

transient关键字修饰的字段不会被序列化。这通常用于敏感信息(如密码)或者可以从别处推导出来的字段。反序列化后,这些字段会是类型的默认值(对象是 null,int 是 0)。

静态字段也不会被序列化,因为序列化针对的是对象实例的状态,静态字段属于类。这一点经常被误解,有人以为给静态字段加 transient 有用,其实加不加都一样。

还有一个容易踩的点:如果父类没有实现 Serializable,但子类实现了,那么父类的字段不会被序列化,反序列化时父类字段会走父类的无参构造函数来初始化。如果父类没有无参构造函数,反序列化就会失败。这个规则在继承体系里很容易出问题,建议要么整个继承链都实现 Serializable,要么就避免在需要序列化的类里做复杂继承。

2.4 Java 原生序列化的几个硬伤

Java 原生序列化虽然用起来简单,但在现代工程里问题不少。

体积大。它会把完整的类描述信息写进字节流,同一个类的多个对象序列化时,第一个对象带完整描述,后续对象用引用指回,但整体还是比 JSON、Protobuf 大很多。

性能一般。反射加流式读写,在高频场景下开销明显。有基准测试显示,Java 原生序列化的吞吐量通常只有 Protobuf 的几分之一。

跨语言能力为零。别的语言读不懂 Java 的字节流,微服务场景基本用不了。

安全风险高。这是最严重的问题。反序列化时会根据字节流里的类名去加载类,如果攻击者能控制字节流,就能加载任意可用的类,触发危险方法。这就是 Java 反序列化漏洞的核心。

版本兼容脆弱。前面说的 serialVersionUID 问题,加上字段增删的处理不够灵活,导致它在需要长期存储的场景里很难维护。

所以现在的实践里,Java 原生序列化基本只用在进程内或者临时场景,比如某些框架的内部通信、Session 复制(还得配合特定容器)。对外接口、缓存、消息队列,几乎都换成了 JSON 或二进制协议。

3. JSON 序列化:当下最主流的跨语言方案

3.1 为什么 JSON 能成为事实标准

JSON 能流行起来,核心原因是它同时满足了几个条件:文本格式人类可读、结构简单、语言中立、浏览器原生支持。它不需要任何额外的解析库就能被 JavaScript 直接JSON.parse,这让前后端交互变得极其自然。

JSON 的数据类型只有六种:对象、数组、字符串、数字、布尔、null。这个极简的类型系统既是优点也是缺点。优点是任何语言都能轻松映射;缺点是它无法表达一些语言特有的类型,比如 Java 的Date、BigDecimal、枚举、集合的具体实现类。这些都需要序列化框架额外处理。

3.2 Jackson 与 Fastjson 的选型对比

Java 生态里 JSON 库很多,最主流的是 Jackson 和 Fastjson(以及它的后续版本 Fastjson2)。两者各有特点。

Jackson 是 Spring 生态的默认选择,功能全面、扩展性好、社区活跃。它的ObjectMapper是线程安全的,可以全局复用。配置上支持各种注解,比如@JsonProperty改字段名、@JsonFormat格式化日期、@JsonIgnore忽略字段。

Fastjson 在国内用得多,主要优势是快。但历史上出过多次严重的反序列化漏洞,导致很多团队对它心有余悸。Fastjson2 在安全性和性能上都做了改进,如果要用,建议直接用 Fastjson2,并且开启安全模式。

选型上我的建议是:新项目优先 Jackson,生态成熟、坑少;如果对性能有极致要求且能接受维护成本,可以考虑 Fastjson2 或 Protobuf。不要因为“听说 Fastjson 快”就盲目选它,序列化往往不是系统的性能瓶颈,稳定和安全更重要。

3.3 日期、枚举、大数字的序列化陷阱

JSON 序列化里最容易出问题的三类数据:日期、枚举、大数字。

日期。Java 的Date默认会被 Jackson 序列化成时间戳(毫秒数),而前端往往期望yyyy-MM-dd HH:mm:ss格式。解决办法是加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")。时区问题尤其要注意,服务器时区和客户端时区不一致时,同一个时间戳显示出来会差好几个小时。统一用 UTC 存储、按需转换是更稳妥的做法。

枚举。默认序列化成枚举的 name,反序列化时按 name 匹配。如果枚举改了名字,老数据就反序列化失败。更稳妥的做法是给枚举加一个稳定的 code 字段,用@JsonValue指定序列化用 code,用@JsonCreator指定反序列化按 code 查找。

大数字。前面提过的雪花 ID 问题。Long类型的 ID 超过 2^53 后,JavaScript 解析会丢精度。解决方案是在序列化时把 Long 转成 String,可以用@JsonSerialize(using = ToStringSerializer.class)标注字段,或者全局配置。

public class Order { @JsonSerialize(using = ToStringSerializer.class) private Long orderId; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date createTime; @JsonValue private Integer statusCode; }

3.4 循环引用与 @JsonIgnore 的正确用法

对象之间有双向引用时,JSON 序列化会无限递归,最终栈溢出。比如订单引用用户,用户又引用订单列表。Jackson 会抛StackOverflowError或者JsonMappingException: Infinite recursion。

解决办法有几种。最简单的是在其中一个方向加@JsonIgnore,切断递归。但这样会导致那个字段不输出,如果前端需要这个字段就不行。更好的方式是用@JsonManagedReference和@JsonBackReference配对,前者正常序列化,后者在序列化时忽略,反序列化时又能恢复。或者用@JsonIdentityInfo给对象加唯一标识,重复出现时用引用代替。

实际项目里,我倾向于用 DTO(数据传输对象)来隔离,实体类之间的复杂引用关系不直接暴露给序列化层。这样既避免了循环引用,也让接口契约更清晰。

4. Redis 序列化:缓存场景下的关键选择

4.1 RedisTemplate 的默认序列化为什么让人头疼

用 Spring Data Redis 的时候,很多人第一次用RedisTemplate存对象,然后去 redis-cli 里get一下,发现是一串带乱码的二进制,完全看不懂。这是因为RedisTemplate默认用的是JdkSerializationRedisSerializer,也就是 Java 原生序列化。存进去的 key 和 value 都是 Java 序列化后的字节,可读性差,而且跨语言完全没法用。

更麻烦的是,如果你用默认序列化存了数据,后来想换成 JSON 序列化,老数据就读不出来了,因为格式不兼容。所以项目一开始就要把序列化方式定好。

4.2 StringRedisSerializer 与 Jackson2JsonRedisSerializer 的组合配置

比较通用的做法是:key 用StringRedisSerializer,保证 key 在 redis-cli 里可读;value 用GenericJackson2JsonRedisSerializer或Jackson2JsonRedisSerializer,把对象存成 JSON。

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }

GenericJackson2JsonRedisSerializer会在 JSON 里额外写入@class字段记录类型信息,反序列化时能自动还原成原来的类型。这很方便,但有两个注意点:一是它会信任 JSON 里的@class,如果缓存被篡改,可能反序列化出危险对象,所以要用activateDefaultTyping配合白名单;二是@class会增加存储体积。

如果追求更小的体积和更强的类型控制,可以用Jackson2JsonRedisSerializer并指定具体类型,但这样每个类型都要单独配置,灵活性差一些。

4.3 缓存穿透、雪崩之外,序列化引发的缓存故障

大家谈缓存问题总说穿透、雪崩、击穿,但序列化引发的故障同样常见,而且更隐蔽。

一种是类结构变更导致的反序列化失败。缓存里的老数据是按旧类结构序列化的,新代码改了字段,反序列化时要么报错,要么字段丢失。如果代码里没做好异常处理,一次缓存读取失败可能直接导致接口 500。

另一种是序列化方式不一致。比如 A 服务用 JSON 存,B 服务用 JDK 序列化读,读出来全是乱码。多服务共用缓存时,序列化协议必须统一,最好写进团队规范。

还有一种是大对象序列化耗时。缓存里存了一个包含几千个元素的 List,每次读取都要反序列化,CPU 飙升。这种情况要考虑拆分缓存或者换更高效的序列化方式。

我的经验是:缓存里的数据尽量用简单的 DTO,字段少、结构扁平;序列化方式在项目初期就统一;读取缓存的地方一定要有降级逻辑,反序列化失败时回源查数据库,而不是直接抛异常。

5. 反序列化漏洞:为什么它是最危险的漏洞类型之一

5.1 反序列化漏洞的本质:信任了不该信任的数据

反序列化漏洞的根源,用一句话概括就是:程序把外部输入的、不可信的字节流,当成了可信的对象构造指令。

正常的反序列化流程是:读取字节流 → 解析出类名和字段 → 加载类 → 创建实例 → 填充字段。如果攻击者能控制字节流,他就可以指定任意一个 classpath 里存在的类,让程序去创建它。而有些类在创建或者后续方法调用时,会执行危险操作,比如执行命令、读写文件、发起网络请求。攻击者把这些类串联起来,就能构造出一条从反序列化入口到危险操作的“利用链”(gadget chain)。

这就是为什么反序列化漏洞危害极大:它往往能直接导致远程代码执行,而且利用方式隐蔽,流量上看不出明显特征。

5.2 Java 反序列化漏洞的典型链路

Java 反序列化漏洞的经典案例,是利用 Commons Collections 这类基础库里的类。这些类本身是正常工具类,但它们的某些方法在特定调用顺序下会触发反射调用,攻击者通过精心构造的对象图,让反序列化过程自动走到Runtime.exec这类方法。

链路大致是这样的:反序列化入口(比如某个接收序列化数据的接口)→ 触发某个类的readObject方法 → 该方法内部调用了transform链 → 最终通过反射执行命令。整个过程不需要程序显式调用危险方法,全靠对象图在反序列化时的副作用。

防御的核心思路是:不要反序列化不可信的数据。如果业务上必须接收序列化数据,就要做严格的类型白名单,只允许反序列化预期的类。Java 提供了ObjectInputFilter机制,可以在反序列化时校验类名。

ObjectInputStream ois = new ObjectInputStream(inputStream); ois.setObjectInputFilter(info -> { Class<?> clazz = info.serialClass(); if (clazz != null && !ALLOWED_CLASSES.contains(clazz.getName())) { return ObjectInputFilter.Status.REJECTED; } return ObjectInputFilter.Status.ALLOWED; });

5.3 PHP 反序列化漏洞与魔术方法

PHP 的反序列化漏洞机制和 Java 不同,但本质一样。PHP 用serialize()把对象转成字符串,用unserialize()还原。PHP 的类里有一批“魔术方法”,比如__wakeup()、__destruct()、__toString(),它们在对象被反序列化、销毁、转字符串时自动调用。如果这些方法里有危险操作,攻击者就能通过构造序列化字符串来触发。

PHP 序列化字符串的格式是O:类名长度:"类名":字段数量:{字段名;字段值;...}。攻击者可以手工构造这个字符串,指定任意类名和字段值。如果目标代码里存在一个类,它的__destruct会删除文件或者执行命令,那么构造这个类的序列化字符串就能触发漏洞。

防御 PHP 反序列化漏洞,核心也是不要unserialize用户输入。如果必须用,可以用allowed_classes参数限制允许的类:

$data = unserialize($input, ['allowed_classes' => ['SafeClass']]);

5.4 从漏洞案例反推安全编码习惯

看了这么多漏洞,其实能总结出几条通用的安全习惯。

第一,永远不要反序列化不可信数据。用户提交的数据、URL 参数、Cookie、HTTP 头,这些都不能直接丢给反序列化函数。如果业务需要传递结构化数据,用 JSON 并做 schema 校验,比用原生序列化安全得多。

第二,最小化可反序列化的类范围。用白名单机制,只允许业务真正需要的类被反序列化。Java 的ObjectInputFilter、PHP 的allowed_classes、Python 的RestrictedUnpickler都是这个思路。

第三,及时升级依赖。很多反序列化漏洞的利用链依赖特定版本的第三方库,升级到修复版本能堵住已知链路。但要注意,升级只是缓解,不是根治,因为新的利用链可能被发现。

第四,在架构上隔离。把处理不可信数据的服务单独部署,限制它的权限,即使被攻破也影响有限。这是纵深防御的思路。

6. 跨语言与高性能场景下的序列化选型

6.1 Protobuf、Thrift、Avro 的定位差异

当系统规模变大、跨语言通信变多、性能要求变高时,JSON 就不够用了。这时候要考虑二进制序列化协议。

Protobuf是 Google 的方案,需要先写.proto文件定义消息结构,然后用工具生成各语言的代码。它的优点是体积小、解析快、有强类型约束、向前向后兼容做得好。缺点是 schema 变更需要重新生成代码,灵活性不如 JSON。

Thrift是 Apache 的方案,除了序列化还包含 RPC 框架,功能更全。它的 IDL 定义和代码生成机制和 Protobuf 类似,适合构建完整的服务通信体系。

Avro的特点是 schema 和数据的分离,序列化时可以不写 schema,靠读写双方约定。它在大数据生态里用得多,比如 Kafka 的消息格式。

选型上,如果是微服务 RPC,Protobuf 或 Thrift 都行;如果是大数据管道,Avro 更合适;如果只是想让 JSON 更紧凑,可以考虑 MessagePack 或 CBOR,它们保留了 JSON 的灵活性但体积更小。

6.2 序列化性能的实测维度

评估一个序列化方案,不能只看“快不快”,要分几个维度看。

维度说明影响
序列化速度对象转字节的耗时影响写入性能
反序列化速度字节转对象的耗时影响读取性能
序列化体积字节流大小影响网络和存储成本
兼容性字段增删的支持影响版本迭代
跨语言支持的语言数量影响技术栈选择
安全性反序列化风险影响系统安全

实际测试时,要用真实的数据结构,而不是简单的 POJO。嵌套对象、大集合、字符串占比高的数据,不同协议的表现差异很大。我做过一组测试,对于包含大量字符串的对象,Protobuf 的体积只有 JSON 的三分之一左右,反序列化速度快两到三倍;但对于字段很少的小对象,差距没那么明显。

6.3 选型决策的实用清单

面对一个具体项目,怎么选序列化方案?我一般按这个顺序问自己几个问题。

第一,数据要不要跨语言?要,就排除 Java 原生序列化,在 JSON 和二进制协议里选。不要,可以考虑原生序列化,但也要权衡安全和兼容。

第二,数据要不要长期存储?要,就必须考虑兼容性,选支持 schema 演进的方案,比如 Protobuf、Avro,或者用 JSON 但做好字段的兼容处理。

第三,性能是不是瓶颈?先用 JSON 跑起来,压测发现序列化确实是瓶颈了,再换二进制协议。不要过早优化。

第四,团队能不能维护?Protobuf 需要维护.proto文件和代码生成流程,如果团队没有相应经验,引入成本不低。JSON 几乎没有学习成本。

第五,安全要求高不高?处理不可信数据的场景,坚决不用原生序列化,JSON 也要注意深度限制和类型校验。

7. 实操中那些文档不会告诉你的细节

7.1 序列化 ID 的生成与维护策略

前面说了 serialVersionUID 要显式声明,但具体填什么值有讲究。有人填1L,有人用 IDE 自动生成的一长串。我的建议是:新类统一用1L,后续除非有明确的兼容性需求,否则不要改。因为 serialVersionUID 的作用是标识“这个类的序列化格式版本”,只要你没有改变字段的含义,加字段、加方法都不应该改它。改了反而会导致老数据读不出来。

如果确实需要做不兼容的变更,比如删除了一个关键字段、改变了字段类型,那就应该换一个新的类名或者新的缓存 key,而不是靠改 serialVersionUID 来“强制失效”。后者会让老数据变成一堆无法读取的垃圾。

7.2 序列化字段顺序与兼容性

JSON 本身不依赖字段顺序,但有些二进制协议依赖。Protobuf 用字段编号来标识字段,所以字段顺序无关,但字段编号一旦使用就不能改。这就是为什么 Protobuf 的.proto文件里,删除字段时要保留编号(用reserved标记),防止后来的人复用导致数据错乱。

Java 原生序列化对字段顺序敏感吗?实际上它写入的是字段名和值,反序列化时按名字匹配,所以加字段、删字段在 serialVersionUID 不变的情况下是能兼容的:新字段用默认值,老字段被忽略。但字段类型改变会失败。

7.3 大对象序列化的内存与 GC 影响

序列化大对象时,会在内存里产生一份完整的字节数组。如果对象有几十 MB,序列化瞬间内存占用会翻倍,可能触发 Full GC,甚至 OOM。这个坑在导出报表、批量处理数据时特别常见。

应对办法有几个:一是分页处理,不要一次性序列化整个大集合;二是用流式序列化,边读边写,避免在内存里拼完整的字节数组;三是限制单次序列化的数据量,超过阈值就拒绝或拆分。

Jackson 提供了JsonGenerator可以流式写 JSON,Protobuf 也有writeDelimitedTo这类流式接口。在数据量大的场景,流式处理是必须的。

7.4 序列化异常的处理与降级

序列化和反序列化都可能抛异常,比如NotSerializableException、JsonParseException、InvalidClassException。这些异常如果没处理好,会直接冒泡到接口层,变成 500 错误。

我的做法是:在序列化的边界做统一封装,捕获异常并转换成业务可理解的错误。对于反序列化,尤其是从缓存或消息队列读取的场景,一定要有降级逻辑。比如缓存反序列化失败,就删掉这个 key 并回源查数据库;消息反序列化失败,就把消息投到死信队列,人工排查,而不是让消费线程一直报错。

还有一点:日志里打印序列化失败的对象时,要小心。如果对象很大,直接toString可能又触发一次序列化,或者打印出敏感信息。最好是记录类名和关键 ID,而不是整个对象。

8. 把序列化当成一种契约来对待

写到这里,我想把最核心的一个观点再强调一遍:序列化不是“把对象转成字节”这么简单的一个工具调用,它是一种契约。这个契约规定了数据在内存和存储/网络之间的转换规则,一旦有数据被序列化出去,这个契约就被固定下来了。后续所有的代码变更,都要考虑对已有契约的影响。

很多线上事故的根源,就是开发者把序列化当成了一个无状态的、随时可以改的工具,忽略了它背后沉淀的数据。缓存里的数据、消息队列里的消息、数据库里存的 JSON 字段、接口的历史版本——这些都是契约的产物,改契约就要考虑兼容。

所以我的建议是:在项目初期就把序列化方案定下来,写进技术规范;对需要长期存储的数据,设计好 schema 演进策略;对不可信数据,坚决不做原生反序列化;对序列化异常,做好降级和监控。这些工作看起来繁琐,但比起半夜被叫起来处理缓存雪崩或者安全事件,成本低得多。

序列化和反序列化这两个词,说到底就是数据在“活的形态”和“死的形态”之间转换的桥梁。理解这座桥怎么建、能承重多少、哪里容易塌,是每个后端工程师绕不开的基本功。希望这篇内容能帮你把这块认知补扎实,下次再遇到相关问题,能顺着链路快速定位,而不是对着报错发呆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询