跳到主内容

一文搞懂游标与偏移分页统一封装 ​

问题背景:列表接口为什么不能只靠 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。

HTTP Query统一参数解析PageRequest校验与规范化Offset 查询Cursor 查询统一响应items/pagenext_cursor

查询参数规范:先统一契约,再写实现 ​

推荐列表接口固定使用如下查询参数:

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

规范要点如下:

  1. page 从 1 开始,避免客户端理解 0 页;size 默认 20,最大 100;
  2. 游标模式使用 limit,默认 20,最大 100,不返回无意义的 total;
  3. sort 使用字段白名单,-created_at 表示倒序,多字段用逗号分隔;
  4. 时间范围统一使用 RFC3339 格式,并保证 start <= end;
  5. 多值过滤优先使用重复参数,如 status=PAID&status=SHIPPED,不要混用逗号和数组命名;
  6. 未知参数默认忽略或直接返回 400,建议开放 API 选择 400,内部接口至少记录告警;
  7. 游标必须服务端签发,建议 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(

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。