外观
一文搞懂游标与偏移分页统一封装
问题背景:列表接口为什么不能只靠 page 和 size
中后台系统里最常见的接口就是列表:订单列表、用户列表、审计日志、任务记录、消息通知。很多团队最初都会采用 page=1&size=20 这种偏移分页,因为它直观、容易调试,也方便前端展示页码。但随着数据量增长,问题会逐渐暴露:
- 深分页性能差,
OFFSET 100000 LIMIT 20在 MySQL 中通常仍需扫描并丢弃大量记录; - 数据实时写入时,翻页可能重复或遗漏,例如第 1 页刚查完,第 2 页前插入了新数据;
- 前端把过滤条件随意拼成 Query 参数,后端出现大量
statusList、status_list、statuses等重复语义; - 不同业务接口各自定义排序、时间范围、游标字段,客户端难以形成统一 SDK。
分页不是简单的“返回一批数据”,而是接口契约、查询性能、数据一致性和参数治理的交叉点。中后台场景下,我们通常需要同时支持两类能力:管理后台需要页码跳转,移动端滚动加载和日志流需要稳定游标。因此,更合理的做法不是二选一,而是在协议层统一抽象,在实现层按场景选择。
方案对比:偏移分页与游标分页如何选型
| 对比项 | 偏移分页 page/size | 游标分页 cursor/limit |
|---|---|---|
| 用户体验 | 支持跳页、总页数 | 仅支持上一页/下一页或无限滚动 |
| 查询语义 | LIMIT size OFFSET (page-1)*size | 根据游标位置继续扫描 |
| 深分页性能 | 偏移越大越慢 | 与位置基本无关,依赖索引 |
| 数据变动稳定性 | 容易重复、漏数据 | 排序稳定时一致性更好 |
| 适用场景 | 后台管理、低频查询、小结果集 | 消息流、日志、订单增量、高并发列表 |
游标分页的本质是“记住上一页最后一条记录的排序位置”,而不是记录页码。例如按创建时间倒序、ID 倒序排列时,游标可以包含 (created_at, id)。下一页查询应表达为:
sql
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT :limit;注意,仅用 created_at 做游标并不安全。同一毫秒可能出现多条记录,必须追加唯一字段作为 tie-breaker。
查询参数规范:先统一契约,再写实现
推荐列表接口固定使用如下查询参数:
text
GET /api/admin/orders?page=1&size=20&status=PAID&created_at_start=2026-01-01T00:00:00Z&created_at_end=2026-02-01T00:00:00Z&sort=-created_at,id
GET /api/admin/orders?mode=cursor&limit=20&cursor=eyJjIjoxNzY...&status=PAID&sort=-created_at,id规范要点如下:
page从 1 开始,避免客户端理解 0 页;size默认 20,最大 100;- 游标模式使用
limit,默认 20,最大 100,不返回无意义的total; sort使用字段白名单,-created_at表示倒序,多字段用逗号分隔;- 时间范围统一使用 RFC3339 格式,并保证
start <= end; - 多值过滤优先使用重复参数,如
status=PAID&status=SHIPPED,不要混用逗号和数组命名; - 未知参数默认忽略或直接返回 400,建议开放 API 选择 400,内部接口至少记录告警;
- 游标必须服务端签发,建议 Base64URL 编码并带版本号,禁止客户端自行构造。
统一响应建议保持同一外层结构:
json
{
"items": [],
"page": { "page": 1, "size": 20, "total": 128, "has_next": true },
"next_cursor": "",
"has_more": false
}虽然偏移分页和游标分页字段不完全相同,但客户端可以围绕 items、has_more、next_cursor 形成统一渲染逻辑。
Go 实战:可运行的统一分页封装
下面示例使用 Go 标准库实现参数解析、游标编解码、排序白名单和两种查询策略。为了便于直接运行,存储层使用内存切片模拟,接入 MySQL 时只需把 listByOffset 和 listByCursor 替换为 SQL 构造。
go
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
// Order 是列表返回的业务对象,CreatedAt 与 ID 共同构成稳定排序位置
type Order struct {
ID int64 `json:"id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// PageRequest 是所有列表接口统一接收的分页请求
type PageRequest struct {
Mode string // offset 或 cursor
Page int
Size int
Limit int
Cursor *Cursor
Sort []SortField
Status string
}
type SortField struct {
Name string
Desc bool
}
// Cursor 使用版本号便于后续演进,V1 保存最后一条记录的排序位置
type Cursor struct {
V int `json:"v"`
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
}
func EncodeCursor(c Cursor) string {
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
func DecodeCursor(s string) (Cursor, error) {
var c Cursor
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return c, fmt.Errorf("游标不是合法的 Base64URL: %w", err)
}
if err := json.Unmarshal(b, &c); err != nil {
return c, fmt.Errorf("游标内容无法解析: %w", err)
}
if c.V != 1 {
return c, fmt.Errorf("不支持的游标版本: %d", c.V)
}
return c, nil
}
var sortWhitelist = map[string]bool{
"created_at": true,
"id": true,
}
// ParsePageRequest 将 HTTP Query 规范化为后端统一对象
func ParsePageRequest(q url.Values) (PageRequest, error) {
req := PageRequest{
Mode: "offset",
Page: 1,
Size: 20,
Limit: 20,
Status: strings.TrimSpace(q.Get("status")),
}
if q.Get("mode") == "cursor" {
req.Mode = "cursor"
}
if req.Mode != "offset" && req.Mode != "cursor" {
return req, fmt.Errorf("mode 只能是 offset 或 cursor")
}
if v := q.Get("page"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return req, fmt.Errorf("page 必须是大于等于 1 的整数")
}
req.Page = n
}
if v := q.Get("size"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 100 {
return req, fmt.Errorf("size 必须在 1 到 100 之间")
}
req.Size = n
}
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 100 {
return req, fmt.Errorf("limit 必须在 1 到 100 之间")
}
req.Limit = n
}
if c := q.Get("cursor"); c != "" {
decoded, err := DecodeCursor(c)
if err != nil {
return req, err
}
req.Cursor = &decoded
}
// 默认按创建时间倒序,再按 ID 倒序,保证排序确定性
sortExpr := q.Get("sort")
if sortExpr == "" {
sortExpr = "-created_at,-id"
}
for _, item := range strings.Split(sortExpr, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
desc := strings.HasPrefix(item, "-")
name := strings.TrimPrefix(item, "-")
if !sortWhitelist[name] {
return req, fmt.Errorf("不允许按字段 %s 排序", name)
}
req.Sort = append(req.Sort, SortField{Name: name, Desc: desc})
}
if len(req.Sort) == 0 {
return req, fmt.Errorf("至少需要一个排序字段")
}
return req, nil
}
func main() {
// 模拟客户端请求:滚动查询第二页游标
firstNext := EncodeCursor(Cursor{V: 1, CreatedAt: time.Now(), ID: 1001})
q := url.Values{}
q.Set("mode", "cursor")
q.Set("limit", "20")
q.Set("status", "PAID")
q.Set("cursor", firstNext)
req, err := ParsePageRequest(q)
if err != nil {
panic(err)
}
fmt.Printf("mode=%s status=%s cursorID=%d limit=%d\n",
req.Mode, req.Status, req.Cursor.ID, req.Limit)
}仓储层建议采用“多取一条”的方式判断是否还有下一页:
go
// ListResult 是仓储层返回给 Service 的结果
type ListResult struct {
Items []Order
HasMore bool
}
func listByCursor(rows []Order, req PageRequest) ListResult {
limit := req.Limit
filtered := make([]Order, 0, limit+1)
for _, row := range rows {
// 业务过滤必须在游标比较前完成,避免游标跨过滤条件失效
if req.Status != "" && row.Status != req.Status {
continue
}
if req.Cursor == nil {
filtered = append(filtered, row)
} else {
// 默认排序为 created_at DESC,id DESC
if row.CreatedAt.Before(req.Cursor.CreatedAt) ||
(row.CreatedAt.Equal(req.Cursor.CreatedAt) && row.ID < req.Cursor.ID) {
filtered = append(filtered, row)
}
}
if len(filtered) == limit+1 {
break
}
}
hasMore := len(filtered) > limit
if hasMore {
filtered = filtered[:limit]
}
return ListResult{Items: filtered, HasMore: hasMore}
}在真实 SQL 中,需要让过滤字段和排序字段命中联合索引。例如:
sql
INDEX idx_status_created_id (status, created_at DESC, id DESC)不要把所有过滤条件都交给 ORM 动态拼接到最后,索引顺序、范围条件数量和排序方向都可能影响执行计划。
过滤参数治理:避免列表接口变成条件堆砌场
分页封装解决的是“怎么取”,过滤参数治理解决的是“按什么取”。建议建立统一的参数模型和校验层:
- 枚举值使用大写或后端统一的稳定码值,不允许前端直接传中文状态;
- 时间范围统一成对出现,并设置最大跨度,例如订单查询最多 92 天;
- ID 类参数使用重复 Query 或逗号分隔二选一,全系统保持一致;
- 模糊搜索参数命名为
keyword,明确最小长度、是否前缀匹配、是否走搜索引擎; - 租户、组织、数据权限等内部条件不从 Query 接收,而应从登录态注入;
- 所有过滤字段在后端维护白名单,禁止直接把
sort、field拼进 SQL。
对于复杂后台查询,可以把 Query 解析为显式结构体,而不是散落的 map[string]any:
go
type OrderFilter struct {
Statuses []string
CreatedAtStart *time.Time
CreatedAtEnd *time.Time
Keyword string
TenantID int64 // 从鉴权上下文注入,不信任客户端
}这样做的收益是可测试、可复用、可审查。新增过滤条件时,代码评审能清楚看到参数、校验、索引和权限影响,而不是在 Handler 中多出一个无人知晓的 `if q.Get(
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。