4. 常见分页策略概览
在构建 REST API 时,分页是控制数据流量的核心手段。不同的分页策略在性能、一致性和实现复杂度上各有取舍。本节将系统梳理四种主流分页方案:基于偏移量分页、基于游标分页、基于键集分页和基于时间戳分页,帮助你在服务边界设计中做出合理选择。
设计提示:分页策略的选择直接影响 API 的响应速度、数据一致性和客户端实现复杂度。没有银弹,只有最适合业务场景的方案。
4.1 四种策略核心对比
| 策略 | 核心原理 | 典型参数 | 数据一致性 | 性能特征 | 适用场景 |
|---|---|---|---|---|---|
| 偏移量分页 | 使用 OFFSET 跳过前 N 条记录 |
page, size |
弱(新增/删除导致偏移漂移) | 大偏移量时性能急剧下降 | 静态数据集、管理后台 |
| 游标分页 | 基于不透明游标定位记录位置 | cursor, limit |
强(不受新增/删除影响) | 稳定 O(log N) 或 O(1) | 实时数据流、社交 Feed |
| 键集分页 | 基于排序列的已知值定位 | after_id, since |
强(依赖唯一排序列) | 高效,利用索引直接跳转 | 有序列表、排行榜 |
| 时间戳分页 | 基于时间字段范围过滤 | before, after |
中等(时间精度影响) | 高效,时间索引支持 | 日志、事件流、时序数据 |
4.2 基于偏移量分页
偏移量分页是最传统、最直观的分页方式。客户端通过 page(页码)和 size(每页条数)参数请求数据,服务端计算 OFFSET = (page - 1) * size 后执行查询。
// Go 示例:偏移量分页查询
func ListUsers(ctx context.Context, page, size int) ([]User, int64, error) {
offset := (page - 1) * size
var total int64
db.Model(&User{}).Count(&total)
var users []User
result := db.Offset(offset).Limit(size).Find(&users)
return users, total, result.Error
}
避坑指南:当
OFFSET 值很大时(如第 10000 页),数据库仍需扫描并丢弃前 OFFSET 条记录,导致查询延迟显著增加。此外,并发写入会导致同一记录出现在不同页或重复出现。
4.3 基于游标分页
游标分页使用一个不透明的字符串(游标)来标记当前页的最后位置。客户端在后续请求中携带该游标,服务端解码后直接定位到对应记录之后的数据。游标通常经过 Base64 编码,对客户端隐藏内部实现细节。
// Go 示例:游标分页查询
func ListUsersByCursor(ctx context.Context, cursor string, limit int) ([]User, string, error) {
var users []User
query := db.Order("id ASC").Limit(limit + 1)
if cursor != "" {
decodedID, _ := decodeCursor(cursor) // 解码游标获取最后记录的 ID
query = query.Where("id > ?", decodedID)
}
result := query.Find(&users)
if result.Error != nil {
return nil, "", result.Error
}
var nextCursor string
if len(users) > limit {
nextCursor = encodeCursor(users[limit-1].ID) // 编码新游标
users = users[:limit]
}
return users, nextCursor, nil
}
优势:游标分页不受数据新增或删除的影响,每次请求都从固定位置开始读取,保证数据一致性。性能稳定,不随页数增加而下降。
4.4 基于键集分页
键集分页(Keyset Pagination)是游标分页的一种特例,它直接暴露排序列的值作为分页锚点。客户端通过 after_id 或 since 参数指定上一页最后一条记录的排序列值,服务端利用数据库索引直接跳转到该位置。
-- SQL 示例:键集分页查询
SELECT id, name, created_at
FROM users
WHERE id > 1000 -- 上一页最后一条记录的 ID
ORDER BY id ASC
LIMIT 20;
键集分页要求排序列必须唯一且有序。通常使用自增主键或业务唯一键(如订单号)作为排序列。如果排序列存在重复值,会导致数据丢失或重复。
| 特性 | 键集分页 | 游标分页 |
|---|---|---|
| 参数透明度 | 客户端可见排序列值 | 客户端不可见内部结构 |
| 实现复杂度 | 低,直接使用 SQL WHERE | 中,需要编码/解码逻辑 |
| 排序灵活性 | 依赖单一排序列 | 可支持复合排序 |
| API 友好度 | 客户端需理解排序逻辑 | 客户端只需传递不透明字符串 |
4.5 基于时间戳分页
时间戳分页适用于按时间维度排序的数据集,如日志、事件流、交易记录等。客户端通过 before 或 after 参数指定时间范围,服务端利用时间索引高效过滤。
// Go 示例:时间戳分页查询
func ListEvents(ctx context.Context, after time.Time, limit int) ([]Event, error) {
var events []Event
result := db.Where("event_time > ?", after).
Order("event_time ASC").
Limit(limit).
Find(&events)
return events, result.Error
}
设计建议:时间戳分页常与游标分页结合使用——将时间戳编码为游标,既保留时间维度的语义,又隐藏内部实现细节。例如:
cursor = base64(time.UnixMilli())。
4.6 四种策略选择决策树
4.7 策略选择速查表
| 业务场景 | 推荐策略 | 理由 |
|---|---|---|
| 用户动态 Feed | 游标分页 | 数据实时新增,需要强一致性 |
| 管理后台用户列表 | 偏移量分页 | 数据变化频率低,支持随机跳页 |
| 排行榜/积分排名 | 键集分页 | 按唯一排名键排序,性能要求高 |
| 操作日志/审计记录 | 时间戳分页 | 按时间范围查询,天然支持时间索引 |
| 商品列表(含筛选) | 游标分页 + 过滤 | 筛选条件变化频繁,游标保证结果稳定 |
| 消息队列消费 | 键集分页 | 按消息 ID 顺序消费,支持断点续传 |
架构决策:在 Go 服务中,推荐将游标分页作为默认分页方案。它兼顾了性能、一致性和 API 友好度。对于需要随机跳页的管理后台场景,可以额外提供偏移量分页作为备选方案,但需明确告知客户端其局限性。