本节目标:把借阅服务的列表接口做成可上生产的分页查询——选对
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 幂等与并发控制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。