资讯动态

Spring Boot循环引用错误:Jackson序列化Infinite recursion四大解法

发布时间:2026/9/19 23:41:24 来源:尧图企业网站定制
简介面向Java后端开发者的技术排错文档聚焦JPA项目中Controller返回JSON数据时触发HttpMessageNotWritableException的问题。错误源于实体类之间双向引用造成的无限递归Jackson在序列化过程中反复遍历对象引用链最终抛出StackOverflowError并导致接口无法正常返回数据。资源以单个PDF文件形式提供体积仅约40KB内容紧凑不拖沓目前已吸引4046人学习下载。文档开篇展示完整异常堆栈便于读者对照定位随后以User和Address两个实体为例演示一对多关联场景下的正确注解写法文中针对不同场景列出解决循环引用的多种途径包括使用JsonManagedReference和JsonBackReference管理双向关联、利用JsonIgnore排除不需要输出的字段、通过JsonIdentityInfo将对象ID作为序列化标识乃至调整ObjectMapper配置或自定义JsonSerializer进行精细控制。每种方案都补充了注意事项和适用条件并提醒开发者注意JPA懒加载与Jackson序列化时机之间的关系避免事务外访问延迟加载属性对于使用Spring Data JPA并遭遇JSON序列化报错的开发者来说这份实战笔记能显著减少盲目搜索和试错时间。1. 返回 JSON 抛 HttpMessageNotWritableExceptionInfinite recursion 不是查询问题接口写好了SQL 也查出了数据可前端一调就 500控制台抛 HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError)。这不是查询慢也不是字段类型不匹配而是 Jackson 在把实体转 JSON 时掉进了对象循环User 里有 ordersOrder 里又有 user序列化器反复横跳直到线程栈被写满。用 Spring Boot JPA 写双向关联的团队基本都会在生产环境撞上一次。下面从最小复现讲起按定位、拆环、进阶、验证四步走给出 JsonIgnore、JsonBackReference、JsonIdentityInfo 和 DTO 四种方案的选择边界新手能按步骤复现熟手也能对照检查自己的实体出参规范。2. 复现 Infinite recursion一对多双向引用怎么把 Jackson 栈打爆2.1 最小复现User 和 Order 互相引用先造两个最典型的实体。User 一端是 OneToManyOrder 一端是 ManyToOne这是 JPA 建模里最常见的父子结构Entity Table(name t_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; OneToMany(mappedBy user) private ListOrder orders new ArrayList(); // Lombok Data 生成 getter/setter }Entity Table(name t_order) public class Order { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String orderNo; ManyToOne JoinColumn(name user_id) private User user; // getter/setter }Controller 直接返回实体GetMapping(/user/{id}) public User getUser(PathVariable Long id) { return userService.getUserById(id); }User 的 orders 里每个 Order 都有 useruser 里又有 orders。Jackson 序列化 User 时先写 id、name再写 orders写第一个 Order 时遇到 user 属性又回到 User 的序列化逻辑。这一轮一轮走下去每次只消耗栈帧不消耗数据最终命中 StackOverflowError被 Spring MVC 包装成 HttpMessageNotWritableException 返回 500。这里的关键是 Lombok Data 会同时生成 getOrders 和 getUser两个 getter 都对外开放。序列化器不关心 JPA 的关联方向它只看 getter 能把哪些属性暴露出来。只要 getter 构成环递归就必然发生。所以最小复现的三要素是双向 getter、父含子集合、子含父引用缺一个都不会触发这条报错。2.2 为什么是 StackOverflowErrorJackson 序列化没有深度上限Jackson 默认的 BeanSerializer 会遍历每个 getter 并递归序列化复杂对象这个递归没有内置的深度上限。可以在本地用 ObjectMapper 直接验证把 Spring MVC 这一层剥掉ObjectMapper mapper new ObjectMapper(); mapper.writeValueAsString(userService.getUserById(1L)); // 抛 JsonMappingException: Infinite recursion (StackOverflowError) // 堆栈里交替出现 User.getOrders 和 Order.getUser判断是不是这条报错看两个特征第一异常消息里带 Infinite recursion第二堆栈里同一个类的 getter 反复出现。Spring Boot 默认每请求占一个线程栈压到几千层递归就会炸所以响应时间不一定变慢但错误率直接拉满。有人会问 Jackson 不是有 FAIL_ON_SELF_REFERENCES 这个序列化特性吗它默认关闭而且只检测“对象直接包含自己”的一环自引用比如 A 的 list 里装 A对 User - Order - User 这种两跳循环无能为力。Jackson 2.15 之后提供的 StreamReadConstraints 也只限制反序列化时的嵌套深度管不到写 JSON 的方向。所以这个 StackOverflowError 只能靠拆环、改引用或换出参结构来终结。报错特征定位手段结论消息含 Infinite recursion (StackOverflowError)看堆栈是否交替出现两个实体的 getter双向 getter 成环消息前缀是 Could not write JSON用 ObjectMapper 单测直接序列化实体确认已走到 Jackson 写阶段同一接口有时好有时炸关联数据为空时不递归有数据才触发典型循环引用2.3 为什么 SQL 侧不炸、JSON 侧炸有同事会疑惑JPA 查出来也是这个对象图为什么数据库查询没炸因为 SQL 侧走的是表和关联的外键查完一次就结束关联集合默认懒加载不访问就不展开。而 Jackson 压根不碰数据库它只按 getter 链递归取值getOrders 拿到集合集合里每个元素又调 getUser。持久化上下文管不到序列化这一层所以递归发生在 MVC 的返回值写入阶段和 SQL 执行是两个完全独立的环节。3. 拆环第一招用 JsonIgnore 和 JsonBackReference 终止 JSON 递归3.1 JsonIgnore 直接砍掉反向属性最常见的止血方式是在“多”方子方的父引用上加 JsonIgnoreEntity Table(name t_order) public class Order { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String orderNo; ManyToOne JoinColumn(name user_id) JsonIgnore private User user; }改完再调 /user/1返回体里 Order 不再带 user 字段orders 这个 json 数组正常输出递归链条被切断。这个方案改动最小适合内部系统或前端根本不需要“订单里的用户信息”的场景。提示JsonIgnore 加在字段上即可不要同时加在 getter 上否则 IDE 有时会报注解冲突排查时反而多一层干扰。注意 JsonIgnore 是双向生效的序列化时不输出 user反序列化时也不会把请求体里的 user 绑定回 Order。如果前端提交订单时依赖 JSON 回传 user 对象后续保存就会拿到空引用这时要由 Service 层根据 userId 重新装配关联。也就是说这个注解解决的只是响应方向的递归不负责重建关联关系。3.2 JsonManagedReference 与 JsonBackReference 配对需要保留父子结构、又不希望环存在时用 Jackson 自带的一对注解在实体两侧配对// User 一侧 OneToMany(mappedBy user) JsonManagedReference private ListOrder orders; // Order 一侧 ManyToOne JsonBackReference private User user;加了之后序列化 User 时会展开 orders但 orders 里的 user 属性被直接跳过结果就是纯粹的父子树结构。注意 JsonBackReference 必须配一个 JsonManagedReference 才能生效只有一边会抛 IllegalArgumentException。反序列化行为要单独说明当整个对象图在一个请求体里提交时Jackson 会通过上下文把父引用回填给子对象但如果前端只提交单个 Order 的 JSON不包含外层 Useruser 仍然是 null。它解决的是“响应方向”的递归持久化时关联仍由 Service 显式处理。3.3 三个注解的适用边界选型表和踩坑点方案序列化行为反序列化行为适用场景JsonIgnore字段不输出字段不接受绑定前端不需要该属性JsonBackReference JsonManagedReference父带子、子不带父同图提交时回填父引用只需读父侧结构JsonIgnoreProperties(user)类级别批量忽略同 JsonIgnore多个实体统一处理用 JsonIgnore 时有一个高频坑属性被滤掉后前端拿到的 json 格式里字段缺失页面取值变成 undefined问题被误判成“前端 bug”。排查时先抓返回报文用编辑器做一次 json 格式化看字段到底在不在再决定改注解还是改前端。另外老项目实体多逐个加注解容易漏我一般会在涉及递归的几个实体类上用 JsonIgnoreProperties 统一定义把名单集中在一处后续 Code Review 好查。4. 保留双向关系的 JSON 序列化JsonIdentityInfo、DTO 与反序列化缺字段4.1 JsonIdentityInfo用 id 引用代替无限嵌套如果前端确实要“用户下所有订单订单里再带用户 ID”注解层面最优雅的是 JsonIdentityInfoEntity JsonIdentityInfo(generator ObjectIdGenerators.PropertyGenerator.class, property id) public class User { // 实体内容不变 } Entity JsonIdentityInfo(generator ObjectIdGenerators.PropertyGenerator.class, property id) public class Order { // 实体内容不变 }序列化结果变成第一次遇到 User 输出完整对象之后再次遇到同一个 User 只输出 {id:1}Order 同理。对象图被压平成“一次展开 引用”递归必然终止因为第二次碰到同一实体时不再进入属性遍历。{ id: 1, name: 张三, orders: [ {id: 101, orderNo: A001, user: {id: 1}}, {id: 102, orderNo: A002, user: {id: 1}} ] }json 转换结果小了前端拿到的还是完整结构只是重复引用变成 ID。但要注意三点id 为 null 的瞬态对象可能生成不可预期的 ObjectId注解必须加在关联链上的所有实体同一个请求里“先展开谁”取决于序列化入口从 User 进和从 Order 进结果不同。这个方案适合对返回结构有洁癖、又不愿意引入 DTO 的团队。注意JsonIdentityInfo 依赖 id 唯一。联合主键或自然键场景要换 ObjectIdGenerators.StringIdGenerator否则引用会串。4.2 用 DTO 隔离实体接口层根治递归老项目在实体上堆满 JSON 注解后往往发现接口 A 想要的字段和接口 B 不一样注解只能取交集。真正的根治是 Controller 不返回实体返回专门定制的 DTO/VOpublic class UserVO { private Long id; private String name; private ListOrderBriefVO orders; public static UserVO from(User user) { UserVO vo new UserVO(); vo.id user.getId(); vo.name user.getName(); vo.orders user.getOrders().stream() .map(o - new OrderBriefVO(o.getId(), o.getOrderNo())) .toList(); return vo; } }GetMapping(/user/{id}) public UserVO getUser(PathVariable Long id) { return UserVO.from(userService.getUserById(id)); }实体上不需要任何 JSON 注解Jackson 序列化的是 UserVOOrderBriefVO 里根本没有 user 属性递归天然不存在。代价是多写一层转换代码换来的是每个接口返回的 json 格式可以独立演进前端要什么给什么。这是我在稍微正规一点的项目里默认采用的做法注解拆环只用来应急。4.3 拆环拆过头反序列化缺字段和写侧递归的对应关系递归问题修完后麻烦往往出现在“反序列化”这一侧。后端接口用 RequestBody UserVO 接收前端漏传必填字段控制台就会报failed to deserialize the json body into the target type: input: missing fie...。这类报错和 Infinite recursion 恰好是同一个问题的两个方向方向典型报错根因位置写响应Could not write JSON: Infinite recursion实体 getter 成环读请求failed to deserialize ... missing field请求体缺字段或 DTO 校验失败检查顺序一般是先看日志里报错的是哪个字段再对比前端实际提交的 json 数组和 DTO 定义最后确认是不是 JsonIgnore 把必填字段滤掉了。如果是就把接收对象单独拆成 RequestDTO里面保留必填字段并加 NotNull 校验返回对象继续用精简的 VO。读写两侧各管各的结构就不会再出现“改注解救一个接口、炸了另一个接口”的连锁反应。5. 验证与兜底用 MockMvc、自定义序列化器压住 StackOverflowError5.1 用 MockMvc 和 python 快速验证返回体不再递归改完注解后先写一条最小测试把返回体抓出来看SpringBootTest AutoConfigureMockMvc class UserControllerTest { Autowired private MockMvc mockMvc; Autowired private ObjectMapper objectMapper; Test void getUser_returnsArrayWithoutRecursion() throws Exception { String body mockMvc.perform(get(/user/1)) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); JsonNode root objectMapper.readTree(body); // orders 必须是数组且元素里不再出现 user 字段 assertTrue(root.get(orders).isArray()); assertFalse(root.get(orders).get(0).has(user)); } }不想写 Java 测试时用 python 直接拉接口验证结构这里用到的是标准库 urllib不依赖额外依赖import json, urllib.request body json.load(urllib.request.urlopen(http://localhost:8080/user/1)) print(type(body[orders]), len(body[orders]))orders 是 json 数组且长度与数据库一致说明递归已终止。再跑一轮压测JMeter 里用 JSON Extractor 取 orders 数组长度做断言确认响应字节数和耗时没有因序列化异常而波动。5.2 兜底方案自定义序列化器给老实体加强制白名单有些遗留实体动不了、注解不敢乱加可以用 JsonSerialize 指定一个只输出关键字段的序列化器从源头控制写出去的 json 结构public class CompactOrderSerializer extends JsonSerializerListOrder { Override public void serialize(ListOrder orders, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartArray(); for (Order o : orders) { gen.writeStartObject(); gen.writeNumberField(id, o.getId()); gen.writeStringField(orderNo, o.getOrderNo()); gen.writeEndObject(); } gen.writeEndArray(); } }在 User.orders 字段上挂 JsonSerialize(using CompactOrderSerializer.class)等于把订单数组压成白名单结构Order 里的 user 根本没有机会进入 json 转换。它比 JsonIgnore 稳的地方在于不受实体字段增删影响白名单显式控制适合“只读导出”场景比如把一批用户连同订单导出成 json 文件时用。5.3 按报错特征快速选型报错或场景根因第一选择Could not write JSON: Infinite recursion双向 getter 成环JsonIdentityInfo 或 DTO返回的 json 数组缺字段JsonIgnore 滤掉必填属性调整注解位置或改 VOfailed to deserialize ... missing field请求体缺字段RequestDTO NotNull导出 json 文件也炸实体序列化入口多自定义序列化器兜底这套决策路径的核心只有一句递归的根不在数据库关联而在 getter 构成的序列化环拆环时优先想“接口需要什么结构”而不是“实体上哪条注解最顺手”。压测环境里直接抓请求线程的堆栈只要还能看到两个实体 getter 交替出现的栈帧就说明递归链路没拆干净按 5.2 的白名单序列化器收口即可。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价