做后端开发这些年,我几乎每天都要跟序列化和反序列化打交道。前端传过来的JSON要反序列化成对象,查询结果要序列化之后塞进Redis,服务之间调接口逃不开消息序列化处理。可以说,只要数据要跨进程、跨语言、跨网络流动,序列化就必然站在中间。可真要我聊聊序列化原理,不少同事第一反应是“这不是最基础的东西吗”,直到线上出现一次反序列化异常,才意识到自己其实一直没吃透。
这篇内容不写教科书式的定义,就站在一个经常被序列化坑的从业者角度,把核心原理、方案选型、Spring Boot里的处理机制,还有反序列化漏洞这条安全线,一起捋一遍。无论你是刚入门的后端,还是被线上问题追着跑的资深开发,应该都能找到一点能直接落地的经验。
1. 先把概念落到地上:序列化和反序列化到底在干什么
1.1 从“内存对象”到“字节流”的转换过程
聊技术我习惯先用生活里能摸到的东西打比方。你可以把内存里的一个对象想象成乐高搭起来的一辆小车,它在当前进程里是立体的、完整的一个结构。现在你想把这辆小车寄给另一个城市的同事,你不可能把整个三维模型塞进快递箱,只能先把它拆成一张零件清单,写上“这里有几个轮子、什么颜色、怎么连接”,同事收到清单之后,再照着图纸重新搭一遍。拆解零件并写清单的过程,就是序列化;收到清单后照着还原小车的过程,就是反序列化。
内存对象本质上就是一片带结构的数据。Java对象里有字段名、字段类型和值,PHP数组本身就是键值对,Python字典也是类似结构。问题是这些结构只对你当前进程里的运行时环境可见,一旦进程退出,或者数据要发送给另一台服务器上的另一个进程,内存里的那点状态就全没了。序列化的核心功能,就是把这些“只存在于内存里的结构”转换成一串可存储、可传输的字节流或文本,让数据能够离开进程,在磁盘、网络、第三方中间件之间流动。
这里有一个很多人没意识到的小细节:序列化后的数据并不只是“值”的堆积,它往往会包含类型信息、结构信息甚至类名路径。正因为这样,反序列化时才可以重建出和原来一致的对象,而不是得到一个“看起来像但类型不对”的普通字段集合。这个特性也正好埋下了后面要讲的反序列化漏洞的隐患,因为你交给反序列化器的不仅是一份数据,还隐含了一套“怎么构建对象”的指令。
1.2 三个最常见的使用场景:缓存、RPC和消息队列
讲完概念,我把最常遇到序列化的三个真实场景列一下。
第一个是缓存。不管是Redis还是本地缓存,存进去的是必须跨进程访问的数据。把Java对象序列化成JSON字符串(或二进制)再SET到Redis,下一次GET到之后再反序列化回对象,这就是最典型的用法。之所以不能直接存Java对象,是因为Redis是独立进程,它不认识Java的堆内存结构。这个场景里,序列化格式的选择会直接影响缓存存储空间和读取性能,甚至会关系到缓存数据能不能被其他语言的服务复用。
第二个是RPC。服务间调用的时候,调用方把参数对象序列化,通过网络传到服务提供方,服务提供方反序列化得到参数对象,执行完再序列化返回结果,再传回去。Dubbo、gRPC、Spring Cloud的HTTP调用,本质都是这个过程。这里序列化方式直接决定了RPC的耗时和网络带宽占用,所以选型会很关键。我见过不少团队所有接口都用JSON,在业务量不大的时候没什么感觉,一旦进入大流量、大对象场景,序列化开销立马就成了瓶颈。
第三个是消息队列。生产者把业务对象序列化后写入MQ,消费者拉取消息后先反序列化再处理。相比RPC,MQ场景对序列化的容错要求更高,因为消息可能被多个不同版本的服务消费,序列化格式的兼容性一旦出问题,就会出现反序列化异常,而且这个问题通常不是重启就能解决的,需要回放消息或者做格式兼容迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主流的序列化方案怎么选:JSON、XML、二进制一把梭的真实差异
2.1 JSON是“万金油”,但它并不是所有场景的最优解
JSON应该是目前应用最广泛的序列化格式。它的优势不用多说:人可读、跨语言、生态成熟,几乎所有编程语言都有自己的JSON解析库。Spring Boot默认用Jackson来解析JSON,Python里json库随手就能用,PHP里json_encode/json_decode也几乎是日常标配。但是,JSON并不总是最优解,这点在我接手过好几个项目之后体会特别深。
很多人没意识到,JSON序列化后的体积是相当膨胀的,字符串标签、引号、大括号到处都是,一个很简单的对象能撑出几百字节。对带宽敏感、对性能要求极高的核心链路,这个开销就很可观。其次,JSON只描述数据,不描述类型,反序列化时你只能得到Map/字典/数组这种通用结构,除非额外带类型信息,否则语言里的强类型对象是重建不回来的。比如Java的List<User>,如果用Jackson反序列化,不指定TypeReference的话,很容易得到一个List<LinkedHashMap>,后面一调User的方法就抛ClassCastException。
所以我在实际项目里的选择逻辑是:对外API、日志、配置这种要给人看的,用JSON;内部RPC、缓存里的大对象、跨服务核心链路,优先考虑二进制方案,或者至少做针对性优化。JSON不是不好,而是要用在合适的层。
2.2 Protobuf、Kryo这些二进制序列化,赢在小体积高性能
二进制序列化方案里,Protobuf、Kryo、Hessian是比较有代表性的几个。Protobuf由Google设计,要求你先定义.proto文件,然后通过编译器生成对应语言的类。它把字段编码成紧凑的二进制流,字段名在真正传输时几乎不占空间,因为它用的是字段编号而不是字段名,所以体积能比JSON小很多,解析速度通常也更快。跨语言是Protobuf最大的杀手锏,Java、Go、Python、C++都能通过同一份.proto文件生成各自的代码,非常适合异构系统间的通信。
Kryo则是Java生态里比较流行的序列化库,不需要预先定义schema,直接对Java对象进行反射式的紧凑编码,体积和性能都优于Java原生序列化。我自己在游戏后端和实时推荐服务里用过Kryo,它的表现确实可以,但有个典型问题:对类的结构变化比较敏感,字段增删或者类型变更后,老数据很容易反序列化失败,需要额外处理兼容性。
选择二进制方案前建议先掂量一下收益和成本。Protobuf的压缩率和性能是最稳的,但代价是代码需要从.proto文件生成,团队多了一个约束规范。Kryo和Hessian不需要定义协议,接入成本低,但跨语言能力弱,基本只能Java跑到Java,如果你有多语言互通的需求,就得慎重了。还有一点,二进制数据在排查问题时没有JSON那么直观,需要额外的解码工具辅助。
2.3 语言内建方案:Java序列化与PHP serialize的便利和包袱
Java原生的Serializable序列化应该是历史包袱最重的一个方案。实现Serializable接口的类会自动获得一个默认的serialVersionUID,当你序列化一个对象后,如果类的结构发生变化,比如加了一个字段或改了一个类型,反序列化时很可能抛InvalidClassException。这个坑我在线上踩过不止一次,所以规则很简单:凡是可能要长期保存的序列化类,一定要显式声明serialVersionUID,并且做兼容性设计。
PHP里对应的是serialize函数和unserialize函数。serialize之后得到的是PHP变量序列化格式的字符串。很多新手会直接拿它来存Session、存缓存、甚至存Redis,确实方便,但这里有个重要问题:PHP序列化格式高度依赖PHP环境,跨语言基本是不可能互操作的。一旦系统里多了一个Java服务要读同一份缓存,你就不得不做数据迁移或格式改造,这个改造往往比想象中痛苦得多。
Java原生序列化和PHP serialize的共同点是“语言绑定”。用起来爽,破局的时候痛苦。我的经验是:能不用语言原生序列化做跨进程数据交换,就尽量不用;语言原生方案只适合Session这种完全封闭的场景。
3. 后端实战:Spring Boot环境下的消息序列化与缓存处理
3.1 从Spring Boot 4.x版本看HTTP消息序列化处理机制
先说一个很多人问过我的点:Spring Boot处理HTTP请求和响应的时候,序列化到底在哪一层发生?答案在HttpMessageConverter这套机制里。Controller得返回一个Java对象,Spring MVC会找到合适的HttpMessageConverter,把这个对象转换成响应体字节流返回给前端;请求过来时则反向操作,把请求体里的JSON字节流转换成Controller方法的参数对象。这层转换就是消息序列化处理的实质。
即使是在Spring Boot 4.x系列的较新版本(比如4.0.3)里,这套以HttpMessageConverter为核心的处理骨架也没有变。版本迭代更多是底层依赖的升级,比如Jackson版本升级、JDK基线提高、默认的配置项调整。如果遇到“请求能进来但参数是空的”“返回结果字段变了”这类问题,优先排查的就是消息转换器有没有配置正确、全局的ObjectMapper有没有被覆盖、日期格式和空值处理策略是不是符合预期。
自定义ObjectMapper的时候要格外小心。很多人直接在配置里new了一个ObjectMapper,结果把Spring Boot默认注册的那些模块(比如JavaTimeModule,也就是Java 8日期时间支持)全挤掉了,导致LocalDateTime序列化直接报错。正确做法是在Spring容器里增强,而不是整体替换。具体就是通过Jackson2ObjectMapperBuilderCustomizer这类回调,在现有ObjectMapper基础上调整配置,这样既保住了默认能力,又能加入自己的规则。
3.2 Redis缓存场景的序列化踩坑与选型
Spring Data Redis里,RedisTemplate的默认序列化器是JdkSerializationRedisSerializer,也就是Java原生序列化。如果你没做配置,直接把对象塞进去,Redis里会出现一堆以\xAC\xED\x00\x05t开头的内容,这就是Java序列化的魔数。它最大的问题除了体积大,还有跨版本兼容风险,以及Redis Desktop Manager里看数据完全是乱码。
另一个常见坑是使用Jackson2JsonRedisSerializer或GenericJackson2JsonRedisSerializer时,反序列化拿不到类型信息。Generic版本会在JSON里写入@class字段来保存类型,但引入了一个安全面:如果你用的是自动类型转换,攻击者可能在@class里指定一个危险类,造成反序列化攻击。所以用JSON序列化Redis缓存时,一定要用白名单机制,或者明确指定能反序列化的类集合,不要图省事直接允许所有类型。
我的通用建议:Redis缓存里的数据,能存JSON字符串就存JSON字符串,能不用JdkSerializationRedisSerializer就不用。如果必须存二进制,优先考虑Kryo或Protobuf,而不是Java原生序列化。另外,无论选哪种方案,都要给缓存键和值设计好过期策略与前后缀规范,否则排查问题的时候根本分不清哪个key对应哪个业务。
3.3 fastjson换fastjson2:序列化库升级的实战经验
fastjson在国内的普及率非常高,主要胜在API简单、性能好。但fastjson的漏洞史也是出了名的,反序列化漏洞接二连三,很多安全团队最后直接强制要求项目里禁掉fastjson。后来阿里推出了fastjson2,在兼容原API的同时重构了底层实现,性能和安全性都有明显改善。如果你还在老项目里用fastjson,我建议早点评估切换。
我在接手一个老项目时做过一次fastjson2迁移。过程不算复杂,fastjson2官方保持了com.alibaba.fastjson2:fastjson2这个新artifactId,但直接换坐标往往不够,因为代码里import的是com.alibaba.fastjson包路径。好在fastjson2提供了一个兼容包,能解决大部分历史代码的依赖问题。迁移之后建议用压测补一遍,重点看大对象和嵌套对象的序列化结果是否符合预期,特别是日期格式和枚举类型,这两个最容易在版本切换时出意外。
说到“fastjson序列化时不包括转义字符”这个点,其实就是控制输出时的转义行为。fastjson默认会对特殊字符做转义处理,通过SerializerFeature里的BrowserCompatible、DisableCheckSpecialChar等特性可以调整输出策略。具体到中文场景,如果你发现返回给前端的JSON字符串里中文被转成了\uXXXX,通常解决方式是配置序列化特性,让中文字符保持原样输出。这个在5.1里我还会展开说一下。
4. 序列化安全问题:反序列化漏洞是怎么来的,又怎么防
4.1 fastjson反序列化漏洞的来龙去脉
反序列化漏洞的核心成因,是反序列化过程里隐含了“对象构造指令”。正常数据当然没问题,但如果用户输入能控制反序列化内容,攻击者就可以构造一段恶意数据,让目标反序列化出一个危险对象,或者通过一连串调用链执行到危险方法。这类问题在Java反序列化里尤其突出,Java的对象序列化流里本身就带着类名和字段信息,等于给攻击者提供了拼积木的零件。
fastjson反序列化漏洞之所以被广泛讨论,一方面是因为fastjson提供了autoType机制,允许JSON字符串里通过@type指定要反序列化的类。这个设计给了用户灵活性,但也打开了大门:如果开发者处理了外部输入且没有限制类白名单,攻击者就可以通过@type指定一个具有利用价值的类,触发危险调用链。我见过不少团队因为这个机制收到安全预警,最后解决方案基本都是关掉autoType,或者升级到加白名单的版本。
要强调一点,我讲这些不是为了让你去复现攻击。开发者的正确姿势是:把任何外部输入都当成不可信数据,反序列化的过程中默认不信任任何类信息。能用白名单就上白名单,不能白名单就别用支持类型指定的序列化工具。这是安全底线问题,不是性能问题能比的。
4.2 PHP反序列化与phar反序列化:不只存在于unserialize
PHP反序列化漏洞的常见触发点自然是unserialize()函数。如果应用程序把一个由用户可控的内容作为参数传给了unserialize,攻击者就能构造序列化字符串,触发自动加载类或执行魔术方法。很多开发者在代码里写unserialize的时候根本没意识到这一段“普通数据”可能带着方法调用链。魔术方法比如__wakeup()、__destruct(),在反序列化或对象销毁时会自动执行,攻击者就是靠这些钩子把恶意行为串起来的。
phar反序列化是更隐蔽的一种形态。PHP的phar文件本质上是一个打包格式,但它会包含一个序列化的meta-data。当你用file_exists()、is_dir()、getimagesize()这类文件操作函数去处理一个phar文件时,PHP会自动反序列化phar文件里的meta-data,而不需要显式调用unserialize。这意味着表面上没有反序列化函数的代码,也可能成为反序列化漏洞的入口。像pikachu这类漏洞靶场里,就有专门的反序列化漏洞练习环境,其中phar反序列化是一个经典关卡,能让初学者直观理解这种“无unserialize触发”的攻击面。
对PHPer来说,防御手段其实不复杂:首先尽量用json_encode/json_decode替代serialize/unserialize,因为JSON不携带对象构造指令,攻击面小得多。其次,任何用户可控输入都不要直接进unserialize,必须做类型校验或白名单。第三,对文件操作路径做严格限制,防止用户指定任意文件路径从而触发phar反序列化。
4.3 反序列化漏洞的常规防御思路
把Java、PHP这些不同语言的反序列化漏洞放在一起看,防御思路是高度相似的。
第一,明确反序列化的数据源是否可信。外部输入默认不可信,只有在边界做了严格校验之后才能进反序列化。第二,使用黑名单之外的类白名单。Java的ObjectInputFilter、fastjson的safeMode、PHP的allowed_classes参数,本质都是让你限制反序列化能构建的类集合。白名单比黑名单可靠得多,黑名单永远追不上新漏洞的挖掘速度。
第三,优先选择不携带执行能力的序列化格式。JSON天然没有方法执行的语义,所以跨服务数据交换用JSON通常比用Java原生序列化更安全。第四,及时升级依赖版本。安全漏洞在持续被修复,长期不升级的依赖就是把漏洞留在门口。第五,加上监控和告警。反序列化异常往往伴随明显的堆栈特征,提前配置好日志采集和异常告警,能在真正被攻击之前发现可疑尝试。
5. 常见问题与排查技巧实录
5.1 中文乱码和转义字符:“不包括转义字符”怎么做
中文乱码是序列化里出现频率最高的问题之一。这里要区分两个层面:一个是编码层面,比如HTTP响应的Content-Type没有指定UTF-8,导致浏览器按ISO-8859-1解码,所有中文全变问号;另一个是转义层面,JSON序列化器默认会把非ASCII字符转成\uXXXX,导致数据明明是UTF-8编码,看起来却是一堆乱码。
如果需要在fastjson里让输出不包括转义字符,或者说不把中文字符转成\uXXXX,常见做法是在toJSONString时传入SerializerFeature.BrowserCompatible,或者用JSONWriter的配置来控制输出。Jackson里对应的是JsonGenerator.Feature.ESCAPE_NON_ASCII,默认不启用,就不会做非ASCII转义,中文会保持原样输出。
我自己遇到过最坑的一幕是:前端同学截图反馈说接口返回的字段值显示成\u4e2d\u6587这种格式,查下来是某个环节强行设置了ESCAPE_NON_ASCII,而且这个设置是全局的,影响面很大。排查这类问题,核心是确认你看到的转义发生在哪一层,是HTTP响应编码、日志打印、还是序列化器配置。定位到具体层再改,才不会误伤其他模块。还有一个容易忽略的点:日志框架打印JSON时也可能做转义,让你误以为是接口返回的数据有问题,实际前端拿到的数据是正常的。
5.2 序列化版本不一致导致的兼容性故障
Java序列化最容易出兼容性问题。一个类加了一个字段,老数据反序列化时就可能抛InvalidClassException;一个字段类型变了,可能直接报类转换异常。更隐蔽的是serialVersionUID不一致,明明看着是同一个类,但因为编译环境不同导致生成的UID不同,反序列化直接失败。这类问题在灰度发布、滚动升级过程中尤其常见,新老实例同时工作,缓存或MQ里可能同时存在新旧两种格式的数据。
我给项目定过几条硬性规范:所有实现Serializable的类必须显式声明private static final long serialVersionUID;新增字段时使用OptionalField或者兼容模式处理旧数据;不轻易修改已有字段的类型和名称;如果真的要大改,优先增加新字段,而不是改动语义。这套规范执行下来,线上因为序列化兼容性引发的故障基本消失。
另外,如果你在设计跨服务接口的时候用的是Java原生序列化,请慎之又慎。接口的升级意味着所有调用方必须跟着升级到兼容版本,否则就会出现一部分老客户端调新服务、或者新客户端调老服务的场景,反序列化问题会以各种鬼畜形式爆发。很多时候用JSON或者Protobuf反而能绕开这些版本地狱,因为这些格式在字段缺失时通常有更宽松的处理策略。
5.3 性能瓶颈排查:序列化慢到底慢在哪
序列化性能问题通常被描述成“接口变慢了”,但慢在序列化的场景很有辨识度:大响应对象、大列表、高并发接口。排查思路很简单,先把序列化这块单独压一下,确认是不是瓶颈。如果你发现一个上千字段的大对象,JSON序列化一次要几十毫秒,那在QPS上千的接口里,这个开销就会被放大到肉眼可见的程度。
优化手段从易到难排列:第一,能只返必需字段就不要返回整个大对象的全字段,DTO设计得好,序列化性能问题直接少一半。第二,换性能更好的序列化库,比如Jackson写对配置之后通常比fastjson的某些老版本更稳,fastjson2和Protobuf在性能上也有明显提升。第三,对热点数据加缓存,让序列化结果复用而不是每次都重新算一遍。第四,如果还是不够,再考虑压缩,gzip压缩对JSON这种文本格式的压缩比往往很高,但会引入CPU开销,需要压测确认。
最后提醒一句:不要为了压性能而牺牲可读性。序列化不只是给机器看的,排障的时候你经常需要对照原数据手动解码,完全不可读的二进制在排查阶段会浪费很多时间。我在实践里的习惯是:核心链路用高效序列化,但相关日志里一定要能追溯原始对象内容,这样线上出问题的时候,至少能快速判断是数据问题还是序列化问题。
