1. 概述

Spring JPA和Hibernate为数据库交互提供了强大的工具。但当我们将更多控制权委托给框架(包括查询生成)时,结果可能与预期大相径庭。

在处理一对多关系时,选择List还是Set常常令人困惑。这种困惑被Hibernate的术语放大了——它使用相似的名称(bags、lists、sets)但含义略有不同。

大多数情况下,Set更适合一对多或多对多关系。但它们有特定的性能影响需要我们注意。

本教程将学习实体关系中List与Set的区别,通过不同复杂度的示例进行对比,并分析每种方法的优缺点。

2. 测试方案

我们将使用专用库测试请求数量。检查日志不是好方案——它不自动化且仅适用于简单示例。当请求生成数十上百个查询时,日志效率太低。

首先添加依赖(注意artifact ID中的数字对应Hibernate版本):

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

同时添加日志分析工具:

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

用这些库进行探索性测试,覆盖应用关键部分。确保实体类的修改不会在查询生成中产生隐形副作用

需用提供的工具包装数据源。使用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. 领域模型

为使示例更相关,我们使用社交网站模型:包含群组、用户、帖子和评论的不同关系。逐步增加复杂度,突出差异和性能影响。

✅ 关键点:

  • 简单模型无法完整展现问题
  • 过度复杂模型会淹没信息
  • 仅对多关系使用立即加载(eager fetch)
  • List和Set在延迟加载时行为相似

示意图中用Iterable表示多关系字段(仅简洁起见),代码中会明确定义类型。

4. 用户与帖子

先考虑简单部分:用户和帖子的双向关系。用户可有多篇帖子,每篇帖子只有一个作者。

4.1. List与Set的JOIN查询

获取单个用户时,Hibernate对List和Set都生成单条LEFT JOIN查询:

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

Set版本类似:

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

查询结果相同

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 = ?

⚠️ 注意:用户数据会为每行重复——ID、邮箱、用户名出现次数等于该用户的帖子数:

u.id u.email u.username p.id p.author_id p.content
101 user101@example.com user101 1 101 "User101 post 1"
101 user101@example.com user101 2 101 "User101 post 2"
102 user102@example.com user102 3 102 "User102 post 1"

当用户表列数多或帖子量大时,可能引发性能问题。可通过显式指定fetch mode解决。

4.2. List与Set的N+1问题

获取多个用户时,两者都遭遇臭名昭著的N+1问题

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

Set版本测试相同:

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

生成两类查询:

  1. 获取所有用户:
    SELECT u.id, u.email, u.username
    FROM simple_user u
    
  2. 为每个用户获取帖子(N次):
    SELECT p.id, p.author_id, p.content
    FROM post p
    WHERE p.author_id = ?
    

结论:此类关系中List与Set无差异。

5. 群组、用户与帖子

增加复杂度:添加群组与用户的单向多对多关系。

5.1. List与N+1问题

定义Group类:

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

获取所有群组时:

@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生成三类查询:

-- 获取所有群组
SELECT g.id, g.name FROM interest_group g

-- 为每个群组获取成员(N次)
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 = ?

-- 为每个成员获取帖子(M次)
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 = ?

-- 为每个成员获取帖子(N次)
SELECT p.author_id, p.id, p.content
FROM post p
WHERE p.author_id = ?

总请求数:N + 1(N=群组成员数)

5.2. Set与笛卡尔积

Set版本表现不同:

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

获取所有群组时:

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

请求数减少为N+1,但查询更复杂

-- 获取所有群组
SELECT g.id, g.name FROM interest_group g

-- 通过双JOIN获取用户及帖子(N次)
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产生笛卡尔积,包含重复数据:

  • 群组信息为每个用户重复
  • 所有数据为每个用户的帖子重复
u.id u.username u.email p.id p.author_id p.content gm.interest_group_id
301 user301 user301@example.com 201 301 "User301's post 1" 101
302 user302 user302@example.com 202 302 "User302's post 1" 101
303 user303 user303@example.com NULL NULL NULL 101

获取单个群组时只需一次请求:

@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenCreateCartesianProductInOneQuery(Long groupId) {
    groupService.findById(groupId);
    assertSelectCount(1);
}
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的删除行为

@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);
        }
    });
}

生成两条合理SQL:

-- 获取数据(因测试无事务)
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 = ?

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);
        }
    });
}

生成低效SQL序列:

-- 获取群组及成员
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 = ?

-- 获取成员的帖子(N次)
SELECT p.author_id, p.id, p.content FROM post p WHERE p.author_id = ?

-- 删除所有关联
DELETE FROM interest_group_members WHERE interest_group_id = ? 
    
-- 重新插入剩余成员(N-1次)
INSERT INTO interest_group_members (interest_group_id, members_id) VALUES (?, ?)

总请求数约1 + 2N(N=成员数)

❌ 根本原因:List允许重复元素,Hibernate无法区分笛卡尔积重复与集合重复。

结论:@ManyToMany关系必须使用Set,否则会遭遇严重性能问题。

6. 完整领域模型

考虑更真实的互联模型:

包含多种一对多、双向多对多及传递循环关系。

6.1. List版本

所有多关系使用List。获取所有用户:

@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;
      })
    );
}

生成大量查询:

  1. 获取所有用户ID
  2. 为每个用户获取群组和帖子
  3. 获取每篇帖子的详细信息

优点:避免多表JOIN的笛卡尔积,返回数据无重复
缺点:请求数量爆炸

获取单个用户时更糟:

@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);
}

DFS计数显示:获取ID为2的用户需42次数据库请求
⚠️ 虽主因是立即加载,但List加剧了请求爆炸。

6.2. Set版本

将所有List改为Set:

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

请求数减少(N+1),但单条查询极其复杂:

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 = ?

每个用户一条巨型查询,结果集因笛卡尔积包含大量重复数据。

7. 优缺点对比

本教程使用立即加载突出默认行为差异。虽然立即加载可能提升性能,但需谨慎使用。

Set的优势

  1. 更符合领域模型(群组中不能有重复用户)
  2. 更灵活(可显式控制fetch mode)
  3. @ManyToMany删除操作高效
  4. 避免List的全删重建问题

Set的劣势

  1. 复杂关系易产生笛卡尔积
  2. 巨型结果集可能消耗更多网络带宽

⚠️ 关键建议

  • 用测试覆盖关键数据库交互
  • 监控查询生成,防止隐形性能问题
  • 小数据集可能暴露不出问题

8. 结论

大多数情况下应优先使用Set处理多关系,它提供更可控的关系并避免删除开销。

但所有优化都需经过性能分析和测试验证——问题可能在小数据集或简单关系中隐藏。

本文所有代码可在GitHub获取。


原始标题:List vs. Set in @OneToMany JPA | Baeldung