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);
}
生成两类查询:
- 获取所有用户:
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 = ?
结论:此类关系中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;
})
);
}
生成大量查询:
- 获取所有用户ID
- 为每个用户获取群组和帖子
- 获取每篇帖子的详细信息
优点:避免多表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的优势:
- 更符合领域模型(群组中不能有重复用户)
- 更灵活(可显式控制fetch mode)
- @ManyToMany删除操作高效
- 避免List的全删重建问题
❌ Set的劣势:
- 复杂关系易产生笛卡尔积
- 巨型结果集可能消耗更多网络带宽
⚠️ 关键建议:
- 用测试覆盖关键数据库交互
- 监控查询生成,防止隐形性能问题
- 小数据集可能暴露不出问题
8. 结论
大多数情况下应优先使用Set处理多关系,它提供更可控的关系并避免删除开销。
但所有优化都需经过性能分析和测试验证——问题可能在小数据集或简单关系中隐藏。
本文所有代码可在GitHub获取。