在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分页了。

spring-bootspring-data-jpapageablerest-apijpa