1、概览 {#1概览}
Spring JPA 和 Hibernate 为无缝数据库通信提供了强大的工具。不过,由于客户端将更多控制权委托给了框架,因此生成的查询可能远非最佳。
本文将带你了解使用 Spring JPA 和 Hibernate 时常见的 N+1 问题,以及可能导致该问题的不同情况。
2、社交媒体平台 {#2社交媒体平台}
为了更好地将问题形象化,我们需要概述实体之间的关系。以一个简单的社交网络平台为例。这里只有用户(User
)和帖子(Post
):
我们在图表中使用了 Iterable
,并且我们将为每个示例提供具体的实现:List
或 Set
。
为了测试请求的数量,我们将使用一个专用库,而不是检查日志。不过,我们会参考日志,以便更好地了解请求的结构。
如果在每个示例中没有明确指定关系的获取类型(Fetch Type),则默认情况下假定为默认值。所有的一对一关系都使用急切加载(Eager Fetch),而一对多关系则使用延迟加载(Lazy)。此外,代码示例中使用了 Lombok 来减少代码中的冗余。
3、N+1 问题 {#3n1-问题}
N+1 问题指的是,对于单个请求(例如检索用户),会对每个用户发出额外请求,以获取其信息。虽然这个问题通常与懒加载有关,但并非总是如此。
任何类型的关系都可能出现这种问题。不过,它通常出现在多对多或一对多关系中。
3.1、延迟加载 {#31延迟加载}
首先,来看看懒加载是如何导致 N+1 问题的,示例如下:
@Entity
public class User {
@Id
private Long id;
private String username;
private String email;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "author")
protected List<Post> posts;
// 构造函数/getter/setter
}
User
与 Post
之间是一对多的关系。这意味着每个 User
都有多个 Post
。我们没有明确确定字段的 Fetch 策略。策略是从注解中推断出来的。如前所述,@OneToMany
默认采用 Lazy Fetch 策略:
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface OneToMany {
Class targetEntity() default void.class;
CascadeType[] cascade() default {};
FetchType fetch() default FetchType.LAZY;
String mappedBy() default "";
boolean orphanRemoval() default false;
}
如果尝试获取所有 User
,Lazy Fetch 只会检索需要的数据,不会检索关联数据。
@Test
void givenLazyListBasedUser_WhenFetchingAllUsers_ThenIssueOneRequests() {
getUserService().findAll();
assertSelectCount(1);
}
因此,要获取所有 User
,只需发出一个请求。
尝试访问 Post
,Hibernate 会发出一个额外请求,因为信息没有事先获取。对于单个 User
来说,这意味着总共需要两次请求:
@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenLazyListBasedUser_WhenFetchingOneUser_ThenIssueTwoRequest(Long id) {
getUserService().getUserByIdWithPredicate(id, user -> !user.getPosts().isEmpty());
assertSelectCount(2);
}
getUserByIdWithPredicate(Long, Predicate)
方法会过滤用户,但它在测试中的主要目标是触发加载。我们会有 1+1 个请求,但如果我们扩展它,就会出现 N+1 的问题:
@Test
void givenLazyListBasedUser_WhenFetchingAllUsersCheckingPosts_ThenIssueNPlusOneRequests() {
int numberOfRequests = getUserService().countNumberOfRequestsWithFunction(users -> {
List<List<Post>> usersWithPosts = users.stream()
.map(User::getPosts)
.filter(List::isEmpty)
.toList();
return users.size();
});
assertSelectCount(numberOfRequests + 1);
}
我们应该谨慎对待 Lazy Fetch。在某些情况下,Lazy Fetch 可以减少我们从数据库获取的数据。但是,如果我们在大多数情况下都要访问 Lazy Fetch 的信息,就可能会增加请求量。
3.2、急切加载 {#32急切加载}
在大多数情况下,急切加载可以帮助我们解决 N+1 问题。不过,结果取决于实体之间的关系。
考虑一个类似的 User
类,但明确设置了急切加载:
@Entity
public class User {
@Id
private Long id;
private String username;
private String email;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "author", fetch = FetchType.EAGER)
private List<Post> posts;
// 构造函数、Setter、Getter 省略
}
如果我们获取的是单个 User
,那么 Fetch Type 将迫使 Hibernate 在一次请求中加载所有数据:
@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerListBasedUser_WhenFetchingOneUser_ThenIssueOneRequest(Long id) {
getUserService().getUserById(id);
assertSelectCount(1);
}
同时,获取所有 User
的情况也发生了变化。无论是否要使用 Posts
,都会立即面临 N+1 的问题。
@Test
void givenEagerListBasedUser_WhenFetchingAllUsers_ThenIssueNPlusOneRequests() {
List<User> users = getUserService().findAll();
assertSelectCount(users.size() + 1);
}
虽然急切加载改变了 Hibernate 提取数据的方式,但很难称其为成功的优化。
4、多个集合 {#4多个集合}
在初始 Domain 中引入 Group
:
Group
包含 User
列表 List<User>
:
@Entity
public class Group {
@Id
private Long id;
private String name;
@ManyToMany
private List<User> members;
// 构造函数、Getter、Setter 略
}
4.1、延迟加载 {#41延迟加载}
这种关系的表现一般与之前使用懒加载的示例类似。每次访问懒加载的信息时,都会发起一个新请求。
因此,除非直接访问 User
,否则只会发出一次请求。
@Test
void givenLazyListBasedGroup_whenFetchingAllGroups_thenIssueOneRequest() {
groupService.findAll();
assertSelectCount( 1);
}
@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenLazyListBasedGroup_whenFetchingAllGroups_thenIssueOneRequest(Long groupId) {
Optional<Group> group = groupService.findById(groupId);
assertThat(group).isPresent();
assertSelectCount(1);
}
但是,如果试图访问 Group
中的每个 User
,就会产生 N+1 问题:
@Test
void givenLazyListBasedGroup_whenFilteringGroups_thenIssueNPlusOneRequests() {
int numberOfRequests = groupService.countNumberOfRequestsWithFunction(groups -> {
groups.stream()
.map(Group::getMembers)
.flatMap(Collection::stream)
.collect(Collectors.toSet());
return groups.size();
});
assertSelectCount(numberOfRequests + 1);
}
countNumberOfRequestsWithFunction(ToIntFunction)
方法会对请求进行计数,并触发懒加载。
4.2、急切加载 {#42急切加载}
现在来看看急切加载的行为。在请求一个 Group
时,结果如下:
@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());
}
这是合理的,因为我们需要急切地获取每个 User
的信息。同时,当我们获取所有 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);
}
我们需要获取 User
的信息,然后针对每个 User
,获取他们的 Post
。从技术上讲,我们遇到了 N+M+1 的情况。因此,无论是懒加载还是急切加载,都不能完全解决问题。
4.3、使用 Set {#43使用-set}
让我们以不同的方式处理这种情况。用集合(Set
)替代列表(List
)。我们将使用急切加载(Eager fetch),因为延迟加载的集合和列表的行为类似。
@Entity
public class Group {
@Id
private Long id;
private String name;
@ManyToMany(fetch = FetchType.EAGER)
private Set<User> members;
// 构造函数、Setter、Getter 略
}
@Entity
public class User {
@Id
private Long id;
private String username;
private String email;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "author", fetch = FetchType.EAGER)
protected Set<Post> posts;
// 构造函数、Setter、Getter 略
}
@Entity
public class Post {
@Id
private Long id;
@Lob
private String content;
@ManyToOne
private User author;
// 构造函数、Setter、Getter 略
}
进行类似的测试,看看这样做是否有什么不同:
@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenCreateCartesianProductInOneQuery(Long groupId) {
groupService.findById(groupId);
assertSelectCount(1);
}
在获取单个 Group
时,我们解决了 N+1 问题。Hibernate 在一次请求中获取了 User
和他们的 Post
。此外,在获取所有 Group
时,请求的数量减少了,但仍然存在 N+1 问题。
@Test
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenIssueNPlusOneRequests() {
List<Group> groups = groupService.findAll();
assertSelectCount(groups.size() + 1);
}
尽管我们部分地解决了问题,但却创建了另一个问题。Hibernate 使用了多个 JOIN
操作,创建了笛卡尔积(Cartesian product)。
SELECT g.id, g.name, gm.interest_group_id,
u.id, u.username, u.email,
p.id, p.author_id, p.content
FROM group g
LEFT JOIN (group_members gm JOIN user u ON u.id = gm.members_id)
ON g.id = gm.interest_group_id
LEFT JOIN post p ON u.id = p.author_id
WHERE g.id = ?
查询可能会变得过于复杂,而且由于对象之间存在许多依赖关系,会占用数据库的大量空间。
由于 Set
的性质,Hibernate 可以确保结果集中的所有重复数据都来自笛卡尔积。而 List
则无法做到这一点,因此在使用 List
时,应通过单独的请求获取数据,以保持数据的完整性。
大多数关系与 Set
的不变性相一致。允许 User
拥有多个相同的 Post
是没有多大意义的。同时,我们可以显式地提供 fetch mode,而不是依赖默认行为。
5、权衡利弊 {#5权衡利弊}
在简单情况下,选择适当的 Fetch Type 可能有助于减少请求的数量。然而,使用简单的注解,我们对查询生成的控制有限。而且,这是透明进行的,Domain 模型的小改动可能会带来巨大的影响。
解决问题的最佳方法是观察系统行为并识别访问模式。创建单独的方法、SQL 和 JPQL 查询有助于针对每种情况进行调整。此外,还可以使用 Fetch Mode 来提示 Hibernate 如何加载相关实体。
添加简单测试有助于解决模型中的意外变化。这样,就能确保新的关系不会产生笛卡尔积或 N+1 问题。
6、总结 {#6总结}
虽然 Eager Fetch Type 可以通过额外查询缓解一些简单的问题,但它可能会导致其他问题。有必要对应用进行测试,以确保其性能。
不同的 Fetch Type 和关系组合往往会产生意想不到的结果。因此,最好通过测试来覆盖关键部分。
Ref:https://www.baeldung.com/spring-hibernate-n1-problem