Spring JPA中@OneToMany关系中的List与Set对比 | Baeldung
Spring JPA中@OneToMany关系中的List与Set对比 | Baeldung
Spring JPA和Hibernate为与数据库的无缝通信提供了强大的工具。然而,随着客户端将更多的控制权委托给框架,包括查询生成,结果可能远非我们所期望的。
通常在to-many关系中使用_Lists_还是_Sets_存在混淆。这种混淆通常因为Hibernate为其bags、lists和sets使用相似的名称,但背后的含义略有不同而被放大。
在大多数情况下,_Sets_更适合_one-to-many_或_many-to-many_关系。然而,它们有特定的性能影响,我们应该意识到这一点。
在本教程中,我们将学习在实体关系上下文中_Lists_和_Sets_的区别,并回顾几个不同复杂度的示例。我们还将确定每种方法的优缺点。
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>``
我们可以使用这些库进行探索性测试,覆盖我们应用程序的关键部分。这样,我们确保实体类的更改不会在查询生成中产生一些看不见的副作用。
我们应该用提供的实用工具包装我们的DataSource以使其工作。我们可以使用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. 领域模型
为了使示例更加相关和易于理解,我们将使用一个社交网络网站的模型。我们将有不同组、用户、帖子和评论之间的关系。
然而,我们将逐步增加复杂性,添加实体以突出差异和性能影响。这很重要,因为只有少数关系的简单模型无法提供完整的画面。 同时,过于复杂的模型可能会压倒性地提供信息,使其难以跟随。
对于这些示例,我们将只使用to-many关系的_eager fetch type_。通常,当我们使用lazy fetch时,_Lists_和_Sets_表现相似。
在视觉上,我们将使用_Iterable_作为to-many字段类型。这只是为了简洁,所以我们不需要为_Lists_和_Sets_重复相同的视觉。我们将在每个部分明确定义专用类型,并在代码中显示。
4. 用户和帖子
首先,让我们只考虑我们领域的这一部分。在这里,我们将只考虑用户和帖子:
4.1. _Lists_和_Sets_连接
让我们来检查一下当我们只请求一个用户时查询的行为。我们将考虑_Set_和_List_的以下两种场景:
@Data
@Entity
public class User {
// 其他字段
@OneToMany(cascade = CascadeType.ALL, mappedBy = "author", fetch = FetchType.EAGER)
protected List``<Post>`` posts;
}
基于_Set_的_User_看起来非常相似:
@Data
@Entity
public class User {
// 其他字段
@OneToMany(cascade = CascadeType.ALL, mappedBy = "author", fetch = FetchType.EAGER)
protected Set``<Post>`` posts;
}
在获取_User_时,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 = ?
虽然我们只有一个查询,但用户的数据将为每一行重复。这意味着我们将看到ID、电子邮件和用户名多次出现,就像特定用户有多少帖子一样:
| 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” |
如果用户表有很多列或帖子,这可能会造成性能问题。我们可以通过显式指定获取模式来解决这个问题。
4.2. Lists_和_Sets N+1
同时,在获取多个用户时,我们遇到了一个臭名昭著的N+1问题。这对于基于_List_的_Users_是真实的:
@Test
void givenEagerListBasedUser_WhenFetchingAllUsers_ThenIssueNPlusOneRequests() {
List````````<User>```````` users = getService().findAll();
assertSelectCount(users.size() + 1);
}
对于基于_Set_的_Users_也是如此:
@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个后续请求,为每个_User_获取帖子:
SELECT p.id, p.author_id, p.content
FROM post p
WHERE p.author_id = ?
因此,对于这些类型的关系,_Lists_和_Sets_之间没有任何差异。
5. 组、用户和帖子
让我们考虑更复杂的关系,并将组添加到我们的模型中。它们与用户创建单向_many-to-many_关系:
5.1. Lists_和_N+1
我们将有以下带有@_ManyToMany_关系的_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
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 =service.findById(groupId);
assertThat(group).isPresent();
assertSelectCount(1 + group.get().getMembers().size());
}
我们将有类似的情况,但我们将使用_LEFT JOIN_在单个查询中获取所有_User_数据。因此,将只有两种类型的查询:
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. _Sets_和笛卡尔积
使用_Sets_时,我们将看到不同的情况。让我们检查一下基于_Set_的_Group_类:
@Data
@Entity
public class Group {
@Id
private Long id;
private String name;
@ManyToMany(fetch = FetchType.EAGER)
private Set````````<User>```````` members;
}
获取所有组将产生与基于_List_的组略有不同的结果:
@Test
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenIssueNPlusOneRequests() {
List```<Group>``` groups = groupService.findAll();
assertSelectCount(groups.size() + 1);
}
与前一个例子中的_N + M +_ 1不同。我们将只有_N_ + 1,但会得到更复杂的查询。 我们仍然有一个单独的查询来获取所有组,但是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 = ?
尽管我们减少了查询数量,但由于JOINs,结果集可能包含重复数据,随后是笛卡尔积。我们将为组中的所有用户获取重复的组信息,所有这些将为每个用户帖子重复:
| 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 |
回顾前面的查询,很明显为什么获取一个单一的组会发出一个单一的请求:
@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenCreateCartesianProductInOneQuery(Long groupId) {
groupService.findById(groupId);
assertSelectCount(1);
}
我们将只使用第二个带有JOINs的查询,减少了请求数量:
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. 使用_Lists_和_Sets_进行删除
_Sets_和_Lists_之间的另一个有趣差异是它们如何删除对象。这只适用于@_ManyToMany_关系。 让我们首先考虑_Sets_的更简单情况:
@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 = ?
我们有一个额外的选择,只是因为测试方法不是事务性的,原始组没有存储在我们的持久化上下文中。
总的来说,_Sets_的行为正如我们所假设的。现在,让我们检查_Lists_的行为:
@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);
}
});
}
在这里,我们有几个查询:SELECT、DELETE和INSERT。问题是Hibernate从连接表中删除整个组,并重新创建它。 再次,由于测试方法中没有持久化上下文,我们有初始选择语句:
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 (?, ?)
代码将产生一个查询以获取所有组成员。_N_个请求获取成员的帖子,其中_N_是成员的数量。一个请求删除整个组,以及N - 1个请求再次添加成员。总的来说,我们可以认为它是1 + 2N。
_Lists_不会产生笛卡尔积,不是因为性能考虑。由于_Lists_允许重复元素,Hibernate在区分笛卡尔重复项和集合中的重复项时存在问题。
这就是为什么建议仅在@ManyToMany_注解中使用_Sets。 否则,我们应该为剧烈的性能影响做好准备。
6. 完整领域模型
现在,让我们考虑一个更现实的领域,其中包含许多不同的关系:
6.1. Lists
首先,让我们考虑所有to-many关系中使用_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