列表接口一开始很容易写成“查全部”。数据少时没问题,数据变多后就会慢、占内存,还会让前端一次拿到太多无用数据。分页是 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_at 和 id 的组合索引。不同数据库对降序索引和查询优化的细节不同,但原则一致:查询条件和排序方式要让数据库能沿索引读取,而不是每次全表扫描后排序。
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 就结束。它影响数据库索引、前端交互、响应结构和线上稳定性。入门阶段把这些边界想清楚,后面列表接口会少很多坑。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。