1. 简介

在本篇文章中,我们将详细讲解 Hibernate 中的 MultipleBagFetchException 异常。首先会介绍一些必要的前置知识,然后通过多个示例逐步演示引发该异常的原因,并最终提供一个理想解决方案。

为了更好地说明问题,我们构建了一个简单的音乐应用模型来演示各种解决方式。

2. Hibernate 中的 Bag 是什么?

Bag 与 Java 中的 List 类似,允许包含重复元素,但它不保证顺序

⚠️ Bag 是 Hibernate 特有的术语,并不属于 Java Collections Framework。

虽然 Bag 和 List 在底层都使用 java.util.List 实现,但在 Hibernate 的语义中它们是不同的:

  • Bag 的定义:

    // @ 任意集合映射注解
    private List<T> collection;
    
  • List 的定义(注意有 @OrderColumn):

    // @ 任意集合映射注解
    @OrderColumn(name = "position")
    private List<T> collection;
    

📌 总结:没有指定排序字段的 List 就是 Bag。

3. MultipleBagFetchException 出现的原因

当在一个实体中同时 eager 加载两个或以上的 Bag 时,可能会产生笛卡尔积(Cartesian Product)。由于 Bag 没有明确的顺序,Hibernate 无法正确地将列数据映射到对应的实体上,因此抛出 MultipleBagFetchException

来看一个具体例子:

假设我们要定义一个 Artist 实体,它包含歌曲列表和优惠列表两个 Bag 集合,并且都设置为 eager 加载:

@Entity
class Artist {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "artist", fetch = FetchType.EAGER)
    private List<Song> songs;

    @OneToMany(mappedBy = "artist", fetch = FetchType.EAGER)
    private List<Offer> offers;

    // constructor, equals, hashCode
}

此时直接运行程序,会立即抛出 MultipleBagFetchException,Hibernate 甚至无法创建 SessionFactory。

正确的做法是将其中一个或两个集合改为 lazy 加载:

@OneToMany(mappedBy = "artist")
private List<Song> songs;

@OneToMany(mappedBy = "artist")
private List<Offer> offers;

✅ 这样就可以正常启动程序了。但要注意:如果我们在查询中同时 eager 加载这两个 Bag,仍然会抛出异常。

4. 模拟 MultipleBagFetchException

上一节已经解释了异常的触发条件,下面我们用集成测试来验证一下。

继续使用上面的 Artist 实体,编写如下测试代码:

@Test
public void whenFetchingMoreThanOneBag_thenThrowAnException() {
    IllegalArgumentException exception =
      assertThrows(IllegalArgumentException.class, () -> {
        String jpql = "SELECT artist FROM Artist artist "
          + "JOIN FETCH artist.songs "
          + "JOIN FETCH artist.offers ";

        entityManager.createQuery(jpql);
    });

    final String expectedMessagePart = "MultipleBagFetchException";
    final String actualMessage = exception.getMessage();

    assertTrue(actualMessage.contains(expectedMessagePart));
}

📌 测试结果显示:我们确实捕获到了 IllegalArgumentException,其根本原因就是 MultipleBagFetchException

5. 域模型结构

在进入解决方案之前,先看看我们的示例模型结构。

我们以一个音乐应用为例,涉及的核心实体包括:

5.1 Album 实体

@Entity
class Album {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "album")
    private List<Song> songs;

    @ManyToMany(mappedBy = "followingAlbums")
    private Set<Follower> followers;

    // constructor, equals, hashCode

}
  • songs 是 Bag。
  • followers 是 Set。

5.2 User 实体

@Entity
class User {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "createdBy", cascade = CascadeType.PERSIST)
    private List<Playlist> playlists;

    @OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST)
    @OrderColumn(name = "arrangement_index")
    private List<FavoriteSong> favoriteSongs;
    
    // constructor, equals, hashCode
}
  • playlists 是 Bag。
  • favoriteSongs 是有序 List。

6. 方案一:改用 Set(单条 JPQL 查询)

⚠️ 注意:这种方式会产生笛卡尔积,仅作为临时绕过方案。

✅ 如果其中一个集合是 Set,就不会触发 MultipleBagFetchException。

例如 Album 实体中的 followers 是 Set,所以可以这样写:

@Test
public void whenFetchingOneBagAndSet_thenRetrieveSuccess() {
    String jpql = "SELECT DISTINCT album FROM Album album "
      + "LEFT JOIN FETCH album.songs "
      + "LEFT JOIN FETCH album.followers "
      + "WHERE album.id = 1";

    Query query = entityManager.createQuery(jpql)
      .setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false);

    assertEquals(1, query.getResultList().size());
}

📌 只要不是两个 Bag 同时被 fetch,就不会报错。

💡 使用 QueryHints.HINT_PASS_DISTINCT_THROUGH 可避免 JPQL 中的 DISTINCT 影响底层 SQL 执行效率。

7. 方案二:改用 List(单条 JPQL 查询)

⚠️ 同样可能产生笛卡尔积,谨慎使用。

✅ 如果只有一个 Bag,另一个是有序 List,也可以安全加载。

比如 User 实体:

@Test
public void whenFetchingOneBagAndOneList_thenRetrieveSuccess() {
    String jpql = "SELECT DISTINCT user FROM User user "
      + "LEFT JOIN FETCH user.playlists "
      + "LEFT JOIN FETCH user.favoriteSongs ";

    List<User> users = entityManager.createQuery(jpql, User.class)
      .setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false)
      .getResultList();

    assertEquals(3, users.size());
}

📌 playlists 是 Bag,而 favoriteSongs 是有序 List,因此不会触发异常。

8. 最佳实践:使用多条查询(推荐)

✅ 为了彻底避免笛卡尔积和性能问题,最佳方式是分多次查询。

回到最开始的 Artist 实体——它有两个 Bag:songsoffers

我们可以分两步来加载:

@Test
public void whenUsingMultipleQueries_thenRetrieveSuccess() {
    String jpql = "SELECT DISTINCT artist FROM Artist artist "
      + "LEFT JOIN FETCH artist.songs ";

    List<Artist> artists = entityManager.createQuery(jpql, Artist.class)
      .setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false)
      .getResultList();

    jpql = "SELECT DISTINCT artist FROM Artist artist "
      + "LEFT JOIN FETCH artist.offers "
      + "WHERE artist IN :artists ";

    artists = entityManager.createQuery(jpql, Artist.class)
      .setParameter("artists", artists)
      .setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false)
      .getResultList();

    assertEquals(2, artists.size());
}

✅ 两次查询分别加载不同的 Bag,避免了异常和笛卡尔积问题。

9. 总结

本文围绕 Hibernate 中常见的 MultipleBagFetchException 展开了深入分析:

  • ✅ 明确了 Bag 与 List 的区别
  • ✅ 分析了异常触发的根本原因
  • ✅ 提供了三种应对策略:
    • ❌ 不推荐:使用 Set 或 List 单次查询(有性能隐患)
    • ✅ 推荐:使用多次查询分别加载 Bag 集合

最后提醒一句:虽然 Hibernate 很强大,但在处理复杂关联时,还是要小心设计实体关系,避免不必要的 eager 加载。

📘 完整源码可参考:GitHub 示例项目


原始标题:A Guide to MultipleBagFetchException in Hibernate