在Spring Boot中使用Pageable进行分页和排序
1. 概述
一个API返回整个orders表可能在有100行时运行良好。当表增长到一百万行时,该API会使应用程序崩溃。Spring Data通过Pageable解决了这个问题,这是一个描述“获取哪一页、多少个元素、按什么排序”的对象。
读完这篇文章,你将知道如何:
- 声明具有分页的repository方法;
- 在
Page和Slice之间选择; - 直接从HTTP请求接收
page、size、sort参数; - 避免一些关于性能和安全性的陷阱。
2. 准备
您需要Spring Data JPA、Spring Web和一个数据库。本文使用H2以便简洁:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
示例实体是一个订单:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerName;
private BigDecimal total;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime createdAt;
protected Order() {
}
public Order(String customerName, BigDecimal total, OrderStatus status, LocalDateTime createdAt) {
this.customerName = customerName;
this.total = total;
this.status = status;
this.createdAt = createdAt;
}
// getter方法
}
public enum OrderStatus { NEW, PAID, SHIPPED }
表被命名为orders,因为ORDER是SQL关键字。如果使用默认名称,许多数据库会报告语法错误。
3. 在repository层分页
3.1. Pageable和PageRequest
JpaRepository已继承了PagingAndSortingRepository,所以你已经拥有findAll(Pageable)。要创建一个Pageable,你使用PageRequest.of:
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findAll(pageable);
System.out.println("页码: " + page.getNumber());
System.out.println("此页面的元素数: " + page.getNumberOfElements());
System.out.println("总元素数: " + page.getTotalElements());
System.out.println("总页数: " + page.getTotalPages());
System.out.println("还有下一页吗? " + page.hasNext());
各参数的含义:
0是页码。Spring Data 从0开始计算页码,而不是从1开始。10是每页的元素数。Sort.by("createdAt").descending()将订单按最新的排在前面排序。
对于表中有25个订单的情况,输出为:
页码: 0
此页面的元素数: 10
总元素数: 25
总页数: 3
还有下一页吗? true
启用spring.jpa.show-sql=true,你会看到Hibernate运行了两个SQL语句:
select o.id, o.created_at, o.customer_name, o.status, o.total
from orders o order by o.created_at desc offset ? rows fetch first ? rows only
select count(o.id) from orders o
第一条语句获取该页的数据。第二条语句计算总行数以计算totalElements和totalPages。offset ... fetch first的语法是H2的。对于MySQL或PostgreSQL,你会看到limit ? offset ?。
3.2. 带分页的派生查询
你只需要在方法的末尾添加一个Pageable参数。Spring Data会自动处理剩余的部分:
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
Slice<Order> findByCustomerName(String customerName, Pageable pageable);
List<Order> findTop5ByStatus(OrderStatus status, Sort sort);
}
findByStatus返回Page,附带count语句。findByCustomerName返回Slice。第4节会详细说明区别。findTop5ByStatus只需要排序而不需要分页。在这种情况下,你传递Sort而不是Pageable。
3.3. 组合多个排序条件
Sort允许你将多个列链接在一起:
Sort sort = Sort.by(Sort.Order.desc("total"), Sort.Order.asc("createdAt"));
Pageable pageable = PageRequest.of(1, 5, sort);
Page<Order> paidOrders = orderRepository.findByStatus(OrderStatus.PAID, pageable);
上面的代码获取第二页(第6到10个元素)已支付订单。金额较大的订单排在前面。如果两个订单的金额相同,较早的订单排在前面。
注意: 始终按至少一个唯一列排序,例如添加
id到末尾。如果仅按total排序,而许多行具有相同的值,数据库可能在不同的调用中返回不同的顺序。结果是一条记录可能在两页中出现,或者从两页中消失。
4. Page还是Slice?
Page<T> |
Slice<T> |
|
|---|---|---|
count语句 |
有 | 无 |
getTotalElements()、getTotalPages() |
有 | 无 |
hasNext() |
有 | 有 |
| 适合用于 | 带页码的表格(1, 2, 3 … 50) | 无限滚动、“查看更多”按钮 |
Slice不运行count语句。相反,它获取size + 1行。如果接收到多余的行,它知道还有下一页:
Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true
对于大型表,count(*)可能与主查询一样慢。如果UI不需要显示总页数,请使用Slice。
5. 从HTTP请求接收Pageable
5.1. Controller
Spring Boot自动配置了PageableHandlerMethodArgumentResolver。因此,将Pageable声明为controller参数就足够了:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderRepository orderRepository;
public OrderController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@GetMapping
public PagedModel<OrderResponse> list(
@RequestParam OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
Page<OrderResponse> page = orderRepository.findByStatus(status, pageable)
.map(OrderResponse::from);
return new PagedModel<>(page);
}
}
public record OrderResponse(Long id, String customerName, BigDecimal total, LocalDateTime createdAt) {
static OrderResponse from(Order o) {
return new OrderResponse(o.getId(), o.getCustomerName(), o.getTotal(), o.getCreatedAt());
}
}
需要注意的几点:
@PageableDefault在client未发送page、size或sort时设置默认值。page.map(...)将实体转换为DTO同时保持分页信息。你不应该将实体暴露到API中。PagedModel(来自org.springframework.data.web包,自Spring Data 3.3 / Boot 3.3起可用)将Page包装为稳定的JSON结构。
客户端调用API如下:
curl "http://localhost:8080/orders?status=PAID&page=0&size=2&sort=total,desc&sort=id,asc"
{
"content": [
{ "id": 17, "customerName": "Bình", "total": 990000.00, "createdAt": "2026-10-01T09:15:00" },
{ "id": 4, "customerName": "An", "total": 870000.00, "createdAt": "2026-09-28T14:02:00" }
],
"page": { "size": 2, "number": 0, "totalElements": 12, "totalPages": 6 }
}
sort参数的格式为属性,方向。你可以重复它多次来按多列排序。
5.2. 为什么不直接返回Page?
直接返回Page<OrderResponse>也可以工作。但是JSON时会序列化PageImpl这个内部类,所以结构可能在不同版本间变化。从Spring Data 3.3起,当这样做时你会在日志中看到警告。有两种处理方法:返回PagedModel如上所示,或者为整个应用程序启用自动转换模式:
@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}
5.3. 全局配置
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false
spring.data.web.pageable.page-parameter=page
spring.data.web.pageable.size-parameter=size
spring.data.web.sort.sort-parameter=sort
max-page-size是最重要的属性。如果client发送size=1000000,Spring会自动将其削减到100。默认限制是2000,对于大多数API来说太大了。
one-indexed-parameters=true允许client为第一页发送page=1。在内部,Pageable仍然使用从0开始的索引。
6. 使用@Query进行分页
对于自己编写的JPQL,Spring Data仍然会自动添加order by和limit/offset。它也会尝试从你的查询推断count语句。当查询包含join fetch时,这个推断通常是错误的,所以你需要单独声明countQuery:
@Query(value = "select o from Order o join fetch o.customer c where c.city = :city",
countQuery = "select count(o) from Order o join o.customer c where c.city = :city")
Page<Order> findByCity(@Param("city") String city, Pageable pageable);
这部分假设Order与Customer实体有@ManyToOne关系。对于@ManyToOne,join fetch与分页可以正常工作。
注意: 在分页时不要将
join fetch与集合(@OneToMany)一起使用。Hibernate无法在SQL级别为这种情况应用limit。它会将所有数据加载到内存中,然后再分页,同时记录警告HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory。正确的方法是先按ID分页,然后根据该ID列表获取集合。
7. 测试
@DataJpaTest启动嵌入式数据库,仅加载JPA层,所以非常适合测试repository:
@DataJpaTest
class OrderRepositoryTest {
@Autowired
OrderRepository orderRepository;
@BeforeEach
void setUp() {
LocalDateTime base = LocalDateTime.of(2026, 10, 1, 8, 0);
for (int i = 1; i <= 25; i++) {
orderRepository.save(new Order("客户 " + i, BigDecimal.valueOf(i * 10_000L),
OrderStatus.PAID, base.plusHours(i)));
}
}
@Test
void shouldReturnSecondPageSortedByTotalDesc() {
Pageable pageable = PageRequest.of(1, 10, Sort.by("total").descending());
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);
assertThat(page.getTotalElements()).isEqualTo(25);
assertThat(page.getTotalPages()).isEqualTo(3);
assertThat(page.getContent()).hasSize(10);
assertThat(page.getContent().getFirst().getTotal())
.isEqualByComparingTo("150000");
assertThat(page.hasNext()).isTrue();
}
@Test
void lastPageShouldContainRemainingOrders() {
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, PageRequest.of(2, 10));
assertThat(page.getContent()).hasSize(5);
assertThat(page.isLast()).isTrue();
}
}
在第一个测试中,第1页(第二页)包含第11到20个元素。该页的第一个元素是第11大的订单,即25 − 10 = 15,对应150000。第二个测试验证最后一页只包含5个订单。
8. 常见错误
按不存在的属性排序。 请求?sort=passwordHash或?sort=abc会导致PropertyReferenceException。如果不处理,client会收到500错误。更糟的是,client可能按敏感列排序来探测数据。你应该使用白名单过滤:
private static final Set<String> SORTABLE = Set.of("createdAt", "total", "id");
static Pageable sanitize(Pageable pageable) {
Sort safe = Sort.by(pageable.getSort().stream()
.filter(order -> SORTABLE.contains(order.getProperty()))
.toList());
return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
safe.isSorted() ? safe : Sort.by("createdAt").descending());
}
深层偏移。 page=50000&size=20强制数据库读取然后跳过一百万行。查询时间随偏移量线性增加。对于大数据或无限滚动,使用keyset分页,即“获取id < lastSeenId的行”。Spring Data通过ScrollPosition和Window支持这种方法。
混淆页面从1开始。 PageRequest.of(1, 10)是第二页。如果前端从1开始计数,启用one-indexed-parameters,而不是在代码中零散地减1。
数据在调用之间更改。 当使用偏移量分页时,有新记录插入时不稳定。用户在翻页时可能看到一个订单重复。Keyset分页避免了这个问题。
何时不应该使用Page: 当表很大且UI不需要总页数时,例如feed或日志。count语句在这种情况下是多余的开销,所以使用Slice或Window。
9. 结论
Pageable将三个信息组合成一个对象:页码、大小和排序方式。Spring Data将其转换为每一层的limit/offset和order by,从派生查询、@Query到controller参数。当你需要总页数时使用Page,当你只需要知道是否还有数据时使用Slice。返回JSON时,使用PagedModel以确保响应结构稳定。
不要忘记三件事:限制max-page-size、仅允许按白名单中的属性排序,以及始终按唯一列排序。当偏移量开始变慢时,是时候切换到keyset分页了。