《Spring Boot 实战》5.2 分页、过滤与排序

围绕借阅服务的列表接口,讲清 Pageable 与 Page/Slice 的取舍、count 查询的真实代价、偏移分页的深分页问题与 keyset 分页的写法、过滤参数的表达方式、排序字段白名单如何同时防注入与防全表扫描,以及分页元信息的响应信封该怎么定。

本节目标:把借阅服务的列表接口做成可上生产的分页查询——选对 Page 与 Slice、看懂 count 查询的代价、用 keyset 分页绕开深分页、用白名单管住排序字段,并定下分页元信息的响应格式。
适用版本:Spring Boot 4.1.x(Java 21)

5.2 分页、过滤与排序

5.1 把资源与路由定好了,其中 /loans 这种集合资源一定会遇到三个问题:返回多少条、怎么筛、按什么排。 入门卷演示过 Pageable 的基本用法,本节不重复那部分,只讲生产上的取舍。

一个具体的场景:管理后台要展示全部借阅记录,可能有几十万条。产品经理说「每页 20 条,能翻到最后一页」。这句话里藏着两个坑——Page 的 totalElements 要额外跑一次 count,而「翻到最后一页」在深分页下会退化成全表扫描。本节把这两件事拆开讲。

5.2.1 Page 与 Slice:count 查询是要花钱的

Spring Data 的列表返回类型有两个常用选择:

类型额外查询能回答「共多少条」适用场景
Page<T>一次 COUNT(*)是需要页码总数、跳页导航
Slice<T>无(多取 1 条判断有无下一页)否无限滚动、加载更多

Page 的代价在 totalElements。为了算出总数,Spring Data 会为你的查询再生成一条 count 语句,而这条语句往往比数据查询本身更贵——它必须扫描满足条件的全部行,即使你只要 20 条。

public interface LoanRepository extends JpaRepository<Loan, Long> {

    // 返回 Page:框架会额外执行一条 count 查询
    Page<Loan> findByStatus(LoanStatus status, Pageable pageable);

    // 返回 Slice:只多取 1 条判断是否还有下一页,不跑 count
    Slice<Loan> findByMemberId(Long memberId, Pageable pageable);
}

Slice 的实现很巧妙:它取 size + 1 条,如果拿到了 size + 1 条就说明还有下一页,返回时丢掉多出的那条。它的代价是一次几乎免费的多取,换来的是省掉一次可能很贵的 count。

判断该用哪个,问一个问题:界面上真的有「第 37 页」这个可点击的页码吗?

  • 有分页器、要显示「共 12,345 条」→ 用 Page,count 的钱必须花。
  • 无限滚动、只有「加载更多」按钮 → 用 Slice,count 纯属浪费。

一个常见的中间方案是:首屏用 Page(用户需要知道总量),后续翻页用 Slice。 但同一个接口返回两种类型会让客户端难写,实践中更简单的是「需要总量就 Page,不需要就 Slice」,别在同一个接口里混。

5.2.2 深分页:偏移分页为什么会崩

Page 和 Slice 都基于偏移分页,底层是 LIMIT ? OFFSET ?。问题出在 OFFSET 变大时。

数据库执行 LIMIT 20 OFFSET 100000 时,并不是直接跳到第 100000 行——它必须先定位并丢弃前面 100000 行。偏移量越大,被丢弃的行越多,查询越慢。这就是深分页。注意这里只给量级判断,不给具体数字:在几十万到百万行规模上,偏移量到十万级时响应时间会从毫秒级掉到秒级甚至更差,具体拐点取决于表宽、索引覆盖情况和数据库类型,必须自己用真实数据量测。

测的方法很直接:造一份接近生产规模的数据,用 EXPLAIN ANALYZE 看不同 OFFSET 下的实际行扫描数与耗时,而不是凭感觉。对于大多数「后台管理」场景,真实用户根本不会点到第 5000 页,所以一个务实的做法是限制最大偏移量:

spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false

max-page-size=100 挡住「?size=1000000 一次性拉全表」的请求;再配合业务层拒绝过大的 page 值(比如 page * size > 10000 直接返回 400),就把深分页挡在门外。

keyset 分页(游标分页)是根治方案。 它不用偏移量,而是用「上一页最后一条记录的排序键」作为游标:

-- 偏移分页:OFFSET 越大越慢
SELECT * FROM loan ORDER BY id LIMIT 20 OFFSET 100000;

-- keyset 分页:无论翻到哪一页,都走索引定位
SELECT * FROM loan WHERE id > 100000 ORDER BY id LIMIT 20;

只要 id 上有索引,第二条语句的代价与偏移量无关,稳定在「定位 + 取 20 行」。游标就是排序键的值,客户端拿到响应里的 nextCursor 原样带回即可:

record LoanCursorPage(List<LoanResponse> items, String nextCursor, boolean hasMore) {}

@GetMapping("/loans")
LoanCursorPage list(@RequestParam(required = false) Long cursor,
                    @RequestParam(defaultValue = "20") int size) {
    List<Loan> rows = loanRepository.fetchAfter(cursor, size + 1);
    boolean hasMore = rows.size() > size;
    List<Loan> page = hasMore ? rows.subList(0, size) : rows;
    String next = hasMore ? String.valueOf(page.get(page.size() - 1).getId()) : null;
    return new LoanCursorPage(page.stream().map(LoanResponse::from).toList(), next, hasMore);
}

keyset 分页的取舍也要说清:

维度偏移分页keyset 分页
深分页性能随偏移量劣化与位置无关
随机跳页支持(?page=37)不支持,只能顺序前进
排序键要求任意必须唯一且有序(常加 id 兜底)
数据一致性插入/删除会导致错位无错位,但可能漏掉新插入项
实现复杂度框架内置需自己写游标编解码

结论:后台管理表格用偏移分页 + 最大偏移限制;面向用户的无限流、消息流、订单流用 keyset。 两者不是替代关系,按界面形态选。

5.2.3 过滤条件的表达:从查询参数到 DSL

过滤的复杂度是渐进的,不要一上来就上查询 DSL。

第一层:等值过滤。 直接映射成 @RequestParam:

@GetMapping("/loans")
Page<LoanResponse> list(@RequestParam(required = false) LoanStatus status,
                        @RequestParam(required = false) Long memberId,
                        @RequestParam(required = false) Long bookId,
                        Pageable pageable) {
    return loanService.search(status, memberId, bookId, pageable).map(LoanResponse::from);
}

这一层覆盖了绝大多数列表接口。参数可空,Service 层按「非空才拼进条件」的方式组装,对应 6.2 会讲的动态查询。

第二层:范围与模糊。 加时间区间、金额区间、前缀匹配。参数名要有约定,否则客户端要猜:from / to 表示闭开区间,status 表示等值,q 表示全文关键词。

GET /loans?status=OVERDUE&dueFrom=2026-09-01&dueTo=2026-10-01&q=算法

第三层:复杂布尔组合。 当过滤变成「(状态是逾期 或 已归还) 且 (会员等级是金卡) 且 (书有库存)」这种带括号的布尔表达式时,query string 就撑不住了。这时有两条路:

  • POST 一个过滤对象到 /loans/search:结构清晰,但违反 GET 语义(不是安全方法,无法被缓存)。
  • 引入受限的查询 DSL:只支持一层 AND + 白名单字段 + 白名单操作符,绝不暴露任意表达式。

关键判断是:过滤条件是不是「固定的几种组合」。 如果是,用查询参数就够了,即使参数多到七八个;如果产品要求「让用户自由组合任意条件」,那本质上是「把数据库查询能力开放给前端」,风险与复杂度都高一个量级,需要非常克制的 DSL 设计。多数业务系统高估了这个需求——先按固定参数做,真到必须自由组合时再引入 DSL。

5.2.4 排序白名单:一条防注入也防全表扫描的线

Spring Data 会自动把 ?sort=fieldName 解析成 Order by fieldName。如果直接把用户传入的字段名拼进排序,至少有两个问题:

  • 注入面:字段名进入 SQL 的 ORDER BY,校验不严可能被利用(取决于框架的转义程度,但不能赌)。
  • 全表扫描:ORDER BY 一个没有索引的列,数据库不得不对全表排序。用户传一个冷门字段,就能让列表接口打满数据库。

所以排序字段必须走白名单:

private static final Set<String> SORTABLE =
        Set.of("id", "loanDate", "dueDate", "returnedDate");

static Sort resolveSort(String sort) {
    String[] parts = sort.split(",");
    String field = parts[0];
    if (!SORTABLE.contains(field)) {
        throw new IllegalArgumentException("Unsupported sort field: " + field);
    }
    Sort.Direction dir = parts.length > 1 && "desc".equalsIgnoreCase(parts[1])
            ? Sort.Direction.DESC : Sort.Direction.ASC;
    return Sort.by(dir, field);
}

白名单要和索引对齐:能排序的字段,都应该是建了索引的字段。 这份白名单最好和数据库的索引清单一起评审,避免出现「接口允许按 description 排序,但 description 上没有索引」这种隐患。对无法加索引又要支持排序的场景,keyset 分页同样要求排序键有序,二者会一起失效——那时就该考虑冗余一个排序列或换存储。

5.2.5 分页参数的解析与默认值

Spring Data Web 会把 ?page= / ?size= / ?sort= 自动绑定成 Pageable,省掉了手写解析,但也带来两个需要显式控制的点。

其一,默认值。不同接口对「不传分页参数」的期望不同:管理列表可能希望默认 20 条,导出类接口可能希望默认 100 条。用 @PageableDefault 覆盖全局默认:

@GetMapping("/loans")
Page<LoanResponse> list(@PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC)
                        Pageable pageable) {
    return loanService.list(pageable).map(LoanResponse::from);
}

其二,参数的语义边界。page 从 0 开始(对应 one-indexed-parameters=false),size 受 max-page-size 约束。这两个约定必须写进 OpenAPI 文档,否则前端按 1 起始传 page=1,会静默地少拿第一页——这类 off-by-one 在联调阶段极难定位。

如果不想把 Pageable 直接暴露在 Controller 签名里(它把框架类型泄露到了接口层),也可以自定义一个 PageQuery record 来接收参数,再在 Service 层转成 Pageable。小项目用 Pageable 更省事,需要严格隔离框架类型时再包一层。

5.2.6 分页元信息:响应体长什么样

列表接口的响应体不该是裸数组,也不该把框架的 Page 对象直接序列化出去——Page 的内部结构(pageable、sort、content)既冗长又和框架实现耦合,升级 Spring Data 时可能变形。

推荐自己定一个稳定的信封:

{
  "items": [
    { "id": 101, "bookId": 42, "memberId": 7, "status": "OVERDUE" }
  ],
  "page": 0,
  "size": 20,
  "totalElements": 1345,
  "totalPages": 68,
  "hasNext": true
}
record PageResponse<T>(List<T> items, int page, int size,
                       long totalElements, int totalPages, boolean hasNext) {

    static <T> PageResponse<T> from(Page<T> page) {
        return new PageResponse<>(page.getContent(), page.getNumber(), page.getSize(),
                page.getTotalElements(), page.getTotalPages(), page.hasNext());
    }
}

对应的 DTO 里用 List<BookResponse> 而不是 Page<BookResponse>,Controller 负责转换。这样「响应格式」是你自己的契约,不随框架走。若确实要保留 Page 的序列化方式,Spring Boot 3.4 起提供了 spring.data.web.pageable.serialization-mode 控制其序列化形态,但自定义信封仍然是更可控的选择。

Slice 没有 totalElements,信封相应简化:去掉 totalElements 与 totalPages,只留 hasNext。

5.2.7 常见坑

坑一:把 Page 当默认返回值。 每个列表接口都返回 Page,于是每个接口都多一次 count。加载更多型的列表改用 Slice,省下的查询在列表页是最明显的一笔。

坑二:不设最大页大小。 默认 max-page-size=2000,意味着客户端能一次要 2000 条。对宽表或带 join 的查询,这足以打满数据库。按接口实际需要调到 100 以内更稳妥。

坑三:排序字段不校验。 直接把 ?sort= 透传给 Spring Data,等于把 ORDER BY 的控制权交给调用方。白名单是必须的。

坑四:keyset 游标用可变的字段。 用 updatedAt 当游标,一旦记录被更新,游标就可能跳行或重复。游标键要选不可变且唯一的字段,常用「业务排序键 + 主键」组合。

坑五:深分页被当成「数据库慢」来优化。 加索引治不了 OFFSET 100000,因为瓶颈是「丢弃 10 万行」而不是「找不到行」。这类问题只有换分页模型(keyset)或限制偏移量才能解决。

小结

  • Page 多跑一次 COUNT(*),Slice 靠多取 1 条判断下一页;界面有页码器才用 Page。
  • 偏移分页的瓶颈是 OFFSET 的丢弃成本,与索引无关;用最大偏移限制挡住深分页,用 keyset 分页根治。
  • keyset 游标必须唯一且不可变,常用「排序键 + 主键」;它的代价是失去随机跳页能力。
  • 过滤按复杂度分层:等值 → 范围/模糊 → 布尔组合;固定组合用查询参数,自由组合才考虑受限 DSL。
  • 排序字段必须走白名单,且白名单要和索引对齐,既防注入也防全表扫描。
  • 分页响应自己定信封(items / page / size / totalElements),不要直接序列化框架的 Page。

列表读得干净了,剩下的是写。并发下的重复提交、重试、状态冲突,是 5.3 要解决的问题。

阅读导航:上一节:5.1 资源建模与路由 · 下一节:5.3 幂等与并发控制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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