JPA @OneToMany 关系中的 List 与 Set

1、概览

Spring JPAHibernate 为不同据库通信提供了强大的工具。然而,随着开发者将更多的控制权(包括查询生成)委托给框架,结果可能与我们的预期相去甚远。

开发者通常会对在多对多关系中使用列表(List)还是集合(Set)产生困惑。而且,Hibernate 对其 bag、list 和 set 使用了类似的名称,但它们之间有稍微不同的含义,这进一步增加了混淆的可能性。

在大多数情况下,Set 更适用于一对多或多对多关系。不过,它们对性能有特殊的影响,需要注意。

本文将带你了解 JPA 实体关系中 ListSet 的区别,以及各自的优缺点。

2、测试

这里使用专门的测试库来测试请求数。检查日志不是一个好的解决方案,因为它不是自动化的,可能只适用于简单的示例。当请求产生数十或数百个查询时,使用日志是不够高效的。

首先,需要 io.hypersistence。注意,artifact ID 中的数字是 Hibernate 版本:

<dependency>
    <groupId>io.hypersistence</groupId>
    <artifactId>hypersistence-utils-hibernate-63</artifactId>
    <version>3.7.0</version>
</dependency>

此外,还使用 util 库进行日志分析:

<dependency>
    <groupId>com.vladmihalcea</groupId>
    <artifactId>db-util</artifactId>
    <version>1.0.7</version>
</dependency>

我们应该使用所提供的 Util封装数据源,使其正常工作。这可以通过 BeanPostProcessor 来做实现:

@Component
public class DataSourceWrapper implements BeanPostProcessor {

    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        return bean;
    }

    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof DataSource originalDataSource) {
            ChainListener listener = new ChainListener();
            SLF4JQueryLoggingListener loggingListener = new SLF4JQueryLoggingListener();
            loggingListener.setQueryLogEntryCreator(new InlineQueryLogEntryCreator());
            listener.addListener(loggingListener);
            listener.addListener(new DataSourceQueryCountListener());
            return ProxyDataSourceBuilder
              .create(originalDataSource)
              .name("datasource-proxy")
              .listener(listener)
              .build();
        }
        return bean;
    }
}

剩下的就很简单了。在测试中,使用 SQLStatementCountValidator 来验证查询的次数和类型。

3、Domain

为了使示例更贴切、更容易理解,本文使用一个社交网站的模型。在 Group(群组)、User(用户)、Post(帖子)和 Comment(评论)之间建立不同的关联关系。

不过,我们会逐步提高复杂性,增加实体以突出差异和性能效果。这一点很重要,因为只有几种关系的简单模型无法提供完整的信息。与此同时,过于复杂的模型可能会让信息过于繁杂,难以理解。

在这些示例中,我们只对 to-many 关系使用 eager fetch 类型。一般来说,当我们使用 lazy fetch(懒加载)时,ListSet 的行为类似。

我们将使用 Iterable 作为 to-many 字段类型。这样做只是为了简洁起见,不需要重复定义 ListSet

4、User 和 Post

首先,只考虑 Domain 的一部分。这里,只考虑 User 和 Post:

User 和 Post 关系模型

用户和帖子之间是简单的双向关系。用户可以拥有许多帖子。同时,一个帖子只能有一个用户作为作者(author)。

4.1、List 和 Set Join

首先来看一下只请求一个 User 时的查询行为。我们将考虑 SetList 的以下两种情况:

@Data
@Entity
public class User {
    // 其他字段
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "author", fetch = FetchType.EAGER)
    protected List<Post> posts;
}

基于 SetUser 基本类似:

@Data
@Entity
public class User {
    // 其他字段
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "author", fetch = FetchType.EAGER)
    protected Set<Post> posts;
}

在检索用户信息时,Hibernate 会使用 LEFT JOIN 生成一个查询,以便一次性获取所有信息。两种情况都是如此:

SELECT u.id, u.email, u.username, p.id, p.author_id, p.content
FROM simple_user u
         LEFT JOIN post p ON u.id = p.author_id
WHERE u.id = ?

虽然只有一个查询,但每一行的用户数据都会重复。这意味着,某个用户发表了多少篇文章,我们就会看到多少次 idemailusername

u.id u.email u.username p.id p.author_id p.content
101 user101@email.com user101 1 101 “User101 post 1”
101 user101@email.com user101 2 101 “User101 post 2”
102 user102@email.com user102 3 102 “User102 post 1”
102 user102@email.com user102 4 102 “User102 post 2”
103 user103@email.com user103 5 103 “User103 post 1”
103 user103@email.com user103 6 103 “User103 post 2”

如果用户表有很多列或帖子,这可能会造成性能问题。我们可以通过明确指定 fetch mode 来解决这个问题。

4.2、List 和 Sets 的 N+1 问题

在获取多个用户时,会遇到一个臭名昭著的 N+1 问题(执行一次查询会触发 N 次额外查询)。对于基于 ListUser,情况也是如此:

@Test
void givenEagerListBasedUser_WhenFetchingAllUsers_ThenIssueNPlusOneRequests() {
    List<User> users = getService().findAll();
    assertSelectCount(users.size() + 1);
}

对于基于 SetUser 也是如此:

@Testvoid givenEagerSetBasedUser_WhenFetchingAllUsers_ThenIssueNPlusOneRequests() {
    List<User> users = getService().findAll();
    assertSelectCount(users.size() + 1);
}

只有两种查询,第一种是获取所有用户:

SELECT u.id, u.email, u.username
FROM simple_user u

以及获取每个用户帖子的 N 次后续查询:

SELECT p.id, p.author_id, p.content
FROM post p
WHERE p.author_id = ?

因此,对于这些类型的关系,在 ListSet 之间没有任何区别。

5、Group、User 和 Post

考虑一下更复杂的关系,将 Group 添加到模型中。它与 User 建立了单向的多对多关系:

Group、User 和 Post 实体关系模型

由于 UserPost 之间的关系保持不变,因此旧测试将有效并产生相同的结果。

Group 创建类似的测试。

5、List 和 N+1 的问题

具有 @ManyToMany 关系的 Group 类如下:

@Data
@Entity
public class Group {
    @Id
    private Long id;
    private String name;
    @ManyToMany(fetch = FetchType.EAGER)
    private List<User> members;
}

尝试检索所有 Group:

@Test
void givenEagerListBasedGroup_whenFetchingAllGroups_thenIssueNPlusMPlusOneRequests() {
    List<Group> groups = groupService.findAll();
    Set<User> users = groups.stream().map(Group::getMembers).flatMap(List::stream).collect(Collectors.toSet());
    assertSelectCount(groups.size() + users.size() + 1);
}

Hibernate 会为每个 Group 发出额外的查询,以获取成员,并为每个成员发出额外的查询,以获取他们的帖子。因此,这会有三种类型的查询:

SELECT g.id, g.name
FROM interest_group g

SELECT gm.interest_group_id, u.id, u.email, u.username
FROM interest_group_members gm
         JOIN simple_user u ON u.id = gm.members_id
WHERE gm.interest_group_id = ?

SELECT p.author_id, p.id, p.content
FROM post p
WHERE p.author_id = ?

总的来说,这会得到 1 + N + M 的查询次数。N 是组的数量,M 是这些组中唯一用户的数量。

尝试检索一个组:

@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerListBasedGroup_whenFetchingAllGroups_thenIssueNPlusOneRequests(Long groupId) {
    Optional<Group> group = groupService.findById(groupId);
    assertThat(group).isPresent();
    assertSelectCount(1 + group.get().getMembers().size());
}

情况类似,但这次使用 LEFT JOIN 在单个查询中获取所有用户数据。这样,就只有两种类型的查询了:

SELECT g.id, gm.interest_group_id, u.id, u.email, u.username, g.name
FROM interest_group g
         LEFT JOIN (interest_group_members gm JOIN simple_user u ON u.id = gm.members_id)
                   ON g.id = gm.interest_group_id
WHERE g.id = ?

SELECT p.author_id, p.id, p.content
FROM post p
WHERE p.author_id = ?

总的来说,会有 N + 1 个查询,其中 N 是组中成员的数量。

5.2、Set 和笛卡尔积

在使用集合(Set)时,情况有所不同。基于 SetGroup 类如下:

@Data
@Entity
public class Group {
    @Id
    private Long id;
    private String name;
    @ManyToMany(fetch = FetchType.EAGER)
    private Set<User> members;
}

获取所有 Group 的结果与基于 ListGroup 略有不同:

@Test
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenIssueNPlusOneRequests() {
    List<Group> groups = groupService.findAll();
    assertSelectCount(groups.size() + 1);
}

和上一个例子中的 N + M + 1 不通过,这里只有 N + 1,但会得到更复杂的查询。这里仍使用单独的查询来获取所有 Group,但 Hibernate 会使用两个 JOIN 在单个查询中获取用户及其帖子:

SELECT g.id, g.name
FROM interest_group g

SELECT u.id,
       u.username,
       u.email,
       p.id,
       p.author_id,
       p.content,
       gm.interest_group_id,
FROM interest_group_members gm
         JOIN simple_user u ON u.id = gm.members_id
         LEFT JOIN post p ON u.id = p.author_id
WHERE gm.interest_group_id = ?

虽然减少了查询次数,但由于 JOIN 以及随后的笛卡尔积,结果集可能包含重复数据。这会重复获得 Group 中所有 User 的 Group 信息,而所有这些信息都将在每个用户的帖子中重复出现:

u.id u.username u.email p.id p.author_id p.content gm.interest_group_id
301 user301 user301@email.com 201 301 “User301’s post 1” 101
302 user302 user302@email.com 202 302 “User302’s post 1” 101
303 user303 user303@email.com NULL NULL NULL 101
304 user304 user304@email.com 203 304 “User304’s post 1” 102
305 user305 user305@email.com 204 305 “User305’s post 1” 102
306 user306 user306@email.com NULL NULL NULL 102
307 user307 user307@email.com 205 307 “User307’s post 1” 103
308 user308 user308@email.com 206 308 “User308’s post 1” 103
309 user309 user309@email.com NULL NULL NULL 103

在查看了前面的查询后,就知道为什么获取单个 Group 会发出单个请求了:

@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenCreateCartesianProductInOneQuery(Long groupId) {
    groupService.findById(groupId);
    assertSelectCount(1);
}

只使用带 JOIN 的第二个查询,从而减少请求次数:

SELECT u.id,
       u.username,
       u.email,
       p.id,
       p.author_id,
       p.content,
       gm.interest_group_id,
FROM interest_group_members gm
         JOIN simple_user u ON u.id = gm.members_id
         LEFT JOIN post p ON u.id = p.author_id
WHERE gm.interest_group_id = ?

5.3、使用 List 和 Set 进行删除

ListSet 之间另一个有趣的区别是它们如何移除对象。这只适用于 @ManyToMany 关系。首先考虑一个更简单的 Set 案例:

@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerListBasedGroup_whenRemoveUser_thenIssueOnlyOneDelete(Long groupId) {
    groupService.findById(groupId).ifPresent(group -> {
        Set<User> members = group.getMembers();
        if (!members.isEmpty()) {
            reset();
            Set<User> newMembers = members.stream().skip(1).collect(Collectors.toSet());
            group.setMembers(newMembers);
            groupService.save(group);
            assertSelectCount(1);
            assertDeleteCount(1);
        }
    });
}

这种行为非常合理,只需从连接表中删除记录即可。

在日志中可以看到两个查询:

SELECT g.id, g.name,
       u.id, u.username, u.email,
       p.id, p.author_id, p.content,
       m.interest_group_id,
FROM interest_group g
         LEFT JOIN (interest_group_members m JOIN simple_user u ON u.id = m.members_id)
                   ON g.id = m.interest_group_id
         LEFT JOIN post p ON u.id = p.author_id

DELETE
FROM interest_group_members
WHERE interest_group_id = ? AND members_id = ?

之所以有额外的选择,只是因为测试方法不是事务性的,而且原始 Group 没有存储在持久化上下文中(Persistence Context)。

总的来说,Set 的行为与我们想象的一样。现在,现在来看看 List

@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerListBasedGroup_whenRemoveUser_thenIssueRecreateGroup(Long groupId) {
    groupService.findById(groupId).ifPresent(group -> {
        List<User> members = group.getMembers();
        int originalNumberOfMembers = members.size();
        assertSelectCount(ONE + originalNumberOfMembers);
        if (!members.isEmpty()) {
            reset();
            members.remove(0);
            groupService.save(group);
            assertSelectCount(ONE + originalNumberOfMembers);
            assertDeleteCount(ONE);
            assertInsertCount(originalNumberOfMembers - ONE);
        }
    });
}

这里有几个查询:SELECTDELETEINSERT。问题是,Hibernate 会从连接表中删除整个 Group,然后重新创建。同样,由于测试方法中缺乏持久化上下文,我们再次看到了最初的 SELECT 语句。

SELECT u.id, u.email, u.username, g.name,
       g.id, gm.interest_group_id,
FROM interest_group g
         LEFT JOIN (interest_group_members gm JOIN simple_user u ON u.id = gm.members_id)
                   ON g.id = gm.interest_group_id
WHERE g.id = ?

SELECT p.author_id, p.id, p.content
FROM post p
WHERE p.author_id = ?

DELETE
FROM interest_group_members
WHERE interest_group_id = ? 
    
INSERT
INTO interest_group_members (interest_group_id, members_id)
VALUES (?, ?)

代码将产生一个查询来获取所有 Group 成员。N 次请求获取帖子,其中 N 为成员数。一次请求删除整个 Group,N - 1 次请求再次添加成员。一般来说,我们可以把它理解为 1 + 2N

List 不产生笛卡尔产品并不是因为性能方面的考虑。由于 List 允许重复元素,因此 Hibernate 在区分笛卡尔重复元素和集合中的重复元素时会遇到问题。

因此,建议只使用带有 @ManyToMany 注解的 Set。否则,要做好性能受到严重影响的准备。

6、完整的 Domain

现在,来看看一个实际中的、更加完整的 Domain,它有许多不同的关系:

完整的实体关系模型

现在,有了一个相互关联的 Domain Model。其中有若干一对多关系、双向多对多关系以及传递性的循环关系。

6.1、Lists

首先,考虑使用 List 来处理所有对多关系(to-many)的关系。

尝试从数据库中获取所有用户:

@ParameterizedTest
@MethodSource
void givenEagerListBasedUser_WhenFetchingAllUsers_ThenIssueNPlusOneRequests(ToIntFunction<List<User>> function) {
    int numberOfRequests = getService().countNumberOfRequestsWithFunction(function);
    assertSelectCount(numberOfRequests);
}

static Stream<Arguments> givenEagerListBasedUser_WhenFetchingAllUsers_ThenIssueNPlusOneRequests() {
    return Stream.of(
      Arguments.of((ToIntFunction<List<User>>) s -> {
          int result = 2 * s.size() + 1;
          List<Post> posts = s.stream().map(User::getPosts)
            .flatMap(List::stream)
            .toList();

          result += posts.size();
          return result;
      })
    );
}

这个请求会产生许多不同的查询。首先,获取所有用户的 ID。然后,分别请求每个用户的所有 Group 和 Post。最后,获取每个 Post 的信息。

总的来说,这会发出大量查询,但同时不会在多对多(to-many)关系之间进行任何连接。这样,就避免了笛卡尔积,返回的数据量也会减少,因为不会有重复的数据,但会使用更多的请求。

在获取单个用户时,会遇到一种有意思的情况:

@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerListBasedUser_WhenFetchingOneUser_ThenUseDFS(Long id) {
    int numberOfRequests = getService()
      .getUserByIdWithFunction(id, this::countNumberOfRequests);
    assertSelectCount(numberOfRequests);
}

countNumberOfRequests 方法是一个 util 方法,它使用 DFS 来计算实体数量和请求数量:

Get all the posts for user #2
The user wrote the following posts: 1,2,3
 Check all the commenters for post #1: 3,8,9,10
  Get all the posts for user #10: 22
   Check all the commenters for post #22: 3,6,7,10
    Get all the posts for user #3: 4,5,6
     Check all the commenters for post #4: 2,4,9
      Get all the posts for user #9: 19,20,21
       Check all the commenters for post #19: 3,4,8,9,10
        Get all the posts for user #8: 16,17,18
         Check all the commenters for post #16: 
         Check all the commenters for post #17: 2,4,9
          Get all the posts for user #4: 7,8,9,10
           Check all the commenters for post #7: 
           Check all the commenters for post #8: 
           Check all the commenters for post #9: 1,5,6
            Get all the posts for user #1: 
            Get all the posts for user #5: 11,12,13,14
             Check all the commenters for post #11: 2,3,8
             Check all the commenters for post #12: 10
             Check all the commenters for post #13: 4,9,10
             Check all the commenters for post #14: 
            Get all the posts for user #6: 
           Check all the commenters for post #10: 2,5,6,8
         Check all the commenters for post #18: 1,2,3,4,5
       Check all the commenters for post #20: 
       Check all the commenters for post #21: 7
        Get all the posts for user #7: 15
         Check all the commenters for post #15: 1
     Check all the commenters for post #5: 1,2,5,8
     Check all the commenters for post #6: 
 Check all the commenters for post #2: 
 Check all the commenters for post #3: 1,3,6

结果是一个传递闭包。对于 ID#2 的单个用户,必须向数据库发出 42(!)次请求。虽然主要问题在于 Eager fetch 类型,但如果我们使用 List,请求数量会爆炸式增长。

当触发大部分内部字段的加载时,Lazy fetch 可能会产生类似的问题。这可能是基于 Domain 逻辑有意为之。也可能是偶然的,例如,对 toString()equals(T)hashCode() 方法的不正确重载。

6.2、Sets

让我们把 Domain model 中的所有 List 都改为 Set,并进行类似的测试:

@Test
void givenEagerSetBasedUser_WhenFetchingAllUsers_ThenIssueNPlusOneRequestsWithCartesianProduct() {
    List<User> users = getService().findAll();
    assertSelectCount(users.size() + 1);
}

首先,这会减少获取所有 User 的请求,整体上应该会更好。但是,如果我们查看一下请求,就会发现以下情况:

SELECT profile.id, profile.biography, profile.website, profile.profile_picture_url,
       user.id, user.email, user.username,
       user_group.members_id,
       interest_group.id, interest_group.name,
       post.id, post.author_id, post.content,
       comment.id, comment.text, comment.post_id,
       comment_author.id, comment_author.profile_id, comment_author.username, comment_author.email,
       comment_author_group_member.members_id,
       comment_author_group.id, comment_author_group.name
FROM profile profile
         LEFT JOIN simple_user user
ON profile.id = user.profile_id
    LEFT JOIN (interest_group_members user_group
    JOIN interest_group interest_group
    ON interest_group.id = user_group.groups_id)
    ON user.id = user_group.members_id
    LEFT JOIN post post ON user.id = post.author_id
    LEFT JOIN comment comment ON post.id = comment.post_id
    LEFT JOIN simple_user comment_author ON comment_author.id = comment.author_id
    LEFT JOIN (interest_group_members comment_author_group_member
    JOIN interest_group comment_author_group
    ON comment_author_group.id = comment_author_group_member.groups_id)
    ON comment_author.id = comment_author_group_member.members_id
WHERE profile.id = ?

这种查询会从数据库中检索大量数据,对每个用户(User)都有一个这样的查询。另外,由于笛卡尔积的原因,结果集将包含重复数据。获取单个用户的结果与此类似,请求较少,但结果集庞大。

7、利弊

本教程中使用了 eager fetch,以突出 ListSet 默认行为的不同。虽然 eager fetch 数据可能会提高性能并简化与数据库的交互,但应谨慎使用。

虽然 eager fetch 通常被认为能解决 N+1 问题,但情况并非总是如此。其行为取决于多种因素和 Domain 实体间关系的整体结构。

由于以下几个原因,使用 Set 时最好不要使用过多的关系。首先,在大多数情况下,不允许重复的 Set 能完美地反映 Domain 模型。一个 Group 中不能有两个相同的 User,一个 User 也不能有两个相同的 Post

另外,Set 也更加灵活。虽然 Set 的默认获取模式是创建 join,但我们可以通过使用 fetch mode 来明确定义它。

使用 List 删除多对多(many-to-many)关系的行为会产生开销。在小数据集上很难察觉到这种差异,但如果数据量很大,就会出现高延迟。

为了避免这些问题,最好用测试来覆盖与数据库交互的关键部分。这将确保 Domain 模型中某个部分的一些看似无关紧要的变化不会在生成的查询中带来巨大的开销。

8、总结

在大多数情况下,应该使用 Set 来处理 to-many 关系。这提供了模式可控的关系,并避免了删除时的开销。

不过,所有关于改进 Domain model 的更改和想法都应进行剖析和测试,这些问题可能不会在小型数据集和简单实体关系中暴露出来。


Ref:https://www.baeldung.com/spring-jpa-onetomany-list-vs-set