一提到JSON序列化,很多人第一反应是:不就是JSON.stringify或者json.dumps吗?有什么好聊的。但我在线上环境排查过太多由序列化引发的事故:大整数悄悄变了值、接口返回的日期格式前后端各执一词、某个安全公告突然说框架自带的反序列化组件能被人远程利用。这篇文章就围绕JSON序列化问题,把我这些年踩过的类型精度、跨语言差异、消息队列集成、以及反序列化安全相关的坑,一次性梳理清楚。内容主要围绕JSON序列化与反序列化这条主线展开,适合正在处理接口联调、消息队列、数据配置文件解析,或者被反序列化漏洞通告折腾得焦头烂额的开发同学。
1. 序列化的本质:为什么数据要“打包”再“拆包”
1.1 内存里的对象和磁盘/网络上的数据,完全是两种形态
程序运行中的对象是什么?是一块内存区域,里面除了业务字段,还有对象头、引用指针、方法表地址。同样是“用户”这个概念,Java里的User对象、Python里的dict、PHP里的数组,在各自进程里的内存布局天差地别。你想把它写到文件里、塞进Redis、丢给RabbitMQ,就必须把这块内存“翻译”成一种可传输、可存储的字节序列,这个过程叫序列化;对方收到后再按自己语言的规则“重建”对象,就是反序列化。
很多初学者不理解为什么序列化会引发问题。我打个比方:内存对象就像你脑子里的一段信息,序列化是把它写到一张纸上,反序列化是对方看完这张纸再把信息记回自己脑子里。写的时候用中文还是英文、保留了哪些细节、丢失了哪些字段,都会影响对方最终是否理解正确。JSON只是这张“纸”的排版规范之一,它规定了字段怎么写、数组怎么排、数字和字符串怎么区分,但它并没有规定“信息传到之后必须100%还原”,这中间的偏差就是无数线上事故的根源。
1.2 JSON能成为默认格式,靠的是自描述和跨语言
JSON全称JavaScript Object Notation,最初只是JS对象字面量语法的子集,后来独立成标准。它一共只有6种值:字符串、数字、布尔、null、数组、对象。这意味着任何一门语言,只要实现“字符串解析”和“对象映射”就能处理JSON,这是它能跨语言流行的基础。
自描述性体现得很直观:{"name":"张三","age":30}这段文本,哪怕没有任何额外文档,你也能看出有两个字段、分别是什么类型。相比XML,JSON更轻量;相比Protocol Buffers,JSON不需要预先定义proto文件,也不需要编译生成代码,改起来自由。所以项目里哪怕内部存储用了二进制格式,接口层、配置文件、消息体大部分仍然用JSON。这一点在热搜词里也体现得很明显:json格式、json转换、json学习,几乎每个开发者都绕不开它。
1.3 序列化和反序列化,其实是格式协商的过程
序列化最容易被忽略的一点:它是一个“双方协商”的过程。同一个JSON字符串,在Java里反序列化成HashMap,在Python里是dict,在PHP里可能是stdClass对象或关联数组,这取决于解析库的默认行为。字段命名是驼峰还是下划线、日期格式是什么、null字段是否输出、数字解析成int还是long,这些都直接影响通信双方对同一份数据的理解。
所以我先给一个总原则:写序列化代码时,默认使用显式配置,不要依赖库的默认行为。这个原则在后面每个章节都会反复出现。很多人出问题,不是不会用某个库,而是相信了“默认行为是合理的”这个假设。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 对象到JSON这条路上,我遇到的五类典型事故
2.1 大整数精度丢失:一个ID从Java到前端变了两位数
有一次联调,后端给我返回一个用户ID字段:"id": 738559821948731392,前端拿到后打印出来变成了738559821948731400。数值变了,排查半天发现是JavaScript的Number类型精度问题。
JavaScript的Number是双精度浮点数,能精确表示的整数范围只有正负2的53次方减1,也就是9007199254740991。Java的Long是64位有符号整数,最大能到9223372036854775807,很多分布式ID用雪花算法生成的长整型,轻轻松松超出JS的精确范围。结果就是:后端序列化没做任何处理,前端JSON.parse时精度就丢了,而且通常表现为末尾几位变成0,肉眼很难察觉。
处理方式我一般用两种:
- 在Jackson中,给Long类型的字段配置
ToStringSerializer,例如@JsonSerialize(using = ToStringSerializer.class),或者全局统一配置,让所有Long字段序列化成字符串。 - 数据库主键、分布式ID这类不需要参与计算的字段,干脆在定义阶段就用字符串类型,从源头避开数字精度问题。
如果用的是fastjson,也可以在SerializeConfig里给Long类型注册ToStringSerializer。切记不要以为“前端不参与计算就没事”,显示出来变了数字就是事故。
2.2 日期时间序列化:同一个时间戳,有人当成秒有人当成毫秒
日期时间也是序列化最容易打架的字段。默认情况下,Java的Jackson会把Date序列化成毫秒时间戳,fastjson默认输出yyyy-MM-dd HH:mm:ss,Python的json.dumps遇到datetime对象直接报错不知道怎么办,JavaScript的Date对象在JSON.stringify下会自动调用toISOString()输出UTC字符串。
同一个“2025-05-01 10:00:00”,在不同语言里可以产生三种完全不同的表现。最稳妥的方式是:接口层统一约定日期格式字符串,比如yyyy-MM-dd HH:mm:ss,并且统一存UTC,展示时由前端转本地时区。如果必须传时间戳,就要显式约定单位是秒还是毫秒,最好在字段名上体现出来,比如createTimeSec、expireAtMs,不要让人靠猜。
在Java里我习惯全局配置Jackson,关闭时间戳输出、固定日期格式:spring.jackson.date-format=yyyy-MM-dd HH:mm:ss,同时把时区固定为GMT+8。Python端则是自定义encoder处理datetime。别依赖“两边都默认”,默认从来不可靠。
2.3 中文编码:满屏\uXXXX看得人头皮发麻
Python的json.dumps默认ensure_ascii=True,序列化中文后输出的是\u5f20\u4e09这种Unicode转义序列。它解析回来确实是原来的汉字,但问题在于:文件直接看到的是\u转义,不好排查;网络传输时体积变大;某些不支持Unicode转义的下游工具会把\u5f20当成字面量字符串处理。
解决方法很简单:json.dumps(data, ensure_ascii=False)。如果要写文件,还要带上encoding='utf-8',否则Windows默认GBK下读出来就是乱码。PHP里的json_encode默认也会把中文转成\uXXXX,需要加JSON_UNESCAPED_UNICODE标志。这个坑在做翻译文件、爬虫导出、日志输出时特别明显,单条数据看不出来,批量导出时满屏转义就难受了。
2.4 循环引用和特殊数值:JSON标准没考虑过的边界
JSON标准里没有NaN、Infinity、undefined,也没有循环引用。一旦对象图出现自引用,比如父节点持有子节点、子节点又持有父节点,序列化库通常有两种行为:抛异常,或者无限递归导致栈溢出。Python的json模块会直接抛ValueError: Circular reference detected;部分Java库姿势不对时会栈溢出。
处理思路是:在业务层打断循环依赖,只序列化ID,或者明确改造成单向引用。千万别试图在JSON里保留完整对象图,JSON这种纯文本格式表达不了引用关系,硬塞进去只会让解析端一脸懵。
特殊数值更隐蔽。JavaScript的NaN和Infinity会被JSON.stringify转换成null;Python的json.dumps默认会把float('nan')输出成NaN,这其实是不合法的JSON扩展值。如果这段数据再遇到严格校验的解析器,直接拒绝。跨语言传输时,要提前约定非有限数值和null的映射规则,不能指望各语言行为一致。
2.5 类型映射的隐形约定:tuple变数组、字典key只能当字符串
不同语言对JSON类型的映射规则并不对称。Python里元组(1, 2)序列化成[1, 2],反序列化回来是列表而不是元组。JSON对象的key必须是字符串,Python的dict即使key是整数,序列化时也会被转成字符串,反序列化回来全是字符串key,类型信息直接没了。
Java里最典型的坑是:反序列化时,List接口被实例化成ArrayList,Map接口被实例化成HashMap,如果业务代码强制依赖了具体实现类,就会出现ClassCastException一类诡异问题。所以我一直强调:JSON适合交换数据,不适合完整保留对象类型信息。如果确实需要把对象完整存下来、下次原样还原,要用语言原生的序列化机制,但这又引出了后续安全章节的问题。
3. 不同语言处理JSON的脾气:Java、Python、PHP、LabVIEW各有各的怪
3.1 Java系:fastjson虽然快,配置和漏洞都得留心
Java里JSON库三分天下:Jackson、Gson、fastjson。Jackson是Spring Boot默认集成的,Gson以简洁著称,fastjson当年以性能出名,很多老项目还在用,但它的反序列化漏洞在安全通告里出现频率也最高。我的态度很明确:新项目默认Jackson或Gson,老项目如果还在用fastjson,必须锁版本、关闭autoType、及时升级,没有任何商量余地。
Spring Boot项目里,很多人不知道HTTP消息的JSON序列化交给HttpMessageConverter处理,默认是MappingJackson2HttpMessageConverter,底层还是Jackson的ObjectMapper。如果你想给所有Long字段统一转字符串、统一日期格式、忽略未知字段,不要到处加注解,直接自定义一个ObjectMapper配置类注入进去。全局配置优先于逐字段注解,这是我从大量重复代码里总结出来的教训。
3.2 Python系:json.dumps和json.loads的默认行为,够你踩半天的
Python的json模块API很简单,但“简单”不等于“省心”。默认的separators=(', ', ': ')会产生带空格的输出,想省流量要显式写成separators=(',', ': ')。sort_keys=True可以让输出稳定,方便做缓存key和签名校验。default参数可以挂自定义序列化函数,处理datetime、Decimal等JSON原生不支持的Python类型。
还有一个常见的坑:json.loads可以接受字符串和bytes,但如果传入带BOM的文件内容,会直接抛JSONDecodeError。读文件时建议用encoding='utf-8-sig',这样能自动吞掉BOM头。如果项目对性能有要求,可以考虑用orjson,序列化和反序列化比标准库快数倍,而且对UTF-8、datetime的处理更友好。但它的返回类型是bytes而不是str,接口层需要再decode一下,这是很多刚换库的人容易忽略的。
3.3 PHP系:json_encode中文、关联数组与对象
PHP的json_encode有一个反直觉的点:关联数组序列化成对象,索引数组序列化成数组。一个空数组[],默认序列化成[]而不是{}。如果你想让空数组输出为对象,要用JSON_FORCE_OBJECT。反过来,接口端期望数组,你却传了关联数组,前端遍历时就容易出错。
很多老PHP项目还保留着serialize()和unserialize()处理会话和缓存的习惯。这种格式效率不高,而且和JSON完全不是一回事,跨语言时根本没法解析。能用JSON就用JSON,别用PHP原生序列化格式做跨语言交换。
3.4 LabVIEW这种工业环境,读JSON文件反而更纯粹
LabVIEW在测试测量领域用得很多,热搜词里出现了“labview读写json文件”,说明测控行业也绕不开JSON了。LabVIEW没有像Python、Java那样随手可用的JSON库,通常要用JKI JSON库或自带的JSON Convert节点。工业环境里JSON主要用来存测试配置、采集数据元信息,字段固定、类型简单,反而不容易踩序列化的坑。
我的建议是:在LabVIEW里读JSON时固定使用UTF-8文件编码,字符串和数值字段分开处理,布尔值不要用0/1代替true/false,避免和测试程序里的类型定义不一致。写JSON时层级不要超过三层,嵌套太深会让现场调试的人非常难受,排查配置问题成本很高。
4. JSON不只在接口里:消息队列、测试提取、数据同步和配置文件的实战
4.1 把JSON放进RabbitMQ:消息转换器与类型头
RabbitMQ本身不关心消息体是文本还是二进制,它只负责转发。所以“把JSON放入RabbitMQ”本质上只是一种约定:生产者把对象序列化成JSON字符串,消费者拿到字符串再反序列化回对象。Spring Boot里如果用了Jackson2JsonMessageConverter,框架会自动把消息体包装成JSON,并在消息头加上__TypeId__来标记Java类型,这样消费者能直接还原成对应类。
这个“类型头”设计在单体系统内很舒服,但微服务跨团队之间非常危险:只要两个服务的类不在同一个包,或者字段有差异,__TypeId__指向的类加载不到,消费者直接报错。我踩过这个坑之后,在跨服务场景反而更愿意用最原始的方式:消息体就是普通String,自己在代码里显式JSON.parse或者json.loads成DTO。虽然少了自动转换的便利,但换来的好处是:类型不匹配、字段丢失都是显式错误,而不会因为你加了某个配置被框架悄悄吞掉。
4.2 JMeter里用JSON提取器做关联
压测和接口自动化里,JMeter的JSON提取器是我最常用的组件。它用JSONPath从响应里取字段,比如登录接口返回{"data":{"token":"abc123"}},设置$.data.token就能拿到token,然后通过${token}引用到后续请求。有几个参数容易被忽略:
Match No.设为0表示随机取一个,-1表示取所有匹配项。Default Value一定要填,否则提取失败时变量为空,后续请求报错很难定位。- JSONPath表达式尽量简单,太复杂的过滤器建议先在在线工具里验证,别直接在压测脚本里调试。
JSONPath和XPath相比,对JSON数据天然友好,数组下标用法$..book[1]、过滤用法$..book[?(@.price<10)]都是常用写法。但要注意,压测脚本的核心目的是稳定复现接口压力,不是炫技,越复杂的表达式越难维护,后续接手的人越容易崩溃。
4.3 DataX任务里的JSON参数:搞懂reader和writer
DataX是数据同步工具,它把一个数据源的数据抽到另一个数据源,整个任务就是一个JSON配置。刚接触DataX的人看这个JSON会一头雾水,其实核心只有三部分:job.content里有reader和writer,分别定义从哪读、写到哪;setting里是任务并发度、channel数、错误记录容忍度;reader和writer的parameter里是具体连接信息和列映射。
一个典型坑是column必须和表结构对齐。*表示全部列,但如果源端和目标端列顺序不一致,用*会串列。另一个坑是channel数不是越大越好,它受数据库连接数、源端和目标端吞吐限制,盲目调到32可能直接把数据库压垮。做数据同步时最容易忽略的是编码参数,源端读取CSV如果没有显式指定UTF-8,Windows下生成的ANSI编码文件会解析出乱码。经验是:DataX配置里所有涉及文件读写的参数都要显式写编码,不要用默认。
4.4 配置文件里的JSON:翻译文件、书源合集、日历数据
热搜里出现了一串有意思的词:“翻译文件json”、“书源合集json”、“2026年日历数据json”、“2026音乐源json分享”。这类场景本质上是用JSON当配置文件或数据文件。它们的共同特点是:文件体积不大、结构固定、需要频繁修改。
这类项目最大的痛点是JSON格式本身不允许注释和尾逗号。很多人从网上复制的“JSON文件”里带着//注释或者{"a":1,},一解析就崩。手工维护JSON配置,一定要在编辑器和CI里挂JSON Schema校验,或者至少用工具实时提示语法错误。书源、音乐源分享最常见的问题其实是字段名不统一,有人用url,有人用bookSourceUrl,有人用weight,有人用priority。这已经不是序列化问题,而是数据契约问题。做这类配置解析时,最好先写一个校验函数,把必填字段、字段类型、取值范围都过一遍,再进业务逻辑,否则下游解析到一半才发现缺字段,排查成本非常高。
5. 反序列化漏洞的真相:很多安全问题不是JSON的错,是用法错了
5.1 fastjson反序列化漏洞:autoType机制为什么会变成入口
现在聊一个比较严肃的话题。热搜词里有“fastjson反序列化漏洞”、“反序列化攻击”。我得先说清楚:JSON本身只是一段文本,解析JSON并不可怕。真正让人担忧的,是某些JSON库在反序列化时允许“根据数据内容动态指定要构造的类”,fastjson的autoType机制就是这种设计。
早期fastjson为了还原对象的具体类型,允许JSON里出现@type字段,指定一个类名,反序列化时直接加载并实例化这个类。恶意攻击者如果能让服务反序列化一段包含@type的JSON,并且这个类在classpath里存在可利用的“魔术方法”或复杂setter/getter链,就可能触发远程命令执行。这也是为什么很多安全通告都围绕fastjson反复更新,1.2.24、1.2.47等版本都有绕过方案。
修复思路其实很直接:升级到官方修复版本;不依赖默认配置,开启safeMode彻底关闭autoType;或者配置白名单,只允许反序列化自己项目里的已知DTO类。做接口开发的人一定要记住:凡是接收外部JSON的地方,都不应该允许任意类型反序列化。用JsonNode或固定DTO做结构解析,不要用“自动还原成Object”这种方便一时、悔恨三年的做法。
5.2 Python和PHP里的“反序列化”陷阱:pickle与session
Python里经常被忽略的坑,是把pickle.loads和json.loads搞混。热搜词里同时出现了“pickle反序列化”、“php序列化中文”、“session反序列化”,这几件事放在一起看,其实在说同一个道理:语言原生的序列化格式,不能直接接收不可信数据。
pickle的设计初衷是“重构任意Python对象”,它本质上是一段需要执行的操作码。pickle.loads(不可信数据)约等于在服务器上执行任意代码,这一点怎么强调都不过分。安全实践只有一条:永远不要对不可信来源做pickle.loads。如果需要跨系统交换数据,用JSON这种纯数据格式;确实需要还原复杂对象,就给pickle流加签名验证和来源白名单。
PHP的session反序列化则是另一个套路。PHP的session数据按某种序列化格式存储,session.serialize_handler可选php、php_binary、php_serialize。当PHP用php_serialize写入、却用php格式读取时,会产生解析差异。如果攻击者能控制session文件内容,再结合session中存在的反序列化时触发魔术方法的类,就可能造成漏洞。CTF靶场pikachu里有对应的题目专门练这种“反序列化入口”的寻找思路,前两年的线下赛里类似题目也出现过。
这类漏洞的共同点都是“对象反序列化”机制,而不是纯JSON解析。所以我在代码评审时有一条自己的原则:项目里禁止对不可信输入使用任何语言原生反序列化函数,包括但不限于Java的ObjectInputStream.readObject()、Python的pickle.loads()、PHP的unserialize()。要用,就必须有严格的来源校验和签名验证。
5.3 phar反序列化:看似无害的文件上传也能被利用
phar反序列化漏洞是这几年比较热的话题。phar是PHP的归档文件格式,里面除了文件内容外,还有一段序列化的元数据。当代码里用file_exists()、file_get_contents()这类函数去访问phar://协议时,即使只是检查文件是否存在,PHP也会读取phar的元数据,并对元数据执行反序列化。
这就造成了一条隐蔽的攻击链:网站允许上传文件,只检查文件内容而不检查文件后缀,攻击者上传一个精心构造的phar文件,然后想办法用phar://路径触发某个文件操作函数,最终形成反序列化利用。防御上主要有三点:一是文件上传后重命名,不使用用户可控的文件名;二是禁用不需要的phar://协议包装器;三是前面那条铁律同样适用——项目里没有必须使用PHP原生反序列化的场景,就干脆禁止unserialize()处理不可信输入。
5.4 安全实践:把反序列化当成“接收不可信代码”来对待
聊了这么多,我想把安全主线总结成一个判断标准:反序列化不可信数据,本质上等于在执行数据里隐含的“构造指令”。JSON因为是纯数据格式,外部字段再多,解析出来最多是个字典或对象图,风险相对可控;但凡是“按数据动态加载类、执行对象构造流程”的机制,风险等级就完全不同。
我自己的项目安全底线有这么几条:
- 对外接口只接受纯JSON纯数据,不启用autoType或类似动态类加载机制。
- 需要还原为强类型的场景,用白名单DTO,字段和类型都固定,不接收额外未知字段。
- session、缓存这类持久化数据至少要加签名,或者使用独立序列化格式,防止被篡改。
- 收到安全通告时,第一时间查自己项目里是否真的用了受影响库,锁定版本,同时排查有没有
@type、__TypeId__、pickle这类特征出现。
任何库的官方文档一旦提过“反序列化漏洞”,且修复方式是“升级版本+关闭特性”,那这个特性就必须默认关闭,即便性能会掉一点。序列化安全没有中间态,被利用一次就是整个服务沦陷。
最后再说一个实际项目里的体会。JSON序列化问题从来没有“一劳永逸”的解法,它是技术选型问题、是数据契约问题、也是安全策略问题。有次凌晨排查线上故障,最后发现是上游服务换了一个JSON库版本,默认行为从“字段包含null”变成“字段直接省略”,下游解析出来的对象瞬间缺了一堆数据。从那次之后,我在所有接口联调文档里都会显式写明:字段是否可空、缺失和null是否等价、数字精度上限、日期格式、时区。这些看起来琐碎的约定,恰恰是JSON序列化这条链路上最值钱的经验。
如果你正在被JSON序列化问题困扰,建议按这个顺序排查:先看库版本和默认配置,再看两端类型映射是否对称,再检查有没有类型信息泄漏(@type、__TypeId__一类),最后补上安全加固。把这个顺序走一遍,绝大多数问题都能定位出来。
