1. 概述

Spring JPA 和 Hibernate 为数据库交互提供了强大的工具。但由于客户端将更多控制权委托给框架,生成的查询可能远非最优。

本教程将探讨使用 Spring JPA 和 Hibernate 时常见的 N+1 问题,并分析可能导致该问题的不同场景。

2. 社交媒体平台示例

为更直观地说明问题,我们先定义实体间的关系。以一个简单的社交网络平台为例,仅包含 用户(Users)帖子(Posts)

用户与帖子关系图

图中使用 Iterable 接口,实际代码中会根据场景使用 ListSet 实现。为测试请求数量,我们将使用专用库(而非检查日志),但会引用日志来分析请求结构。

若未显式指定,关系类型的 抓取策略(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。采用立即抓取(因 SetList 的延迟行为类似):

@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. 权衡取舍

简单场景下,选择抓取类型可减少查询次数。但仅通过注解控制查询生成的能力有限,且行为透明,领域模型的微小改动可能引发巨大影响。

最佳实践

  1. 观察系统行为,识别访问模式
  2. 为不同场景创建专用方法、SQL 或 JPQL 查询
  3. 使用抓取模式向 Hibernate 提示关联实体加载方式
  4. 添加简单测试防止模型意外变更,确保新关系不会引发笛卡尔积或 N+1 问题

6. 总结

立即抓取虽能缓解部分额外查询问题,但可能引发其他性能问题。必须通过测试确保应用性能。

不同抓取类型与关系组合常产生意外结果,因此关键业务逻辑应通过测试覆盖。

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


原始标题:N+1 Problem in Hibernate and Spring Data JPA | Baeldung