ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot 中 JPA 双向关联导致 Jackson 序列化无限递归的解决

Spring Boot 中 JPA 双向关联导致 Jackson 序列化无限递归的解决 简介在Spring Boot结合JPA的项目中Controller返回JSON数据时若出现“Could not write JSON: Infinite recursion”异常并最终抛StackOverflowError通常是因为实体类之间存在双向循环引用尤其是一对多/多对一关系中的集合属性Jackson序列化时反复遍历同一个对象导致。这份PDF面向日常使用Jackson完成Java对象序列化的后端开发人员从JPA实体关联关系出发分析了引用链形成的根因并整理了多套可落地的修复思路在父子关联属性上配合使用参考管理注解通过忽略注解跳过不需要输出的字段利用对象标识注解避免重复展开以及自定义序列化器或调整ObjectMapper配置做兜底。每种方案都配有对应的代码示例和排错步骤能够帮助开发者结合自身实体结构快速选择最合适的处理方式。内容按问题现象、原因分析、解决方案和代码示例组织结构清晰便于按需查阅。资源包共1个文件格式为PDF整体仅40KB内容轻量聚焦目前已有4046人浏览学习适合遇到同类循环引用报错、希望快速定位和解决的Java工程师直接参考。1. 一条递归报错链从 HttpMessageNotWritableException 到 PersistentBagSpring Boot 项目里最磨人的一类问题接口能查到数据前端却收到 500控制台刷出一屏org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError)。堆栈里出现的不是业务异常而是com.fasterxml.jackson.databind.JsonMappingExceptionreference chain 上来回穿梭的是同一个实体类和org.hibernate.collection.internal.PersistentBag。这是 Jackson 在把 Java 对象转换成 JSON 格式时遇到了双向实体关联导致的无限递归A 对象里有 BB 对象里又有 A序列化器一头扎进去出不来最终把 JVM 调用栈撑爆。后端日志只能反复看到某张表名来回出现却很难一眼定位是哪个字段闯的祸。这类错误在 JPA 的一对多、多对一、多对多映射里经常出现处理思路是一致的在实体层切断递归而不是去改 Controller。下面从 Jackson 的遍历机制开始拆这条链路。2. 双向关联下的序列化链路Jackson 为什么会在 JPA 实体上死循环2.1 Jackson 的属性发现机制从 getter 开始下钻Jackson 把普通 Java 对象当作 POJO 处理序列化时通过反射扫描公共 getter 方法和公共字段来推导属性集合。JPA 实体的字段通常是 private真正暴露给 Jackson 的就是 getter因此序列化路径完全由 getter 决定。只要一个 getter 返回了非空对象Jackson 就会递归进入该对象的序列化器继续遍历它的 getter如此一层层向下展开。一对多双向关联是触发递归的典型结构Entity Table(name t_type) public class Type { Id private Long id; private String name; OneToMany(mappedBy type) private ListUser users new ArrayList(); } Entity Table(name t_user) public class User { Id private Long id; private String nickname; ManyToOne JoinColumn(name type_id) private Type type; }序列化Type时Jackson 先输出 id、name然后走到getUsers()把 List 当作 JSON 数组逐个写出。第一个User对象进入自己的序列化器后又碰到getType()于是回到Type的序列化流程。此时Type的 users 还没遍历完Jackson 又继续展开第二个User……这一层一层的递归调用没有任何出口直到栈深度超过 JVM 限制抛出的就是StackOverflowError。这个错误是java.lang.Error而不是ExceptionSpring 默认的ExceptionHandler接不住它所以日志里只会留下异常堆栈接口返回一个空白的 500。2.2 PersistentBagreference chain 里反复出现的集合包装再看报错堆栈里的关键片段through reference chain: net.zjitc.xxx.pojo.XXX[users] - net.zjitc.xxx.pojo.XXX[users] - org.hibernate.collection.internal.PersistentBag[0] - net.zjitc.xxx.pojo.XXX[type] - net.zjitc.xxx.pojo.XXX[users] - org.hibernate.collection.internal.PersistentBag[0] - ...PersistentBag是 Hibernate 对List的持久化集合实现它实现了List接口但内部持有当前 Session 的引用元素访问时会触发延迟加载。这里反复出现PersistentBag[0]说明序列化已经进入了 Hibernate 的集合包装类逐个取出集合元素而元素本身又是同一个实体对象。这同时也传递了一个信息集合已经初始化过了里面装的是真实实体不是空代理。如果集合没有被初始化报错通常是LazyInitializationException而不是无限递归。能在 reference chain 里看到具体的实体引用说明数据都在只是递归没有出口。2.3 手工序列化把问题从接口里剥离出来接口报错时请求链路长日志被拦截器、参数解析混在一起。更直接的方式是绕开 HTTP用 ObjectMapper 单独序列化实体确认问题是否出在实体本身Autowired private ObjectMapper objectMapper; Test void manualSerialize() throws JsonProcessingException { Type type typeRepository.findById(1L).orElseThrow(); String json objectMapper.writeValueAsString(type); System.out.println(json); }这里直接调用writeValueAsString不涉及 Spring MVC 的AbstractJackson2HttpMessageConverter也没有 Controller 层的干扰。如果这条测试抛出的也是StackOverflowError说明递归发生在实体结构里改 Controller 返回类型、加ResponseBody都没用必须从实体或序列化配置下手。3. 用 JsonBackReference 与 JsonManagedReference 切断递归getter 上的正确标注法3.1 两个注解的分工与配对规则JsonManagedReference和JsonBackReference是 Jackson 提供的配对注解专门处理双向引用。JsonManagedReference管理序列化的正向方向标注后正常输出该属性JsonBackReference标注反向引用序列化时直接忽略不再向下遍历。注解标注位置序列化行为说明JsonManagedReference一的一方持有集合的父方正常输出集合内容作为序列化的入口方向JsonBackReference多的一方持有ManyToOne的子方完全忽略该属性防止反向回跳注意这两个注解必须配对出现不能只标一个。只标JsonBackReference时Jackson 不知道对应的正向属性在哪里后续反序列化或引用映射时可能报 managed/back reference 不匹配的异常两个方向都标JsonBackReference则会把关联数据全部丢掉前端拿到的对象没有引用关系。3.2 为什么标在 getter 上而不是 setter 上Jackson 序列化是读操作属性发现依赖 getterJsonBackReference控制的是写出方向标注在 setter 上对该属性的序列化行为没有影响。这个细节在 JPA 项目里尤其重要实体使用字段访问模式默认的Id标在字段上时Hibernate 直接操作字段Jackson 通过 getter 读取两者互不干扰。注解标在字段上不是完全无效但当实体被 Hibernate 代理增强后字段上的注解在某些版本下不会随代理类暴露给 Jackson而 getter 上的注解始终生效。如果把注解标在 setter 上序列化时 Jackson 直接忽略这个设置递归照样发生而且不容易察觉。因此稳妥做法是JsonManagedReference放在一方的getUsers()上JsonBackReference放在多方的getType()上正好对应序列化时忽略反向引用这一语义。3.3 完整改造Type 与 User 的配对标注改造后的实体类如下Entity Table(name t_type) public class Type { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; OneToMany(mappedBy type, cascade CascadeType.ALL) private ListUser users new ArrayList(); JsonManagedReference public ListUser getUsers() { return users; } public void setUsers(ListUser users) { this.users users; } }Entity Table(name t_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String nickname; ManyToOne JoinColumn(name type_id) private Type type; JsonBackReference public Type getType() { return type; } public void setType(Type type) { this.type type; } }第一段代码里JsonManagedReference标注在Type的getUsers()上Jackson 序列化Type时正常输出 users 数组。第二段代码里JsonBackReference标注在User的getType()上序列化User时不再展开 type 字段递归链在User这一侧被切断。改造后的序列化结果结构清晰从接口返回Type前端拿到的是一个带 users 数组的对象数组里的每个 User 没有 type 字段从接口单独返回User则只输出基础字段不再连带一串 Type。整体 JSON 转换后体积变小嵌套深度也固定了。请求体里不需要包含被忽略的反向字段前端提交数据时也不必再构造相互嵌套的完整结构。3.4 常见错误标注与排查方向实际项目中注解标了但递归还在通常是下面几种情况只标了JsonManagedReference没有对应的JsonBackReferenceJackson 无法建立引用对序列化时仍然沿着 getter 往下走。两个方向都标了JsonManagedReference集合正常输出但关联对象里的反向引用还在递归依旧发生。注解标在了 setter 上序列化行为完全没变。Lombok 在字段上生成的 getter 和手写的 getter 混用注解位置不一致某个方向漏掉了。排查时先确定递归发生的入口堆栈里先出现哪个类的 getter就从那一侧开始检查注解是否配对。4. 当注解不够用JsonIgnore、JsonIdentityInfo 与 PersistentBag 场景的取舍4.1 JsonIgnore直接砍掉一个方向如果前端确实不需要某个关联字段JsonIgnore是最省事的做法Entity Table(name t_user) public class User { ManyToOne JoinColumn(name type_id) JsonIgnore private Type type; }这个注解标在字段或 getter 上都有效Jackson 对该属性既不序列化也不反序列化。和JsonBackReference的区别在于后者保留了引用关系的语义只是序列化时不输出前者直接把该字段从 JSON 解析流程里拿掉完全不参与 binding。副作用是如果接口需要接收带type的 JSON字段会被静默丢弃不会报错也不会回填。4.2 JsonIdentityInfo用 id 代替重复对象当两个方向的关联数据都需要返回时简单忽略会丢信息。JsonIdentityInfo提供了另一种策略给对象加一个身份标识第一次出现时输出完整对象后续再遇到同一对象时输出 id 值用扁平引用替代嵌套展开。Entity Table(name t_type) JsonIdentityInfo( generator ObjectIdGenerators.PropertyGenerator.class, property id) public class Type { // 实体字段不变 } Entity Table(name t_user) JsonIdentityInfo( generator ObjectIdGenerators.PropertyGenerator.class, property id) public class User { // 实体字段不变 }序列化结果大致是Type 对象中第一次出现 User 时输出完整结构后续引用同一 User 的地方直接输出它的 id 值。这个方案适合树形结构、组织架构、评论回复这类需要保留完整引用关系又不想无限递归的场景。代价是 JSON 结构不再是纯嵌套前端需要按 id 维护对象索引解析逻辑变复杂。4.3 注解方案对比与选型边界方案序列化行为数据完整性适用场景JsonManagedReferenceJsonBackReference单向输出反向忽略丢失反向引用一对多常规列表JsonIgnore指定字段完全忽略丢失被忽略字段前端不需要该字段JsonIdentityInfo首见全量再见用 id 引用引用关系保留双向关联都重要的图结构我一般会先问一个问题前端拿这个 JSON 到底要画什么。只要表格里展示用户列表JsonIgnore掉 type 就行如果页面要展示某类型下的用户和某用户所属的类型两个入口JsonIdentityInfo更合适大部分管理后台场景JsonManagedReference配JsonBackReference已经足够且语义最好理解。4.4 多对多与中间表场景多对多两个方向都是集合直接配JsonManagedReference/JsonBackReference会面临哪个方向是正向的语义混乱容易出现一侧完全丢失的情况。常见处理方式是选一个方向加JsonIgnore另一个方向正常输出涉及中间表额外字段时直接返回 DTO不要在实体上堆注解。实体注解是全局的一个接口改了会影响所有返回该实体的接口接口数量多起来之后DTO 反而比注解更容易维护边界。5. 本地复现与验证用 Spring Boot 接口确认序列化结果不再 StackOverflow5.1 组装一个最小复现项目两个实体、一个 Repository、一个 Controller 就够。启动时初始化几条数据Component public class DataInitializer implements CommandLineRunner { private final TypeRepository typeRepository; private final UserRepository userRepository; public DataInitializer(TypeRepository typeRepository, UserRepository userRepository) { this.typeRepository typeRepository; this.userRepository userRepository; } Override public void run(String... args) { Type type new Type(); type.setName(admin); User user new User(); user.setNickname(piconjo); user.setType(type); type.getUsers().add(user); typeRepository.save(type); userRepository.save(user); } }这段初始化逻辑里Type 和 User 通过 setter 建立双向引用再分别持久化保证查出来的数据和我们讨论的递归结构一致。CommandLineRunner在 Spring Boot 启动完成后执行适合本地造数。5.2 在测试里验证序列化结构SpringBootTest class SerializationTest { Autowired private ObjectMapper objectMapper; Autowired private TypeRepository typeRepository; Test void typeSerializationShouldNotRecurse() throws JsonProcessingException { Type type typeRepository.findAll().get(0); String json objectMapper.writeValueAsString(type); JsonNode root objectMapper.readTree(json); JsonNode firstUser root.get(users).get(0); assertFalse(firstUser.has(type)); } }这里断言 users 数组里的第一个用户不再包含 type 字段。如果注解没配对或标错位置writeValueAsString会直接抛StackOverflowError测试立刻失败比启动整个应用调试定位快得多。Hibernate的PersistentBag在事务内已经初始化序列化时不会再触发懒加载异常这个测试可以稳定复现问题本身。5.3 用 curl 确认接口返回服务启动后直接请求接口curl -s http://localhost:8080/type | jq .users[0] | has(type)输出false说明递归已经解除。注意别在浏览器里直接看返回体递归修复前 JSON 会膨胀到巨大体积浏览器解析直接卡死容易误判为接口超时。用jq只看关键字段解析结果一目了然。5.4 递归解决后别忽略懒加载注解解决了递归但OneToMany默认是 LAZY 加载。如果同一事务结束之后才触达 users接口会抛LazyInitializationException。Spring Boot 2.x 默认开启spring.jpa.open-in-viewtrueController 返回前集合通常还能初始化所以本地能跑通关闭 OSIV 后仓储层要主动join fetch或使用EntityGraph把集合查出来再交给 Jackson 序列化。这也是为什么有些项目本地正常、部署到生产就报懒加载错误的原因。验证完这一整套链路后再回到最初的那条报错堆栈HttpMessageNotWritableException只是表象真正要处理的是实体双向引用和 Jackson 属性遍历的配合。按顺序检查 getter 上的注解、集合初始化时机、事务边界递归问题可以在一轮测试内确定边界并收口。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进