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 友好度。对于需要随机跳页的管理后台场景,可以额外提供偏移量分页作为备选方案,但需明确告知客户端其局限性。