1. 概述
Spring JPA 和 Hibernate 为数据库交互提供了强大的工具。但由于客户端将更多控制权委托给框架,生成的查询可能远非最优。
本教程将探讨使用 Spring JPA 和 Hibernate 时常见的 N+1 问题,并分析可能导致该问题的不同场景。
2. 社交媒体平台示例
为更直观地说明问题,我们先定义实体间的关系。以一个简单的社交网络平台为例,仅包含 用户(Users) 和 帖子(Posts):
图中使用 Iterable
接口,实际代码中会根据场景使用 List
或 Set
实现。为测试请求数量,我们将使用专用库(而非检查日志),但会引用日志来分析请求结构。
若未显式指定,关系类型的 抓取策略(fetch type) 采用默认值:所有 to-one
关系为 立即抓取(eager),to-many
关系为 延迟抓取(lazy)。代码示例使用 Lombok 减少样板代码。
3. N+1 问题
N+1 问题 指在单个请求(如获取用户列表)中,为每个用户额外发起请求获取其关联数据。⚠️ 虽然该问题常与延迟加载关联,但并非绝对。
该问题可能出现在任何关系类型中,但通常源于 多对多(many-to-many) 或 一对多(one-to-many) 关系。
3.1. 延迟抓取(Lazy Fetch)
先看延迟加载如何导致 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 等
}
用户与帖子是一对多关系。未显式指定抓取策略时,@OneToMany
默认为延迟抓取:
@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;
}
仅获取用户列表时,延迟抓取不会加载额外数据:
@Test
void givenLazyListBasedUser_WhenFetchingAllUsers_ThenIssueOneRequests() {
getUserService().findAll();
assertSelectCount(1); // 仅 1 次查询
}
但若访问帖子数据,Hibernate 会发起额外查询。单个用户场景下共 2 次查询:
@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); // 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); // N+1 次查询
}
✅ 关键点:延迟加载可减少初始数据量,但若频繁访问延迟数据,反而会增加请求数量。需根据实际访问模式权衡。
3.2. 立即抓取(Eager Fetch)
立即加载通常能缓解 N+1 问题,但效果取决于实体关系。修改用户类显式指定立即抓取:
@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;
// 构造器、getter/setter 等
}
查询单个用户时,立即抓取会在一次查询中加载所有数据:
@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerListBasedUser_WhenFetchingOneUser_ThenIssueOneRequest(Long id) {
getUserService().getUserById(id);
assertSelectCount(1); // 仅 1 次查询
}
但查询所有用户时,无论是否使用帖子数据,都会直接触发 N+1 问题:
@Test
void givenEagerListBasedUser_WhenFetchingAllUsers_ThenIssueNPlusOneRequests() {
List<User> users = getUserService().findAll();
assertSelectCount(users.size() + 1); // N+1 次查询
}
❌ 踩坑提示:立即抓取改变了数据加载方式,但在此场景下并非有效优化方案。
4. 多集合场景
引入 群组(Groups) 实体扩展领域模型:
群组包含用户列表:
@Entity
public class Group {
@Id
private Long id;
private String name;
@ManyToMany
private List<User> members;
// 构造器、getter/setter 等
}
4.1. 延迟抓取(Lazy Fetch)
行为与之前延迟加载场景类似。不直接访问用户时,仅发起 1 次查询:
@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);
}
但访问群组内每个用户时,触发 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); // N+1 次查询
}
4.2. 立即抓取(Eager Fetch)
查询单个群组时,需获取所有用户数据,查询次数合理:
@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()); // 1+N 次查询
}
但查询所有群组时,请求数量激增:
@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); // 1+N+M 次查询
}
⚠️ 技术细节:需先获取用户数据,再为每个用户获取其帖子,形成 N+M+1 问题。延迟加载和立即加载均未彻底解决问题。
4.3. 使用 Set 替代 List
尝试用 Set
替代 List
。采用立即抓取(因 Set
和 List
的延迟行为类似):
@Entity
public class Group {
@Id
private Long id;
private String name;
@ManyToMany(fetch = FetchType.EAGER)
private Set<User> members;
// 构造器、getter/setter 等
}
@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;
// 构造器、getter/setter 等
}
@Entity
public class Post {
@Id
private Long id;
@Lob
private String content;
@ManyToOne
private User author;
// 构造器、getter/setter 等
}
测试单个群组查询,N+1 问题得到解决:
@ParameterizedTest
@ValueSource(longs = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenCreateCartesianProductInOneQuery(Long groupId) {
groupService.findById(groupId);
assertSelectCount(1); // 仅 1 次查询
}
Hibernate 在一次查询中通过 JOIN 获取用户及其帖子。但查询所有群组时,请求数量虽减少,仍存在 N+1 问题:
@Test
void givenEagerSetBasedGroup_whenFetchingAllGroups_thenIssueNPlusOneRequests() {
List<Group> groups = groupService.findAll();
assertSelectCount(groups.size() + 1); // N+1 次查询
}
✅ 优化效果:部分解决问题,但引入新问题——Hibernate 使用多表 JOIN 生成 笛卡尔积:
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 不变性(如用户不应有重复帖子)。可显式指定 抓取模式(fetch mode) 替代默认行为。
5. 权衡取舍
简单场景下,选择抓取类型可减少查询次数。但仅通过注解控制查询生成的能力有限,且行为透明,领域模型的微小改动可能引发巨大影响。
最佳实践:
- 观察系统行为,识别访问模式
- 为不同场景创建专用方法、SQL 或 JPQL 查询
- 使用抓取模式向 Hibernate 提示关联实体加载方式
- 添加简单测试防止模型意外变更,确保新关系不会引发笛卡尔积或 N+1 问题
6. 总结
立即抓取虽能缓解部分额外查询问题,但可能引发其他性能问题。必须通过测试确保应用性能。
不同抓取类型与关系组合常产生意外结果,因此关键业务逻辑应通过测试覆盖。
本文所有代码示例可在 GitHub 获取。