@ManyToMany或@OneToMany/@ManyToOne导致循环依赖的问题 java.lang.StackOverflowError jpa

Jakarta EE

Bidirectional relationships must follow these rules.

  • The inverse side of a bidirectional relationship must refer to its owning side by using the mappedBy element of the @OneToOne@OneToMany, or @ManyToMany annotation. The mappedBy element designates the property or field in the entity that is the owner of the relationship.
  • The many side of many-to-one bidirectional relationships must not define the mappedBy element. The many side is always the owning side of the relationship.
  • For one-to-one bidirectional relationships, the owning side corresponds to the side that contains the corresponding foreign key.
  • For many-to-many bidirectional relationships, either side may be the owning side.

Unidirectional Relationships

In a unidirectional relationship, only one entity has a relationship field or property that refers to the other. For example, LineItem would have a relationship field that identifies Product, but Product would not have a relationship field or property for LineItem. In other words, LineItem knows about Product, but Product doesn’t know which LineItem instances refer to it.

2020年12月6日完工。从此jpa多对多不再是阻碍

2021年11月09日。在此对循环依赖做一个收尾

循环依赖出现的原因
  • 情景一:在Spring中,Bean A中注入Bean B,在Bean B中又注入了Bean A。这是两层的循环链,具体业务中,循环链的层数可能更多。
@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class BossServiceServiceImpl implements BossServiceService {

    @Autowired
    private BossProductService bossProductService;
}


@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class BossProductServiceImpl implements BossProductService {

    @Autowired
    private BossServiceService bossServiceService;
}
  • 情景二:在Java中,Entity A中有类型为Entity B的属性,Entity B中有Entity A的属性。或者层级更深。当序列化(toString())时,就会因循环依赖导致栈溢出。业务中以使用MapStruct将 entity转为dto为例。
@Getter
@Setter
@ToString
public class BossProductDTO implements Serializable {

    // ID
    private Long id;

    // 名称
    private String name;

    // 类型,1-基础包产品 2-增值包产品 3-套餐产品
    private Integer type;

    private Set<BossProductServiceDTO> bossProductServiceEntities;
}

@Getter
@Setter
@ToString
//  hashCode导致了栈溢出。所以不要用@Data
public class BossProductServiceDTO implements Serializable {

    // ID
    private Long id;

    // 产品ID
    private BossProductDTO bossProductEntity;

    // 服务ID
    private BossServiceDTO bossServiceEntity;

    // 状态:0-下线,1-上线
    private Integer status;

    private Integer sequence;
}
循环依赖的解决方案
  • 针对情景一,可以添加@Lazy注解解决
@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class BossProductServiceImpl implements BossProductService {
    
    //    @Lazy注解注解的作用主要是减少springIOC容器启动的加载时间
    //    当出现注入的循环依赖时,也可以添加@Lazy
    @Lazy
    @Autowired
    private BossServiceService bossServiceService;
}

@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class BossServiceServiceImpl implements BossServiceService {

    @Lazy
    @Autowired
    private BossProductService bossProductService;
}
  • 针对情景二。

MapStruct官方已给出解决MapStruct中循环依赖的方法; MapStruct关于循环依赖的讨论

但这个只是解决了在toDto、toEntity时的循环依赖问题,在调用toString()方法时,依然会栈溢出。且若实体有重写toString()方法,在转换时(toDto、toEntity中),就可以看到循环调用的情况(不重写toString()就不会有这种情况)。

所以当前找到的解决方法,还是使用额外的Dto,来中断循环链

@Getter
@Setter
@ToString
public class BossProductDTO implements Serializable {

    // ID
    private Long id;

    // 名称
    private String name;

    // 类型,1-基础包产品 2-增值包产品 3-套餐产品
    private Integer type;

    private Set<BossProductServiceDTO> bossProductServiceEntities;
}

@Getter
@Setter
@ToString
public class BossProductServiceDTO implements Serializable {

    // ID
    private Long id;

    // 产品ID
    private BossProductSmallDTO bossProductEntity;

    // 服务ID
    private BossServiceSmallDTO bossServiceEntity;

    // 状态:0-下线,1-上线
    private Integer status;

    private Integer sequence;
}

@Data
public class BossProductSmallDTO implements Serializable {

    // ID
    private Long id;

    // 名称
    private String name;

    // 类型,1-基础包产品 2-增值包产品 3-套餐产品
    private Integer type;

}

这里将Entity B中的Entity A属性的类型,调整为 Entity Asmall,其中没有Entity B,从而中断了循环链。

相关代码

场景记录

  • 未配置CycleAvoid、未使用SmallDTO时,
    • 若重写了hashCode,会报栈溢出。
    • 若未重写,则在转换时,转换方法内调toDTO方法栈溢出
  • 配置CycleAvoid,未使用SmallDTO时,
    • 若重写了toString(),则在转换时会循环调用toDTO系列方法,但不会报栈溢出,在调toString()时,报栈溢出。
    • 若未重写toString(),则在转换时不会出现循环依赖的问题,但在序列化时,依旧出现栈溢出,在序列化(类似于调toString())之前,一切正常。
  • 若使用了SmallDTO,则可以解决循环依赖问题,是否使用CycleAvoid当前影响不大

@ManyToMany 适合于单方维护多对多关系的业务。维护方使用@JoinTable注解,被维护方使用mappedBy属性配置。被维护方无法更新关联表属性。一定不要双方都使用@JoinTable,否则在更新时,会错误的移除部分关联数据,切记!!!

可以将返回的实体封装成DTO,然后在DTO中不包含另一个的依赖

@OneToMany和@ManyToOne适用于双向维护多对多关系的业务。双方使用@OneToMany注解,在关联表上使用@ManyToOne注解。这种方式还适用于关联表有其他业务字段的场景。

这时可按下面的方式。不使用@Data注解。而是使用@Getter @Setter甚至是@ToString。

原因:
使用lombak的@Data,会自动生成hashCode()方法。就是这个方法导致了栈溢出。另建议重写toString,排除关联字段。

解决:
将@Data换成
@Setter
@Getter

具体代码github地址。主体更推荐使用@OneToMany和@ManyToOne的方式

其中main仓库针对@OneToMany和@ManyToOne的方式;master分支针对@ManyToMany的方式

https://github.com/lWoHvYe/spring-boot-jpa-cascade

主要是DTO和SmallDTO中的处理

一对多对多方排序及筛选
@OneToMany
@JoinColumn
//@JoinFormula("upper(substring(middle_name from 0 for 1))") call native SQL functions,可替代@JoinColumn
//@ColumnTransformer(read="decrypt(credit_card_num)", write="encrypt(?)") 对于rw的,还有@ColumnTransformer可以使用
// 排序 加到sql上,但后续Java层的处理可能导致前端收到的数据不是此顺序
@OneToMany
@JoinColumn
// 排序 加到sql上
@OrderBy(value = "sequence DESC, id ASC")
// 前端传的实体会按照此顺序排列,影响jpa级联更新的顺序
@OrderColumn("sequence DESC")
// 筛选 加到sql上,不太建议使用这个,因为及联更新时会略去 !=condition的,从而导致重复
@Where(clause = " status = 1 ")
// 注意类型用Set会重新排序。导入OrderBy无效。但级联更新正常
// 使用Set存取顺序不一致是因为使用的实现是HashSet,
private Set<UserRole> userRoles;
// 类型用List时,OrderBy正常。但级联更新有问题且返回的记录中有null。所以推荐使用Set
//private List<UserRole> userRoles;
image-20210115142932057

实体A中的关联集合A-B.若在更新时,对集合进行了操作。则那些执行修改操作的关联记录不会交由jpa级联处理。

比如产品中有产品与服务的关联关系。若关联关系中不设置产品id,是可以正常保存的。jpa会先将关联中的产品id设置为null。然后执行update设置其值。但如果在保存方法中操作了关联集合。则jpa不会执行后面的update。此时,若产品id还不传,记录对应字段将为null(针对update的部分,insert的部分正常)。推测为实体的状态发生了变化

当关联属性字段 userRoles 是List时,在更新User时,userRoles中的每条记录userRole可以不包含id和user.id,这样会清空然后全部重建,但如果包含了id,则必须有user.id,不然该属性会被置null,但使用Set时,是没有这个问题的,所以各方面都推荐使用Set

todo

public enum CascadeType {
    ALL,
    PERSIST, // 持久化,新增支配方时,若带有关联关系,且被支配方已存在,会报detached entity passed to persist,若不存在会新增;更新支配方时,若关联的被支配方不存在,会insert,但insert的结果好像有问题。
    MERGE, // 更新,新增支配方时,可带着关联关系(被支配方需存在);更新支配方时,除了关联关系,还可以对被支配方update (未传的被支配方字段 会被置空),且若被支配方不存在,会新增;
    REMOVE, // 删除,删除支配方时,除了关联关系,还会将被支配方delete,这个慎用
    REFRESH, // 刷新,获取最新的数据,因为在操作期间可能别的线程已改了数据并持久化了
    DETACH; // 游离,删除支配方时,若其有与其相关的外键,会撤销所以相关的外键关联

    private CascadeType() {
    }
}

针对otm/mto 可以使用all。但针对mtm一定不能包含remove,推荐 merge,可加上refresh

2022-07-09更新

虽然上面已经讲过,这里还是重复一下,调整一些细节

  1. 针对@ManyToMany这种,可以在一方的对应属性上加上 @JsonIgnore,试图来打破序列化时的循环依赖问题
  2. 针对toString,实际上除了不重写toString方法或不使用@ToString,也可以重写但剔除另一个相关的属性
    @JsonIgnore // 视情况使用 @JsonBackReference或 @JsonManagedReference
    @ManyToMany(mappedBy = "bossServices")
    private List<BossProductEntity> bossProducts;

    // 剔除关联的属性
    @Override
    public String toString() {
        return "BossServiceEntity(id=" + this.getId() + ", code=" + this.getCode() + ", name=" + this.getName() + ")";
    }

Leave a Reply

Your email address will not be published. Required fields are marked *

lWoHvYe 无悔,专一