如何处理Hibernate在使用Spring Data JPA同时获取多个集合时抛出的MultipleBagFetchException

543 阅读3分钟

简介

在这篇文章中,我将向你展示如何处理Hibernate在使用Spring Data JPA同时获取多个集合时抛出的MultipleBagFetchException。

MultipleBagFetchException

正如我解释的那样,当你试图一次获取多个List 集合时,Hibernate会抛出MultipleBagFetchException

通过尝试一次获取多个一对多多对多的关联,会产生一个笛卡尔产品,而且,即使Hibernate没有抛出MultipleBagFetchException ,我们仍然希望避免在我们的查询结果集中得到一个笛卡尔产品。

领域模型

让我们假设我们有一个Post 父实体,它与PostComment 实体有一个双向的@OneToMany 关联,与Tag实体有一个单向的@ManyToMany 关联。

Spring Data JPA MultipleBagFetchExceptionSpring Data JPA MultipleBagFetchException

Post 实体有一个comments 集合和一个tags 集合,像这样。

@Entity(name = "Post")
@Table(name = "post")
public class Post {

    @Id
    private Long id;

    private String title;

    @OneToMany(
        mappedBy = "post", 
        cascade = CascadeType.ALL, 
        orphanRemoval = true
    )
    private List<PostComment> comments = new ArrayList<>();

    @ManyToMany(
        cascade = {CascadeType.PERSIST, CascadeType.MERGE}
    )
    @JoinTable(name = "post_tag",
        joinColumns = @JoinColumn(name = "post_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private List<Tag> tags = new ArrayList<>();
    
}

我们的目标是获取一些Post 实体以及它们相关的commentstags 集合。

使用Spring Data JPA查询注解获取MultipleBagFetchException

人们采取的第一个方法是创建一个@Query 方法,在commentstags 集合上使用JOIN FETCH ,就像下面的例子一样。

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("""
        select distinct p
        from Post p
        left join fetch p.comments
        left join fetch p.tags
        where p.id between :minId and :maxId
        """)
    List<Post> findAllWithCommentsAndTags(
        @Param("minId") long minId, 
        @Param("maxId") long maxId
    );
}

但是,如果你试图这样做,你的Spring应用程序甚至不会启动,在试图从相关的@Query 注释中创建JPATypedQuery 时,抛出以下MultipleBagFetchException

java.lang.IllegalArgumentException: org.hibernate.loader.MultipleBagFetchException: 
cannot simultaneously fetch multiple bags: [
    com.vladmihalcea.book.hpjp.spring.data.query.multibag.domain.Post.comments, 
    com.vladmihalcea.book.hpjp.spring.data.query.multibag.domain.Post.tags
]
at org.hibernate.internal.ExceptionConverterImpl
    .convert(ExceptionConverterImpl.java:141)
at org.hibernate.internal.ExceptionConverterImpl
    .convert(ExceptionConverterImpl.java:181)
at org.hibernate.internal.ExceptionConverterImpl
    .convert(ExceptionConverterImpl.java:188)
at org.hibernate.internal.AbstractSharedSessionContract
    .createQuery(AbstractSharedSessionContract.java:757)
at org.hibernate.internal.AbstractSharedSessionContract
    .createQuery(AbstractSharedSessionContract.java:114)
at org.springframework.data.jpa.repository.query.SimpleJpaQuery
    .validateQuery(SimpleJpaQuery.java:90)
at org.springframework.data.jpa.repository.query.SimpleJpaQuery
    .<init>(SimpleJpaQuery.java:66)
at org.springframework.data.jpa.repository.query.JpaQueryFactory
    .fromMethodWithQueryString(JpaQueryFactory.java:51)

如何使用Spring Data JPA修复MultipleBagFetchException?

因此,虽然我们不能使用一个JPA查询来获取两个集合,但我们肯定可以使用两个查询来获取我们需要的所有数据。

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("""
        select distinct p
        from Post p
        left join fetch p.comments
        where p.id between :minId and :maxId
        """)
    List<Post> findAllWithComments(
        @Param("minId") long minId, 
        @Param("maxId") long maxId
    );

    @Query("""
        select distinct p
        from Post p
        left join fetch p.tags
        where p.id between :minId and :maxId
        """)
    List<Post> findAllWithTags(
        @Param("minId") long minId, 
        @Param("maxId") long maxId
    );
}

findAllWithComments 查询将获取所需的Post 实体及其相关的PostComment 实体,而findAllWithTags 查询将获取Post 实体及其相关的Tag 实体。

执行两个查询将允许我们在查询结果集中避免笛卡尔积,但我们必须聚合结果,以便我们返回一个单一的Post 条目集合,其中包含初始化的commentstags 集合。

而这正是Hibernate一级缓存或持久化上下文可以帮助我们实现这一目标的地方。

PostService 定义了一个findAllWithCommentsAndTags方法,其实现方式如下。

@Service
@Transactional(readOnly = true)
public class PostServiceImpl implements PostService {
    
    @Autowired
    private PostRepository postRepository;

    @Override
    public List<Post> findAllWithCommentsAndTags(
            long minId, long maxId) {
            
        List<Post> posts = postRepository.findAllWithComments(
            minId, 
            maxId
        );

        return !posts.isEmpty() ? 
            postRepository.findAllWithTags(
                minId, 
                maxId
            ) :
            posts;
    }
}

由于@Transactional 注解被放置在类的层面上,所有的方法都将继承它。因此,findAllWithCommentsAndTags 服务方法将在事务性上下文中执行,这意味着两个PostRepository 方法的调用将在同一个持久化上下文中发生。

由于这个原因,findAllWithCommentsfindAllWithTags 方法基本上将返回两个List 对象,其中包含相同的Post 对象引用,因为你最多只能有一个实体引用由给定的持久化上下文管理。

findAllWithComments 方法将获取Post 实体并将它们存储在持久化上下文或一级缓存中时,第二个方法,findAllWithTags ,将只是把现有的Post 实体与从DB中获取的引用合并,这些引用现在包含了初始化的tags 集合。

这样,commentstags 集合都将在返回ListPost 实体给服务方法调用者之前被取走。

在我们的集成测试中,我们可以验证这两个集合已经被初始化了。

List<Post> posts = postService.findAllWithCommentsAndTags(
    1L,
    POST_COUNT
);

for (Post post : posts) {
    assertEquals(
        POST_COMMENT_COUNT, 
        post.getComments().size()
    );
    
    assertEquals(
        TAG_COUNT, 
        post.getTags().size()
    );
}

正如你所看到的,我们可以读取commentstags 集合的大小,即使在持久化上下文被关闭之后,因为它们已经被findAllWithCommentsAndTags 服务方法执行的两个实体查询所获取了。