1. 概述

在 Spring Boot 应用中,我们经常需要将表格数据分块(每页20或50行)呈现给客户端。分页是处理大型数据集时返回部分数据的常用技术。但某些场景下,我们需要一次性获取全部结果。

本教程首先回顾 Spring Boot 中分页获取数据的方法,接着探讨如何通过分页方式一次性获取单表所有数据,最后深入处理带关联关系的复杂数据获取场景。

2. Repository

Repository 是 Spring Data 提供的数据访问抽象接口。根据选择的 Repository 子接口,Spring Data 会预定义一组数据库操作方法。

我们无需为标准数据库操作(如查询、保存、删除)编写代码,只需为实体创建接口并继承合适的 Repository 子接口即可。

运行时,Spring Data 会创建代理实现来处理方法调用。当调用 Repository 接口方法时,Spring Data 会根据方法名和参数动态生成查询语句。

Spring Data 定义了三种常用 Repository 子接口:

  • CrudRepository:最基础的 Repository 接口,提供 CRUD(增删改查)操作
  • PagingAndSortingRepository:继承 CrudRepository,增加分页和排序支持
  • JpaRepository:继承 PagingAndSortingRepository,添加 JPA 特有操作(如保存并刷新实体、批量删除等)

3. 获取分页数据

先看一个简单场景:使用分页从数据库获取数据。首先创建 Student 实体类:

@Entity
@Table(name = "student")
public class Student {
    @Id
    @Column(name = "student_id")
    private String id;

    @Column(name = "first_name")
    private String firstName;

    @Column(name = "last_name")
    private String lastName;
}

接着创建 StudentRepository 用于查询 Student 实体。JpaRepository 默认提供 findAll(Pageable pageable) 方法,因此无需额外定义方法即可实现分页查询:

public interface StudentRepository extends JpaRepository<Student, String> {
}

通过调用 findAll(Pageable) 获取第一页数据(每页10条)。第一个参数表示当前页码(从0开始),第二个参数表示每页记录数:

Pageable pageable = PageRequest.of(0, 10);
Page<Student> studentPage = studentRepository.findAll(pageable);

通常需要按特定字段排序返回分页结果。此时创建 Pageable 实例时需传入 Sort 参数。以下示例按 id 字段升序排序:

Sort sort = Sort.by(Sort.Direction.ASC, "id");
Pageable pageable = PageRequest.of(0, 10).withSort(sort);
Page<Student> studentPage = studentRepository.findAll(pageable);

4. 获取所有数据

常见问题:如何一次性获取所有数据?是否需要调用 *findAll()*?答案是否定的。Pageable 接口提供了静态方法 unpaged(),返回一个不包含分页信息的预定义 Pageable 实例。通过该实例调用 findAll(Pageable) 即可获取所有数据:

Page<Student> studentPage = studentRepository.findAll(Pageable.unpaged());

若需排序结果,从 Spring Boot 3.2 开始,可为 unpaged() 方法传入 Sort 参数。例如按 lastName 字段升序排序:

Sort sort = Sort.by(Sort.Direction.ASC, "lastName");
Page<Student> studentPage = studentRepository.findAll(Pageable.unpaged(sort));

但在 3.2 以下版本实现相同功能较复杂,因为 unpaged() 不接受参数。需创建包含最大页面尺寸和 Sort 参数的 PageRequest:

Pageable pageable = PageRequest.of(0, Integer.MAX_VALUE).withSort(sort);
Page<Student> studentPage = studentRepository.getStudents(pageable);

5. 获取关联数据

在 ORM 框架中,我们常定义实体间的关联关系。使用 JPA 等 ORM 框架可快速建模实体关系,避免编写 SQL 查询。

但若不理解底层机制,数据获取时可能出现性能问题。从带关联关系的实体获取结果集时需特别谨慎,尤其是获取所有数据时可能引发性能问题

5.1. N+1 问题

通过示例说明问题。为 Student 实体添加多对一关联:

@Entity
@Table(name = "student")
public class Student {
    @Id
    @Column(name = "student_id")
    private String id;

    @Column(name = "first_name")
    private String firstName;

    @Column(name = "last_name")
    private String lastName;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "school_id", referencedColumnName = "school_id")
    private School school;

    // getters and setters
}

每个 Student 关联一个 School,定义 School 实体:

@Entity
@Table(name = "school")
public class School {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "school_id")
    private Integer id;

    private String name;

    // getters and setters
}

现在获取所有 Student 记录,观察 JPA 实际执行的 SQL 查询数量。使用 Hypersistence Utilities 库的 assertSelectCount() 方法验证查询数量。在 pom.xml 添加依赖:

<dependency>
    <groupId>io.hypersistence</groupId>
    <artifactId>hypersistence-utils-hibernate-62</artifactId>
    <version>3.7.0</version>
</dependency>

创建测试用例获取所有 Student 记录:

@Test
public void whenGetStudentsWithSchool_thenMultipleSelectQueriesAreExecuted() {
    Page<Student> studentPage = studentRepository.findAll(Pageable.unpaged());
    List<StudentWithSchoolNameDTO> list = studentPage.get()
      .map(student -> modelMapper.map(student, StudentWithSchoolNameDTO.class))
      .collect(Collectors.toList());
    assertSelectCount((int) studentPage.getContent().size() + 1);
}

实际应用中不应直接暴露内部实体给客户端。通常会将内部实体映射为外部 DTO 返回。本例使用 ModelMapperStudent 转换为 StudentWithSchoolNameDTO,包含 Student 所有字段及 School 的 name 字段:

public class StudentWithSchoolNameDTO {
    private String id;
    private String firstName;
    private String lastName;
    private String schoolName;

    // constructor, getters and setters
}

执行测试后观察 Hibernate 日志:

Hibernate: select studentent0_.student_id as student_1_1_, studentent0_.first_name as first_na2_1_, studentent0_.last_name as last_nam3_1_, studentent0_.school_id as school_i4_1_ from student studentent0_
Hibernate: select schoolenti0_.school_id as school_i1_0_0_, schoolenti0_.name as name2_0_0_ from school schoolenti0_ where schoolenti0_.school_id=?
Hibernate: select schoolenti0_.school_id as school_i1_0_0_, schoolenti0_.name as name2_0_0_ from school schoolenti0_ where schoolenti0_.school_id=?
...

假设获取了 N 条 Student 记录。JPA 不仅执行一次 Student 表查询,还会额外执行 N 次 School 表查询以获取每个 Student 的关联记录。

此问题在 ModelMapper 转换时触发,当其尝试访问 Student 实例的 school 字段时发生。这种 ORM 性能问题称为 N+1 问题。

需注意:JPA 并非总是对每个 Student 执行 N 次 School 查询。实际数量取决于数据,因 JPA 有一级缓存机制,不会重复获取已缓存的 School 实例。

5.2. 避免获取关联数据

返回 DTO 时,通常不需要包含实体类的所有字段,只需子集。为避免触发关联查询,应只提取必要字段

本例可创建仅包含 Student 表字段的 DTO。若不访问 school 字段,JPA 不会执行额外查询:

public class StudentDTO {
    private String id;
    private String firstName;
    private String lastName;

    // constructor, getters and setters
}

此方法要求实体类中关联的 fetch 类型设置为 LAZY

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id", referencedColumnName = "school_id")
private School school;

⚠️ 注意:若 fetch 属性设为 FetchType.EAGER,即使后续不访问该字段,JPA 仍会在获取 Student 记录时立即执行额外查询

5.3. 自定义查询

当 DTO 需要 School 的字段时,可定义自定义查询,使用 fetch join 在初始查询中立即加载关联实体

public interface StudentRepository extends JpaRepository<Student, String> {
    @Query(value = "SELECT stu FROM Student stu LEFT JOIN FETCH stu.school",
      countQuery = "SELECT COUNT(stu) FROM Student stu")
    Page<Student> findAll(Pageable pageable);
}

执行相同测试用例,从 Hibernate 日志可见仅执行了一条连接 StudentSchool 表的查询:

Hibernate: select s1_0.student_id,s1_0.first_name,s1_0.last_name,s2_0.school_id,s2_0.name 
from student s1_0 left join school s2_0 on s2_0.school_id=s1_0.school_id

5.4. 实体图

更优雅的解决方案是使用 @EntityGraph 注解。该注解通过单次查询获取关联实体,避免为每个关联执行额外查询,从而优化检索性能。

以下示例定义 attributePaths 指示 JPA 查询 Student 记录时同时获取 School 关联:

public interface StudentRepository extends JpaRepository<Student, String> {
    @EntityGraph(attributePaths = "school")
    Page<Student> findAll(Pageable pageable);
}

也可在 Student 实体上使用 @NamedEntityGraph 注解定义实体图:

@Entity
@Table(name = "student")
@NamedEntityGraph(name = "Student.school", attributeNodes = @NamedAttributeNode("school"))
public class Student {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "school_id", referencedColumnName = "school_id")
    private School school;

    // Other fields, getters and setters
}

然后在 StudentRepositoryfindAll() 方法上添加 @EntityGraph 注解并引用命名实体图:

public interface StudentRepository extends JpaRepository<Student, String> {
    @EntityGraph(value = "Student.school")
    Page<Student> findAll(Pageable pageable);
}

执行测试用例后,JPA 会执行与自定义查询相同的连接查询:

Hibernate: select s1_0.student_id,s1_0.first_name,s1_0.last_name,s2_0.school_id,s2_0.name 
from student s1_0 left join school s2_0 on s2_0.school_id=s1_0.school_id

6. 总结

本文学习了 Spring Boot 中分页和排序查询结果的方法,包括获取部分数据和全部数据。同时掌握了处理关联数据时的高效检索实践,特别是避免 N+1 问题的技巧。

✅ 关键要点:

  • 使用 Pageable.unpaged() 一次性获取所有数据
  • 处理关联数据时警惕 N+1 问题
  • 优先使用 DTO 避免不必要关联查询
  • 通过 @EntityGraph 或自定义查询优化关联加载

完整示例代码可在 GitHub 获取。


原始标题:Get All Results at Once in a Spring Boot Paged Query Method | Baeldung