Go 分页 API 入门:limit、cursor 和稳定排序

用任务列表接口讲 Go HTTP API 中的分页设计,包括 limit 校验、offset 的局限、cursor 分页和稳定排序。

列表接口一开始很容易写成“查全部”。数据少时没问题,数据变多后就会慢、占内存,还会让前端一次拿到太多无用数据。分页是 API 的基本功。Go 写分页不难,难的是把参数校验、排序稳定性和响应结构设计清楚。

本文用任务列表接口讲两种常见分页:offset 和 cursor。入门项目可以从 offset 开始,但要知道它的局限;数据量和实时性要求更高时,cursor 更稳。

limit 参数校验

先处理 limit

func parseLimit(r *http.Request) (int, error) {
	raw := r.URL.Query().Get("limit")
	if raw == "" {
		return 20, nil
	}
	n, err := strconv.Atoi(raw)
	if err != nil {
		return 0, errors.New("limit must be a number")
	}
	if n <= 0 {
		return 0, errors.New("limit must be positive")
	}
	if n > 100 {
		return 0, errors.New("limit is too large")
	}
	return n, nil
}

给默认值,也给最大值。不要让用户传 limit=100000。分页不仅是用户体验,也是服务保护。

offset 分页

最常见:

SELECT id, title, created_at
FROM tasks
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?

Go 参数:

func parseOffset(r *http.Request) (int, error) {
	raw := r.URL.Query().Get("offset")
	if raw == "" {
		return 0, nil
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n < 0 {
		return 0, errors.New("invalid offset")
	}
	return n, nil
}

offset 简单,适合后台页面和数据量不大的列表。问题是 offset 很大时数据库要跳过很多行,性能会下降;同时列表有新增或删除时,用户翻页可能看到重复或漏掉的数据。

稳定排序很重要

只按 created_at DESC 排序不够。如果多条记录创建时间相同,顺序可能不稳定。加上唯一字段:

ORDER BY created_at DESC, id DESC

这样同一时间的任务也有确定顺序。分页接口一定要有稳定排序,否则前端翻页时会出现看似随机的重复和遗漏。

cursor 分页

cursor 分页不是传第几页,而是传“从哪条记录后继续”。比如上一页最后一条记录是:

created_at=2024-06-27T10:00:00Z
id=123

下一页查询:

SELECT id, title, created_at
FROM tasks
WHERE (created_at < ?)
   OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT ?

这样数据库可以沿着索引继续往后找,不需要跳过大量 offset。对实时更新的列表也更稳定。

编码 cursor

cursor 可以是 JSON 后 base64:

type Cursor struct {
	CreatedAt time.Time `json:"created_at"`
	ID        int64     `json:"id"`
}

func EncodeCursor(c Cursor) (string, error) {
	data, err := json.Marshal(c)
	if err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(data), nil
}

func DecodeCursor(s string) (Cursor, error) {
	data, err := base64.RawURLEncoding.DecodeString(s)
	if err != nil {
		return Cursor{}, err
	}
	var c Cursor
	if err := json.Unmarshal(data, &c); err != nil {
		return Cursor{}, err
	}
	return c, nil
}

不要把 cursor 当安全边界。客户端可以解码修改,所以服务端仍要校验。如果 cursor 包含敏感信息,可以签名或只放不敏感字段。

响应结构

type ListTasksResponse struct {
	Items      []TaskResponse `json:"items"`
	NextCursor string         `json:"next_cursor,omitempty"`
}

查询时可以多取一条:

rows, err := store.ListTasks(ctx, cursor, limit+1)

如果返回数量大于 limit,说明还有下一页。把多出来的一条用于生成 next cursor,然后响应前 limit 条。

if len(tasks) > limit {
	last := tasks[limit-1]
	next, _ = EncodeCursor(Cursor{CreatedAt: last.CreatedAt, ID: last.ID})
	tasks = tasks[:limit]
}

这样前端只要带着 next_cursor 请求下一页,不需要知道内部排序细节。

选择哪一种

offset 优点是简单,可以跳页。缺点是大 offset 慢,实时列表容易重复或漏数据。cursor 优点是性能和稳定性更好,缺点是不能自然跳到第 10 页,参数更复杂。

后台管理页、数据量小、需要跳页,可以用 offset。用户动态流、消息列表、交易记录、日志列表,更适合 cursor。

索引要跟排序匹配

分页性能不只取决于 Go 代码。数据库需要合适索引。对于下面的排序:

ORDER BY created_at DESC, id DESC

通常要考虑建立包含 created_atid 的组合索引。不同数据库对降序索引和查询优化的细节不同,但原则一致:查询条件和排序方式要让数据库能沿索引读取,而不是每次全表扫描后排序。

Go 层也应该限制可选排序字段。不要让用户随便传 sort=any_column,然后直接拼到 SQL。可以用白名单:

var allowedSort = map[string]string{
	"created": "created_at DESC, id DESC",
	"updated": "updated_at DESC, id DESC",
}

字段来自代码白名单,值作为参数传入。分页 API 的安全和性能,往往就在这些小边界里。

total 要不要返回

很多前端喜欢要 total,但总数并不总是便宜。SELECT COUNT(*) 在大表和复杂条件下可能很慢,而且实时变化的数据里 total 也只是某一刻的近似。后台管理页可以返回 total,用户动态流通常只需要 next_cursor

如果确实要 total,可以让它成为可选参数:

includeTotal := r.URL.Query().Get("include_total") == "true"

默认不算总数,只有需要分页器页码时才计算。API 设计不只是返回更多信息,也要考虑每个字段的成本。

小结

Go 分页 API 首先要校验 limit,设置默认值和上限。排序要稳定,通常需要时间字段加唯一 ID。offset 简单但有性能和一致性局限;cursor 更适合大量数据和实时列表。

分页不是 SQL 后面加个 LIMIT 就结束。它影响数据库索引、前端交互、响应结构和线上稳定性。入门阶段把这些边界想清楚,后面列表接口会少很多坑。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页