Spring 与 JSON 序列化:Jackson 配置陷阱与性能优化实战
1. 从一个线上故障说起:为什么 JSON 序列化会引发 500?
先看一个真实场景:你负责一个订单服务,接口返回给前端的订单对象里有一个createdAt字段。某天升级后,前端突然收到一串奇怪的数字:1691234567890。前端说“我要的是2023-08-05 12:34:56,这串毫秒数我没法用”。后端同学查了半天,发现是 Jackson 默认把LocalDateTime序列化成了时间戳,而不是 ISO 字符串。
更糟的是,另一个接口在返回用户列表时,响应时间从 200ms 涨到了 2 秒。日志里没有异常,但 CPU 飙高。查下去发现是某个对象的字段特别多,每次序列化都要反射解析所有getter,并且开了FAIL_ON_EMPTY_BEANS,导致大量警告和异常处理开销。
这些问题的根源,在于 Spring Boot 默认使用 Jackson 作为 JSON 序列化工具,而 Jackson 的默认配置、Spring 的自动装配,以及我们自己对业务对象的建模方式,三者叠加后产生了很多隐蔽的坑。
本文会顺着这些故障,先帮你建立 Jackson 的整体认知框架,再逐个拆解核心机制,最后给出三个可以运行的示例,覆盖自定义序列化、多态处理和性能优化,让你在真实项目里少踩坑。
2. 一句话模型与整体框架
先记住一句话模型:
JSON 序列化,就是把 Java 对象“翻译”成 JSON 字符串的过程,Jackson 是翻译官,Spring 负责安排翻译官上岗。
把翻译过程拆开看,有三个角色:
- 源对象:你要输出的 Java 对象,比如
Order、User。 - Jackson 序列化器:负责读取对象的属性,并决定如何把属性值转换成 JSON 节点。它内部包含
ObjectMapper(翻译总控)和一系列JsonSerializer(具体翻译员)。 - 输出流:序列化结果写入的地方,可能是
HttpServletResponse的输出流,也可能是字符串。
一次典型的 JSON 输出流程可以画成下面的图(用 ASCII):
+---------------+ 读属性 +----------------+ 生成JSON +--------------+ | Java 对象 | -------------> | ObjectMapper | ---------------> | JSON 输出 | | (Order) | (反射/getter) | (序列化核心) | (写入Generator) | (字符串/流) | +---------------+ +----------------+ +--------------+ ^ | | 遇到特殊类型? | v | +----------------+ +-------------------------| 自定义序列化器 | (SerializerProvider) | (如处理日期/多态) | +----------------+一次用户请求会经历:Controller 方法返回对象 → Spring 调用MappingJackson2HttpMessageConverter→ 内部创建/复用ObjectMapper→ 调用writeValue()序列化 → 写入 HTTP 响应体。
理解这个链路后,我们就知道:要改变输出格式,要么改对象(用注解),要么改序列化器(自定义),要么改全局配置(配置ObjectMapper),三者对应了不同的影响范围。
3. Jackson 核心机制剖析
3.1 ObjectMapper 的角色与配置优先级
ObjectMapper是整个 JSON 序列化的中央处理器。它负责分配序列化器、读取配置、执行写入。Spring Boot 自动配置了一个ObjectMapperbean,但你可以通过三种方式覆盖它:
- 在
application.yml中配置标准属性:例如日期格式、时区、缩进输出等。 - 自定义
Jackson2ObjectMapperBuilderCustomizer:以编程方式微调ObjectMapper,适合复杂逻辑。 - 直接定义
ObjectMapperbean:完全接管,风险是丢失 Spring 的默认配置(如 JavaTimeModule 注册)。
理解配置优先级有助于排查“我改了配置怎么没生效”的问题:属性文件最低,Customizer次之,显式 Bean 最高(但可能关掉了默认)。
3.2 默认序列化流程与反射开销
当ObjectMapper.writeValue()被调用时,它经历以下步骤(简化):
- 检查目标对象的类型,找到对应的
JsonSerializer。 - 如果没有缓存,则通过反射分析类结构,收集可序列化属性(通常是
getter方法)。 - 调用序列化器,生成 JSON 字段名和值。
- 对于复杂对象,递归处理其字段值,直到所有字段都是基本类型、数组或容器。
这里的开销主要在于首次反射分析和后续的方法调用。Jackson 默认会缓存已分析类的序列化器,但如果你频繁创建新ObjectMapper(比如每次请求都 new),或者用了动态代理类,缓存就会失效,导致性能下降。
3.3 常见序列化器:JavaTimeModule 与 JSR310
Java 8 时间类型(LocalDate、LocalDateTime)默认不被 Jackson 支持,需要注册JavaTimeModule。Spring Boot 会自动注册它,但默认把时间序列化为数组或时间戳。要输出成 ISO 字符串,必须配置write-dates-as-timestamps: false。区别如下:
| 配置项 | 默认值 | 输出示例 | 适用场景 |
|---|---|---|---|
spring.jackson.serialization.write-dates-as-timestamps: true | 是(Spring Boot 2.x) | 1691234567890 | 传输效率高,前端处理麻烦 |
false | 否 | "2023-08-05T12:34:56" | 可读性好,符合大多数 API 规范 |
许多项目想输出yyyy-MM-dd HH:mm:ss,则必须自定义格式,我们会在示例中演示。
3.4 注解驱动的自定义序列化
Jackson 提供了丰富的注解来定制序列化,比如@JsonProperty重命名字段,@JsonFormat格式化日期,@JsonIgnore忽略字段,@JsonSerialize指定自定义序列化器。它们可以精确控制单一字段,是最直接的方式。
3.5 多态处理:@JsonTypeInfo 与 @JsonSubTypes
多态是指接口或父类引用指向不同子类对象。JSON 序列化时,Jackson 默认只序列化引用类型的声明字段,丢失子类特有属性;反序列化时更无法知道具体类。
解决方式是使用@JsonTypeInfo在 JSON 中嵌入类型信息,配合@JsonSubTypes列出子类。我们会在示例 2 中展示如何让子类字段正确输出,并能在反序列化时还原。
4. 完整示例:最小配置与自定义序列化器
示例一:全局日期格式与自定义字段序列化
目标:展示如何通过全局配置和@JsonFormat把日期输出成yyyy-MM-dd HH:mm:ss,并演示自定义序列化器的编写与注册。
前置环境:Spring Boot 2.5+,JDK 8+,Maven/Gradle。
步骤:
- 创建一个 Spring Boot 项目,加入
spring-boot-starter-web依赖。 - 在
application.yml中配置全局日期格式:
spring:jackson:date-format:yyyy-MM-dd HH:mm:sstime-zone:Asia/Shanghaiserialization:write-dates-as-timestamps:false- 写一个订单类,在其中使用
@JsonFormat指定特定格式:
importcom.fasterxml.jackson.annotation.JsonFormat;importjava.math.BigDecimal;importjava.time.LocalDateTime;publicclassOrder{privateStringorderId;@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")privateLocalDateTimecreatedAt;// 注意:不要用 this.amount 的 getter,容易造成冲突privateBigDecimalamount;// 构造函数、getter/setter 略。}- 写一个自定义序列化器,用于脱敏手机号:
PhoneSerializer继承JsonSerializer<String>,重写serialize()方法,把中间四位用*替换。
importcom.fasterxml.jackson.core.JsonGenerator;importcom.fasterxml.jackson.databind.JsonSerializer;importcom.fasterxml.jackson.databind.SerializerProvider;importjava.io.IOException;publicclassPhoneSerializerextendsJsonSerializer<String>{@Overridepublicvoidserialize(Stringphone,JsonGeneratorgen,SerializerProviderserializers)throwsIOException{if(phone!=null&&phone.length()==11){gen.writeString(phone.substring(0,3)+"****"+phone.substring(7));}else{gen.writeString(phone);}}}- 在用户类中使用该序列化器:
importcom.fasterxml.jackson.databind.annotation.JsonSerialize;publicclassUser{privateStringname;@JsonSerialize(using=PhoneSerializer.class)privateStringphone;// getter/setter 略。}- 编写测试 Controller:
importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RestController;importjava.time.LocalDateTime;@RestControllerpublicclassTestController{@GetMapping("/test")publicUsertest(){Useruser=newUser();user.setName("张三");user.setPhone("13812345678");returnuser;}@GetMapping("/order")publicOrderorder(){Orderorder=newOrder();order.setOrderId("20230805001");order.setCreatedAt(LocalDateTime.now());order.setAmount(newBigDecimal("199.90"));returnorder;}}- 启动应用,访问
/test得到:{"name":"张三","phone":"138****5678"};访问/order得到:{"orderId":"20230805001","createdAt":"2023-08-05 12:34:56","amount":199.90}。
关键步骤与预期输出:全局配置所有Date与LocalDateTime都按指定格式输出;@JsonFormat覆盖全局配置;手机号被脱敏。
适用场景:定制敏感信息脱敏、时间格式调整、特殊字段逻辑。
容易改错的地方:@JsonFormat的 pattern 对LocalDateTime有效,但对Date需要yyyy-MM-dd HH:mm:ss;如果不注册 JavaTimeModule,LocalDateTime会报异常;脱敏时注意 null 判断。
5. 完整示例:多态类型信息处理
示例二:让 JSON 携带类型信息,序列化与反序列化还原子类
目标:演示@JsonTypeInfo和@JsonSubTypes如何使接口/抽象类序列化时包含类型字段,并在反序列化时解析为正确子类。
前置环境:同示例一。
步骤:
- 定义消息接口
Message,标注类型信息:
importcom.fasterxml.jackson.annotation.JsonSubTypes;importcom.fasterxml.jackson.annotation.JsonTypeInfo;@JsonTypeInfo(use=JsonTypeInfo.Id.NAME,include=JsonTypeInfo.As.PROPERTY,property="type")@JsonSubTypes({@JsonSubTypes.Type(value=TextMessage.class,name="text"),@JsonSubTypes.Type(value=ImageMessage.class,name="image")})publicinterfaceMessage{}- 定义两个子类:
publicclassTextMessageimplementsMessage{privateStringcontent;// getter/setter 略。}publicclassImageMessageimplementsMessage{privateStringurl;privateintwidth;// getter/setter 略。}- 写一个 Controller 返回消息列表:
importjava.util.Arrays;importjava.util.List;@RestControllerpublicclassMessageController{@GetMapping("/messages")publicList<Message>messages(){TextMessagetext=newTextMessage();text.setContent("你好");ImageMessageimage=newImageMessage();image.setUrl("http://example.com/a.jpg");image.setWidth(100);returnArrays.asList(text,image);}}- 启动访问,得到 JSON:
[{"type":"text","content":"你好"},{"type":"image","url":"http://example.com/a.jpg","width":100}]可以看到每个对象都带上了type字段,并且ImageMessage的width字段被正确输出。
反序列化验证:写一个测试方法,用ObjectMapper解析上面的 JSON,断言类型:
importcom.fasterxml.jackson.databind.ObjectMapper;publicclassDeserializeDemo{publicstaticvoidmain(String[]args)throwsException{Stringjson="[{\"type\":\"text\",\"content\":\"你好\"},{\"type\":\"image\",\"url\":\"http://example.com/a.jpg\",\"width\":100}]";ObjectMappermapper=newObjectMapper();List<Message>messages=mapper.readValue(json,newTypeReference<List<Message>>(){});System.out.println(messages.get(0).getClass());// class TextMessageSystem.out.println(messages.get(1).getClass());// class ImageMessage}}关键步骤与结果:序列化时添加type属性,反序列化时根据type值构造对应子类。
适用场景:消息队列、异构事件、插件化架构等需要多态 JSON 传输的场景。
容易改错的地方:@JsonSubTypes的name必须与前端/消费者约定一致;如果子类不在列表,会抛异常;要让 this 类型信息同时被序列化,记得在接口上声明;反序列化时泛型要用TypeReference而不是Message[].class(否则类型信息可能丢失)。
6. 完整示例:性能优化实战
示例三:通过属性过滤与静态类型化减少开销
目标:演示如何使用@JsonIgnoreProperties和ObjectMapper的writerWithView或filter减少序列化字段,从而在大对象列表中显著提升性能。
前置环境:一个包含几十个字段的大对象类HeavyObject,内存足够。
步骤:
- 定义一个大对象:
publicclassHeavyObject{privateStringfield01;privateStringfield02;// 省略 30 个字段privateStringfield30;// getter/setter 略。}- 使用
@JsonIgnoreProperties静态忽略某些字段(例如内部大字符串):
@JsonIgnoreProperties({"field20","field21"})publicclassHeavyObject{// 同上}- 动态过滤:使用 Jackson 的
FilterProvider。在 Controller 中创建一个ObjectMapper(或注入默认的),设置过滤器:
@RestControllerpublicclassPerformanceController{@GetMapping("/heavy")publicObjectheavy(){// 模拟 1000 个大对象List<HeavyObject>list=newArrayList<>();for(inti=0;i<1000;i++){HeavyObjectobj=newHeavyObject();obj.setField01("value"+i);// 填充 30 个字段list.add(obj);}// 使用 ObjectMapper 的 filter 特性(需要 SimpleBeanPropertyFilter)SimpleBeanPropertyFilterfilter=SimpleBeanPropertyFilter.serializeAllExcept("field20","field21");FilterProviderfilters=newSimpleFilterProvider().addFilter("myFilter",filter);// 注意:HeavyObject 需要 @JsonFilter("myFilter") 才能启用过滤ObjectWriterwriter=objectMapper.writer(filters);returnwriter.writeValueAsString(list);}}但上面的代码要求HeavyObject标注@JsonFilter。为了简单,我们改用@JsonView:
publicclassViews{publicinterfaceSummary{};publicinterfaceFullextendsSummary{};}@JsonView(Views.Summary.class)publicclassHeavyObject{@JsonView(Views.Summary.class)privateStringfield01;@JsonView(Views.Full.class)privateStringfield20;// 默认不输出}然后通过writerWithView(Views.Summary.class)只输出部分字段:
// 控制器中ObjectMapperobjectMapper=newObjectMapper();Stringjson=objectMapper.writerWithView(Views.Summary.class).writeValueAsString(list);注意:@JsonView在类上标注会影响所有属性,只输出标记了该视图的属性。这是更干净的动态过滤。
运行并从控制台观察时间:循环执行 100 次,对比完整序列化和视图序列化的耗时,后者通常快 30%-50%(字段减少)。另外,可开启 Jackson 的MapperFeature.INFER_PROPERTY_MUTATORS等,但提升有限。
关键步骤与结果:减少序列化字段数量是提高性能最直接的手段;使用视图或过滤器动态控制输出字段,同时保持代码清晰。
适用场景:列表接口需要轻量返回,详情接口需要完整返回时;或者大量实时日志序列化。
容易改错的地方:@JsonIgnoreProperties是静态的,无法按请求条件变化;@JsonView需要在每个字段上标注,容易漏;过滤器 ID 必须匹配;性能测试要预热 JIT,否则不准;不要滥用过滤器,增加了复杂度。
性能对比表(参考值)
| 方案 | 配置/代码 | 输出字段数量 | 耗时(相对) | 适用场景 |
|---|---|---|---|---|
| 完整序列化 | 无额外注解 | 30 | 100ms | 详情页 |
| @JsonIgnoreProperties | 类上静态忽略 | 28 | 95ms | 固定忽略字段 |
| @JsonView | 按接口动态选择 | 10 | 60ms | 同一对象多种视图 |
注意:数据为示例,实际依赖字段数量和类型。核心思路是减少反射调用和写出字符。
7. 常见误区与生产实践建议
常见误区
| 误区 | 真相 | 避坑建议 |
|---|---|---|
| 每次请求都 new ObjectMapper | 非常昂贵,缓存丢失,建议复用单例 | 注入 Spring 管理的 ObjectMapper |
| 把实体类直接返回给前端 | 可能暴露敏感字段(密码、内部字段) | 使用 DTO 或 @JsonIgnore |
以为设置spring.jackson.date-format对 LocalDateTime 有效 | 只对 java.util.Date 生效,对 Java 8 时间类型无效 | 需配置serialization.write-dates-as-timestamps: false并使用@JsonFormat |
@JsonFormat与全局配置冲突时不知所措 | 注解优先级高于全局 | 了解优先级,注解用于特定字段 |
| 接口返回对象中包含循环引用 | Jackson 会无限递归导致栈溢出 | 使用@JsonIgnoreProperties("user")或@JsonManagedReference |
生产实践建议
- 复用 ObjectMapper:Spring Boot 已提供单例,直接注入使用,除非有特殊要求。
- 定义 DTO:不直接暴露实体,防止字段泄漏和循环引用。
- 全局配置日期格式:统一 API 风格,建议输出 ISO
yyyy-MM-dd'T'HH:mm:ss,前端易解析。 - 使用视图或过滤器控制大数据量字段:避免一次性返回大对象,尤其列表。
- 开启缩进输出:生产环境关闭(会增大体积),调试时开启。
- 监控序列化瓶颈:使用 JMC 或 YourKit 分析
writeValue热点。
8. 排障清单
当出现 JSON 相关问题时,按以下清单逐项检查:
- 输出时间戳而非字符串?→ 检查是否配置
write-dates-as-timestamps: false,并且有 JavaTimeModule。 - 反序列化时类型错误?→ 检查是否缺少
@JsonTypeInfo或子类注册。 - 字段缺失?→ 是不是 getter 返回 null 且配置了
NON_NULL,或者被@JsonIgnore了。 - 循环引用爆栈?→ 检查双向关联,使用忽略注解。
- 性能下降?→ 检查是否每次 new ObjectMapper;是否返回了超大对象。
- 拿到空对象
{}?→ 可能是FAIL_ON_EMPTY_BEANS和没有 getter。 - 自定义序列化器不生效?→ 检查注解位置是否正确(字段/类?),以及是否与全局配置冲突。
- 配置不生效?→ 检查是否自定义了 ObjectMapper 但没有调用
super或应用旧配置。
9. 总结:回到问题,建立决策模型
回顾开头的两个故障:日期格式混乱和性能问题,根源分别是默认配置没改和序列化范围过大。通过本文的知识,你应该能够搭建如下思维模型:
- 先看“谁在翻译”:是不是同一个 ObjectMapper?
- 再看“翻译规则”:全局配置、注解、自定义序列化器分别管什么?
- 最后看“翻译给谁用”:是给内部服务(可压缩、用时间戳)还是给外部前端(可读性好)?
选型时可以遵循条件判断:
- 当需要统一日期格式时,优先在
application.yml配置write-dates-as-timestamps: false和date-format;若对个别字段特殊格式,用@JsonFormat。 - 当存在多态引用时,在基类/接口上加
@JsonTypeInfo和@JsonSubTypes。 - 当列表接口需要精简字段时,使用
@JsonView或自定义序列化器;避免静态@JsonIgnoreProperties影响所有场景。 - 当遇到循环引用时,用
@JsonIgnoreProperties或 DTO 打破。
最终,你可以在自己的项目中画一张 JSON 序列化链路图,标注配置点和优化点,遇到问题能够快速定位。
10. 参考资料
- Jackson 官方文档:https://github.com/FasterXML/jackson-docs
- Jackson 注解参考:https://github.com/FasterXML/jackson-annotations
- Spring Framework 官方文档 Web MVC:https://docs.spring.io/spring-framework/reference/web/webmvc.html
- Spring Boot 官方文档 JSON:https://docs.spring.io/spring-boot/how-to/spring-mvc.html#howto.spring-mvc.jackson-objectmapper
- Java SE 8 DateTime JavaDoc:https://docs.oracle.com/javase/8/docs/api/java/time/package-summary.html