《Spring Boot 入门》13.3 分页与排序

列表接口几乎都要分页。本节讲清 Pageable、Page、Slice、Sort 四者的关系,贴真实 SQL 展示分页背后的 count 与数据两条语句,说明为什么不该直接暴露 Page 的 JSON、排序字段为何要白名单校验,并给出深分页的性能问题与 keyset 分页思路。

本节目标:为图书列表接口加上规范的分页与排序,理解 Pageable 背后的两条 SQL,学会把 Page 转成稳定响应、做排序白名单校验,并认识深分页的坑。
适用版本:Spring Boot 4.1.x(Java 21)

13.3 分页与排序

前两节我们把图书的关联建好了,查询也写得出来。但列表接口不能一次返回全部数据——图书可能有几十万条,全量返回会撑爆内存、拖垮数据库。分页是列表接口的标配,排序则决定用户看到的是「最新的在前」还是「按书名排列」。第 8 章的 8.3 节已经约定查询参数用 page / size / sort,这一节把约定落到代码与 SQL 上。

13.3.1 Pageable、Page、Slice、Sort 四者的关系

先厘清四个接口的分工,它们是「请求」与「响应」两两配对:

类型角色说明
Pageable请求描述「要第几页、每页多少、怎么排序」
Page<T>响应一页数据 + 总数 + 总页数
Slice<T>响应一页数据 + 「还有没有下一页」,不含总数
Sort请求排序规则,被 Pageable 持有

Pageable 是抽象接口,实际用 PageRequest;Page 与 Slice 是查询结果。Slice 是 Page 的父接口——Page 多提供了 getTotalElements、getTotalPages 这些需要额外查询才能得到的信息。Sort 可以独立使用(用于「全部数据按某字段排」的场景),也可以塞进 Pageable 一起用。

13.3.2 构造 PageRequest 与 Sort

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

// 第 0 页,每页 10 条,按 publishedYear 倒序
PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "publishedYear"));

// 多字段排序:先按分类升序,再按价格降序
Sort.by(Sort.Order.asc("category.name"), Sort.Order.desc("price"));

几个容易记错的点:

  • page 从 0 开始。PageRequest.of(0, 10) 是第一页,传负数会抛 IllegalArgumentException。
  • size 必须大于 0。PageRequest.of(0, 0) 直接抛异常。
  • 排序字段写的是实体属性名(publishedYear),不是列名(published_year)。
  • 排序可以嵌套属性:category.name 会生成对应的 join。

13.3.3 分页查询的执行过程:两条 SQL

把 Pageable 直接交给 Repository 方法,Spring Data 会自动完成分页:

public interface BookRepository extends JpaRepository<Book, Long> {

    Page<Book> findByCategoryName(String categoryName, Pageable pageable);
}

执行时,Spring Data 会发出两条 SQL。打开 show-sql 看:

-- 第 1 条:count 查询,算出总数
select count(b1_0.id) from book b1_0
join category c1_0 on c1_0.id=b1_0.category_id
where c1_0.name=?;

-- 第 2 条:数据查询,取当前页
select b1_0.id,b1_0.author_id,b1_0.category_id,b1_0.price,b1_0.title
from book b1_0
join category c1_0 on c1_0.id=b1_0.category_id
where c1_0.name=?
order by b1_0.published_year desc
limit ?,?;

这两条语句解释了分页的成本结构:count 查询与数据查询各一次,count 在大表上可能很慢(它要扫描满足条件的全部行)。limit ?,? 的写法由数据库方言决定——MySQL 是 limit offset,size,PostgreSQL 是 limit size offset n,Hibernate 按方言翻译。一个性能提示:count 查询在大表 + 复杂条件下常常比数据查询还慢。如果业务不需要精确总数(比如「加载更多」的流式列表),就该考虑 Slice。

13.3.4 Slice:不要总数的更轻选择

Slice 只判断「还有没有下一页」,因此不执行 count 查询,只有一条数据查询。实现方式是「多取一条」:要 10 条就 limit 11,拿到 11 条说明有下一页,返回时截断成 10 条。

Slice<Book> findByCategoryName(String categoryName, Pageable pageable);
维度PageSlice
执行 SQL 条数2(count + data)1(data)
是否有 totalElements / totalPages有无
是否有 hasNext有有
适用场景需要页码、总数展示无限滚动、加载更多

选择依据很简单:页面要显示「共 42 条 / 第 3 页,共 5 页」就用 Page;只要「加载更多」就用 Slice。移动端的下拉刷新几乎都适合 Slice。

13.3.5 Page 提供的常用方法

Page<Book> page = bookRepository.findByCategoryName("java", pageable);

page.getContent();          // List<Book>,当前页数据
page.getTotalElements();    // long,总条数
page.getTotalPages();       // int,总页数
page.getNumber();           // int,当前页码(从 0 开始)
page.getSize();             // int,每页大小
page.getNumberOfElements(); // int,当前页实际条数(最后一页可能不满)
page.hasNext();             // 是否有下一页
page.hasPrevious();         // 是否有上一页
page.isFirst();             // 是否第一页
page.isLast();              // 是否最后一页

getTotalPages() 由 getTotalElements() 与 getSize() 计算得出,不需要额外查询。注意 getContent() 返回的 List 是不可变视图,不要试图往里面 add。

13.3.6 把 Page 转成自定义响应 DTO

直接把 Page<Book> 返回给前端会踩坑。看它序列化成 JSON 后的样子(已截断):

{
  "content": [{"id": 1, "title": "Effective Java"}],
  "pageable": {
    "sort": {"empty": false, "sorted": true},
    "offset": 0, "pageNumber": 0, "pageSize": 20, "paged": true
  },
  "totalPages": 3, "totalElements": 42,
  "last": false, "size": 20, "number": 0,
  "first": true, "empty": false
}

问题有三:

  1. 结构臃肿且不稳定。pageable、sort 里全是内部实现细节,Spring Data 版本升级可能改变字段,前端被动跟着改。
  2. 与持久化细节耦合。前端拿到的是「JPA 的 Page」,而不是「我们的分页响应」,违背接口隔离。
  3. 无法扩展。想加一个业务字段,没地方放。

正确做法是定义一个稳定的响应 DTO,在 Service 或 Controller 里转换:

package com.example.library.web.dto;

import java.util.List;
import org.springframework.data.domain.Page;

public record PageResponse<T>(
        List<T> content,
        int page,
        int size,
        long totalElements,
        int totalPages,
        boolean hasNext) {

    public static <T> PageResponse<T> of(Page<T> p) {
        return new PageResponse<>(
                p.getContent(), p.getNumber(), p.getSize(),
                p.getTotalElements(), p.getTotalPages(), p.hasNext());
    }
}
{
  "content": [{"id": 1, "title": "Effective Java"}],
  "page": 0, "size": 20, "totalElements": 42, "totalPages": 3, "hasNext": true
}

这份 JSON 由我们自己掌控:字段该加的加、该删的删,Spring Data 怎么改内部结构都与前端无关。这与 8.3 节给出的 PageResponse 是同一套约定。

13.3.7 排序字段的白名单校验

Pageable 从请求参数自动解析,意味着用户可以传任意排序字段。这带来两类风险:性能(按没索引的列排序,数据库要全表排序)与安全(非法字段可能被拼进 SQL)。因此排序字段必须白名单校验:

private static final Set<String> SORTABLE =
        Set.of("title", "price", "publishedYear", "author.name");

public Page<Book> list(String category, Pageable pageable) {
    Sort safeSort = Sort.unsorted();
    for (Sort.Order order : pageable.getSort()) {
        if (!SORTABLE.contains(order.getProperty())) {
            throw new IllegalArgumentException("不允许按字段排序: " + order.getProperty());
        }
        safeSort = safeSort.and(Sort.by(order.getDirection(), order.getProperty()));
    }
    Pageable safe = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), safeSort);
    return bookRepository.findByCategoryName(category, safe);
}

配合统一异常处理,把 IllegalArgumentException 映射成 400,用户传非法字段时得到明确提示,而不是让数据库硬扛一次全表排序。顺带限制 size 上限也很重要——用户可以传 size=1000000,等于变相拉全表。

13.3.8 @PageableDefault 与参数解析约定

Pageable 参数由 PageableHandlerMethodArgumentResolver 自动解析,请求参数约定如下:

GET /api/books?page=0&size=10&sort=publishedYear,desc&sort=title,asc
参数含义默认值
page页码,从 0 开始0
size每页条数20
sort字段,方向,可重复出现实现多字段排序无

给默认值用 @PageableDefault:

@GetMapping
public PageResponse<Book> list(
        @RequestParam(required = false) String category,
        @PageableDefault(size = 20, sort = "publishedYear", direction = Sort.Direction.DESC)
        Pageable pageable) {
    return PageResponse.of(bookService.list(category, pageable));
}

几个细节:

  • sort 的方向取值是 asc / desc(大小写不敏感),多字段排序靠重复 sort 参数。
  • 可以在 application.yaml 里全局改默认值与上限:
spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100
        one-indexed-parameters: false

max-page-size 是个有用的安全阀:它会把超出上限的 size 直接截断,比自己在代码里 Math.min 更省事。one-indexed-parameters 设为 true 可让 page 从 1 开始,但会与 Spring Data 内部口径不一致,除非团队强要求,否则保持 false。

13.3.9 深分页的性能问题与 keyset 分页

分页到第 10000 页时,limit ?,? 里的 offset 会非常大:

-- 取第 500000 页(每页 10 条)
select ... from book order by published_year desc limit 5000000,10;

数据库必须先把前 500 万行排好序、再丢掉,才能拿到那 10 行。页越深,扫描越多,越慢。这就是深分页问题。两种缓解思路:

思路一:限制最大页码。 业务上很少有人翻到第 10 万页,直接拒绝过深的 page(比如超过 1000 页返回 400)。

思路二:keyset 分页(游标分页)。 不用 offset,改用「上一页最后一条的排序键」作为游标:

-- 第一页
select * from book order by published_year desc, id desc limit 10;

-- 下一页:把上一页最后一条的 (published_year, id) 传进来
select * from book where (published_year, id) < (?, ?)
order by published_year desc, id desc limit 10;
对比项offset 分页keyset 分页
查询条件limit offset, sizewhere 排序键 < 游标
深页性能随页数线性变差稳定(走索引)
能否跳页能(任意 page)不能,只能顺序翻
能否得到总数能需额外 count
排序键要求任意必须唯一(通常带上 id)

代价是不能跳页、总数难算,所以 keyset 适合「无限滚动」而不适合「带页码导航的后台列表」。(published_year, id) < (?, ?) 这种元组比较在 PostgreSQL、MySQL 8 都支持;不支持时拆成 published_year < ? or (published_year = ? and id < ?) 亦可。

13.3.10 与第 8 章 REST 约定呼应

本节的内容与 8.3 节是一体两面:8.3 定「接口长什么样」,本节定「底层怎么实现」。对应关系:

8.3 的约定本节的落地
?page=0&size=20&sort=title,ascPageableHandlerMethodArgumentResolver 解析
page 从 0 开始PageRequest.of(0, size)
用稳定 DTO 包装分页PageResponse.of(page)
列表接口返回 200@GetMapping + PageResponse

实测一条请求:

curl -s "http://localhost:8080/api/books?category=java&page=0&size=10&sort=price,desc"
# → {"content":[{"id":3,"title":"..."}],"page":0,"size":10,"totalElements":42,"totalPages":5,"hasNext":true}

到这里,图书服务的列表查询已经完整:关联建模(13.1)、自定义查询(13.2)、分页排序(13.3)三块拼齐。但分页查询里 save、update、delete 这些写操作要保证「要么全成功、要么全回滚」,这就轮到事务登场了。

小结

  • Pageable 是请求、Page / Slice 是响应、Sort 被 Pageable 持有;Slice 是 Page 的父接口。
  • page 从 0 开始,size 必须大于 0,排序字段写实体属性名。
  • Page 查询执行两条 SQL(count + data);Slice 只执行一条,靠多取一条判断 hasNext。
  • 不要直接把 Page 序列化返回,用 PageResponse 这样的稳定 DTO 隔离持久化细节。
  • 排序字段必须白名单校验,并限制 size 上限,避免全表排序与超大结果集。
  • @PageableDefault 给默认分页参数,spring.data.web.pageable.max-page-size 做全局上限。
  • 深分页随 offset 线性变慢,可用「限制页码」或「keyset 游标分页」缓解,后者不能跳页。

下一节进入第 14 章:给写操作加上事务边界,让「扣库存 + 生成订单」这类多步操作具备原子性。

阅读导航:上一节:13.2 JPQL 与原生 SQL · 下一节:14.1 @Transactional 基本用法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计