Hibernate 和 Spring Data JPA 中的 N+1 问题

1、概览

Spring JPA 和 Hibernate 为无缝数据库通信提供了强大的工具。不过,由于客户端将更多控制权委托给了框架,因此生成的查询可能远非最佳。

本文将带你了解使用 Spring JPA 和 Hibernate 时常见的 N+1 问题,以及可能导致该问题的不同情况。

2、社交媒体平台

为了更好地将问题形象化,我们需要概述实体之间的关系。以一个简单的社交网络平台为例。这里只有用户(User)和帖子(Post):

社交网络平台的模型

我们在图表中使用了 Iterable,并且我们将为每个示例提供具体的实现:ListSet

为了测试请求的数量,我们将使用一个专用库,而不是检查日志。不过,我们会参考日志,以便更好地了解请求的结构。

如果在每个示例中没有明确指定关系的获取类型(Fetch Type),则默认情况下假定为默认值。所有的一对一关系都使用急切加载(Eager Fetch),而一对多关系则使用延迟加载(Lazy)。此外,代码示例中使用了 Lombok 来减少代码中的冗余。

3、N+1 问题

N+1 问题指的是,对于单个请求(例如检索用户),会对每个用户发出额外请求,以获取其信息。虽然这个问题通常与懒加载有关,但并非总是如此。

任何类型的关系都可能出现这种问题。不过,它通常出现在多对多或一对多关系中。

3.1、延迟加载

首先,来看看懒加载是如何导致 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
}

UserPost 之间是一对多的关系。这意味着每个 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、急切加载

在大多数情况下,急切加载可以帮助我们解决 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、多个集合

在初始 Domain 中引入 Group

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、延迟加载

这种关系的表现一般与之前使用懒加载的示例类似。每次访问懒加载的信息时,都会发起一个新请求。

因此,除非直接访问 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、急切加载

现在来看看急切加载的行为。在请求一个 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

让我们以不同的方式处理这种情况。用集合(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、权衡利弊

在简单情况下,选择适当的 Fetch Type 可能有助于减少请求的数量。然而,使用简单的注解,我们对查询生成的控制有限。而且,这是透明进行的,Domain 模型的小改动可能会带来巨大的影响。

解决问题的最佳方法是观察系统行为并识别访问模式。创建单独的方法、SQL 和 JPQL 查询有助于针对每种情况进行调整。此外,还可以使用 Fetch Mode 来提示 Hibernate 如何加载相关实体。

添加简单测试有助于解决模型中的意外变化。这样,就能确保新的关系不会产生笛卡尔积或 N+1 问题。

6、总结

虽然 Eager Fetch Type 可以通过额外查询缓解一些简单的问题,但它可能会导致其他问题。有必要对应用进行测试,以确保其性能。

不同的 Fetch Type 和关系组合往往会产生意想不到的结果。因此,最好通过测试来覆盖关键部分。


Ref:https://www.baeldung.com/spring-hibernate-n1-problem