9.5 KiB
9.5 KiB
分页模式
为什么需要分页?
大型集合不能一次性全部返回,原因在于:
- 性能问题(查询速度慢、数据载荷大)
- 内存限制(服务端和客户端)
- 网络超时
- 用户体验差
集合端点始终要进行分页。
分页策略
1. 基于偏移量的分页(Offset-Based Pagination)
最常见且直观。使用 offset(跳过数量)和 limit(每页条数)。
请求:
GET /users?offset=20&limit=10
响应:
{
"data": [
{"id": 21, "name": "User 21"},
{"id": 22, "name": "User 22"}
],
"pagination": {
"offset": 20,
"limit": 10,
"total": 150,
"has_more": true
},
"links": {
"first": "/users?offset=0&limit=10",
"prev": "/users?offset=10&limit=10",
"next": "/users?offset=30&limit=10",
"last": "/users?offset=140&limit=10"
}
}
优点:
- 实现简单
- 易于理解
- 支持随机访问(可跳转到任意页)
- 显示总条数
缺点:
- 偏移量越大性能越差(数据库需要扫描大量行)
- 分页过程中数据发生变化会导致结果不一致
- 对实时数据效率低下
- 数据库必须计算总行数(开销大)
适用场景:
- 中小型数据集
- 数据变更不频繁
- 需要随机访问页面
- 需要总条数
2. 基于页码的分页(Page-Based Pagination)
使用页码简化的偏移量分页。
请求:
GET /users?page=3&per_page=10
响应:
{
"data": [...],
"pagination": {
"page": 3,
"per_page": 10,
"total_pages": 15,
"total_count": 150
},
"links": {
"first": "/users?page=1&per_page=10",
"prev": "/users?page=2&per_page=10",
"next": "/users?page=4&per_page=10",
"last": "/users?page=15&per_page=10"
}
}
计算公式:
offset = (page - 1) * per_pagetotal_pages = ceil(total_count / per_page)
优缺点与基于偏移量的分页相同,但:
- 对用户更直观(第 1 页、第 2 页)
- 在 Web 应用中常见
3. 基于游标的分页(Cursor-Based Pagination)
使用一个不透明的游标(指针)指向下一组结果。
请求:
GET /users?limit=10
GET /users?cursor=eyJpZCI6MTIzfQ&limit=10
响应:
{
"data": [
{"id": 21, "name": "User 21"},
{"id": 22, "name": "User 22"}
],
"pagination": {
"next_cursor": "eyJpZCI6MzB9",
"prev_cursor": "eyJpZCI6MjB9",
"has_more": true
},
"links": {
"next": "/users?cursor=eyJpZCI6MzB9&limit=10",
"prev": "/users?cursor=eyJpZCI6MjB9&limit=10"
}
}
游标结构(base64 编码):
{"id": 30, "sort": "created_at"}
实现方式:
-- 第一页
SELECT * FROM users ORDER BY created_at DESC LIMIT 10;
-- 下一页(游标指向最后一项)
SELECT * FROM users
WHERE created_at < '2024-01-15T10:30:00Z'
ORDER BY created_at DESC
LIMIT 10;
优点:
- 结果一致(不会出现跳过或重复项)
- 对大型数据集效率高
- 适用于实时数据
- 无需昂贵的 COUNT 查询
- 数据库性能更优
缺点:
- 不支持随机访问(无法跳转到第 10 页)
- 不提供总条数
- 实现较复杂
- 游标不透明(用户无法修改)
适用场景:
- 大型数据集
- 数据频繁变更
- 无限滚动 UI
- 实时信息流
- 性能至关重要
4. 键集分页(Keyset Pagination)
与游标分页类似,但使用实际的字段值而非不透明的游标。
请求:
GET /users?after_id=20&limit=10
GET /users?after_created_at=2024-01-15T10:30:00Z&limit=10
响应:
{
"data": [
{"id": 21, "name": "User 21", "created_at": "2024-01-15T11:00:00Z"},
{"id": 22, "name": "User 22", "created_at": "2024-01-15T11:30:00Z"}
],
"pagination": {
"after_id": 30,
"limit": 10,
"has_more": true
},
"links": {
"next": "/users?after_id=30&limit=10"
}
}
实现方式:
SELECT * FROM users
WHERE id > 20
ORDER BY id ASC
LIMIT 10;
优点:
- 非常高效(利用索引)
- 游标透明(人类可读)
- 结果一致
- 实现简单
缺点:
- 需要已建立索引的列
- 不支持随机访问
- 排序仅限于游标字段
- 多字段排序时较复杂
适用场景:
- 简单排序(按 ID、时间戳)
- 需要高效分页
- 希望游标透明
- 已有合适的索引
5. 查找分页/基于时间的分页(Seek Pagination / Time-Based)
针对时序数据专用的键集分页。
请求:
GET /events?since=2024-01-15T10:00:00Z&until=2024-01-15T11:00:00Z&limit=100
响应:
{
"data": [...],
"pagination": {
"since": "2024-01-15T10:00:00Z",
"until": "2024-01-15T11:00:00Z",
"limit": 100,
"has_more": true
},
"links": {
"next": "/events?since=2024-01-15T11:00:00Z&until=2024-01-15T12:00:00Z&limit=100"
}
}
适用场景:
- 时序数据
- 日志和事件
- 活动流
- 分析数据
默认限制
始终设置合理的默认值和最大限制:
{
"default_limit": 20,
"max_limit": 100,
"min_limit": 1
}
校验:
GET /users?limit=1000
响应:400 Bad Request
{
"error": {
"code": "INVALID_LIMIT",
"message": "Limit must be between 1 and 100. Default is 20."
}
}
响应格式
标准分页对象
{
"data": [...],
"pagination": {
"limit": 10,
"offset": 20,
"total": 150,
"has_more": true,
"has_previous": true
}
}
链接头部(RFC 5988)
Link: </users?offset=0&limit=10>; rel="first",
</users?offset=10&limit=10>; rel="prev",
</users?offset=30&limit=10>; rel="next",
</users?offset=140&limit=10>; rel="last"
使用方: GitHub API
嵌入式链接
{
"data": [...],
"_links": {
"self": { "href": "/users?offset=20&limit=10" },
"first": { "href": "/users?offset=0&limit=10" },
"prev": { "href": "/users?offset=10&limit=10" },
"next": { "href": "/users?offset=30&limit=10" },
"last": { "href": "/users?offset=140&limit=10" }
}
}
分页与排序
分页时始终支持排序:
GET /users?sort=created_at&order=desc&limit=10
GET /users?sort=-created_at&limit=10 # 降序
GET /users?sort=last_name,first_name&limit=10 # 多字段
对于游标分页,游标必须包含排序字段:
{
"cursor": {
"id": 123,
"created_at": "2024-01-15T10:30:00Z",
"sort_fields": ["created_at", "id"]
}
}
分页与过滤
将过滤与分页结合使用:
GET /users?status=active&role=admin&offset=0&limit=10
重要提示: 先过滤再分页:
- 过滤记录
- 统计过滤后的结果数
- 应用分页
- 返回分页后的子集
总条数
包含总条数
{
"data": [...],
"pagination": {
"total": 1523,
"limit": 10,
"offset": 20
}
}
优点:
- 客户端知道结果总数
- 可计算总页数
- 更好的用户体验(显示"第 3 页,共 153 页")
缺点:
- COUNT 查询开销大
- 拖慢响应速度
- 对大型或频繁变更的数据集不准确
省略总条数
{
"data": [...],
"pagination": {
"has_more": true,
"limit": 10
}
}
适用场景:
- 大型数据集(COUNT 太慢)
- 实时数据(总数不断变化)
- 游标分页
- 无限滚动 UI
可选的总条数
让客户端决定是否请求总条数:
GET /users?limit=10&include_total=true
边界情况
空结果
{
"data": [],
"pagination": {
"offset": 0,
"limit": 10,
"total": 0,
"has_more": false
}
}
最后一页
{
"data": [{"id": 150, "name": "Last User"}],
"pagination": {
"offset": 140,
"limit": 10,
"total": 150,
"has_more": false
},
"links": {
"first": "/users?offset=0&limit=10",
"prev": "/users?offset=130&limit=10",
"next": null
}
}
超出范围
GET /users?offset=10000&limit=10
响应:200 OK(空结果)
{
"data": [],
"pagination": {
"offset": 10000,
"limit": 10,
"total": 150,
"has_more": false
}
}
或者对于不存在的页面返回 404:
GET /users?page=1000&per_page=10
响应:404 Not Found
{
"error": {
"code": "PAGE_NOT_FOUND",
"message": "Page 1000 does not exist. Total pages: 15"
}
}
最佳实践
- 始终对集合进行分页——永远不要返回无限制的列表
- 设置合理的默认值——默认每页 20–50 条
- 强制执行最大限制——防止过载(最大 100–1000 条)
- 包含 has_more 标志——告知客户端是否还有更多结果
- 提供导航链接——让获取上一页/下一页变得简单
- 文档化分页——说明游标格式、限制、默认值
- 保持一致性——在所有端点上使用相同的分页模式
- 考虑性能——根据数据大小/类型选择策略
- 支持排序——让客户端控制结果顺序
- 处理边界情况——空结果、最后一页、无效游标
对比矩阵
| 特性 | 偏移量 | 页码 | 游标 | 键集 |
|---|---|---|---|---|
| 性能 | 偏移量大时差 | 差 | 优秀 | 优秀 |
| 随机访问 | 支持 | 支持 | 不支持 | 不支持 |
| 总条数 | 支持 | 支持 | 不支持 | 可选 |
| 一致性 | 差 | 差 | 优秀 | 优秀 |
| 复杂度 | 简单 | 简单 | 中等 | 中等 |
| 实时数据 | 差 | 差 | 优秀 | 优秀 |
| 数据库负载 | 高 | 高 | 低 | 低 |
| 适用场景 | 小数据集 | Web UI | 信息流/流式数据 | 大型数据集 |