列表接口在数据量少时很容易写成"查全部"。但随着业务增长,数据量从几百条变成几百万条,无分页的接口会让数据库承受巨大压力,前端也会因此加载过多无用数据,用户体验急剧下降。分页是 API 设计的基本功,也是服务端性能保护的重要手段。
Go 实现分页 API 本身并不难,真正的挑战在于如何把参数校验、排序稳定性、响应结构、一致性保证和性能优化都设计清楚。本文用任务列表接口作为贯穿示例,深入讲解 Offset 分页和 Cursor 分页的完整实现、选型依据和最佳实践。
为什么分页不只是加个 LIMIT
很多初学者认为分页就是在 SQL 末尾加上 LIMIT 20 OFFSET 0 这么简单。事实上,一个完整的分页系统至少涉及以下问题:
- 参数校验:用户可能传入
limit=-1或limit=99999999,不校验会直接导致数据库被拖垮。 - 排序稳定性:如果排序字段不唯一,翻页时可能出现记录"漂移",同一行数据在一页和下一页之间反复出现。
- 数据一致性:列表在查询过程中被插入或删除时,用户翻页是否会看到重复数据或漏掉数据?
- 性能退化:Offset 分页在大数据量时性能会显著下降,而 Cursor 分页虽然性能更好,但无法支持跳转到任意页码。
- 响应结构:是否需要返回总数?
total在大表上可能是昂贵的操作。
这些问题的答案决定了你应该选择哪种分页方案,以及实现时需要注意哪些边界。
Limit 参数的校验策略
limit 是分页请求的核心参数,也是最需要严格校验的参数之一。一个健壮的 limit 解析函数应该包含默认值、最小值和最大值限制。
package main
import (
"errors"
"net/http"
"strconv"
)
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, max 100")
}
return n, nil
}
这里有几个关键决策:默认值设为 20 是因为大多数列表场景下,20 条是一个合理的用户可视范围;最大值设为 100 是对数据库和带宽的双重保护。一些 API 框架会把默认值设为 10,最大值设为 50,具体数值可以根据业务调整,但一定要有上限。
更严格的实现还可以记录超限请求,如果发现某个客户端反复尝试绕过限制,可以触发告警或限流。
Offset 分页的完整实现
Offset 分页是最直观也是最容易实现的方案。它的核心思想是"跳过前 N 条,取接下来的 M 条"。
参数解析
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
}
SQL 查询
SELECT id, title, status, created_at
FROM tasks
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
对应的 Go store 层实现:
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type TaskStore struct {
db *sql.DB
}
func (s *TaskStore) ListTasksOffset(ctx context.Context, offset, limit int) ([]Task, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, title, status, created_at
FROM tasks
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.Title, &t.Status, &t.CreatedAt); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
Offset 的致命缺陷
Offset 分页的问题在数据量大时会暴露出来:
- 性能退化:当
OFFSET 1000000时,数据库仍然需要扫描前 100 万条记录才能返回结果,只是不返回它们而已。这在 MySQL 的 InnoDB 引擎中尤其明显。 - 数据漂移:在两次查询之间如果有新数据插入到前面已查询的页中,用户翻页时可能会看到重复记录;如果有数据被删除,则可能跳过某些记录。
因此,Offset 分页只适用于数据量较小(通常十万条以内)、对实时一致性要求不高的后台管理页面。
稳定排序为什么是分页的前提
如果不保证稳定排序,分页结果可能完全不可预期。最常见的错误是只按 created_at DESC 排序,忽略了同一时刻可能有多条记录。
-- 错误示例:不稳定排序
ORDER BY created_at DESC
-- 正确示例:稳定排序
ORDER BY created_at DESC, id DESC
id 通常是自增主键或 UUID,具有唯一性。在 created_at 相同的情况下,id 提供了确定的排序依据。如果分页中缺少这样的稳定字段,前端用户会反馈"翻页时记录来回跳"。
最佳实践是建立覆盖排序字段的复合索引:
CREATE INDEX idx_tasks_created_id ON tasks (created_at DESC, id DESC);
这样数据库可以直接按索引顺序读取,避免全表扫描后再排序。
Cursor 分页的完整实战
Cursor 分页(游标分页)不传递"第几页",而是传递"从哪条记录之后继续"。它的核心优势是不受数据插入和删除的影响,性能也不会随着页码增长而退化。
Cursor 的设计
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 查询的 SQL
SELECT id, title, status, created_at
FROM tasks
WHERE (created_at < ?)
OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT ?
Store 层实现
func (s *TaskStore) ListTasksCursor(ctx context.Context, cursor *Cursor, limit int) ([]Task, error) {
var rows *sql.Rows
var err error
if cursor != nil {
rows, err = s.db.QueryContext(ctx, `
SELECT id, title, status, created_at
FROM tasks
WHERE (created_at < ?) OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT ?
`, cursor.CreatedAt, cursor.CreatedAt, cursor.ID, limit+1)
} else {
rows, err = s.db.QueryContext(ctx, `
SELECT id, title, status, created_at
FROM tasks
ORDER BY created_at DESC, id DESC
LIMIT ?
`, limit+1)
}
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.Title, &t.Status, &t.CreatedAt); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
这里故意查 limit+1 条,用于判断是否有下一页。前 limit 条返回给客户端,第 limit+1 条用来生成下一页的 cursor。
Handler 层实现
type ListTasksResponse struct {
Items []Task `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
limit, err := parseLimit(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var cursor *Cursor
if raw := r.URL.Query().Get("cursor"); raw != "" {
c, err := DecodeCursor(raw)
if err != nil {
http.Error(w, "invalid cursor", http.StatusBadRequest)
return
}
cursor = &c
}
tasks, err := h.store.ListTasksCursor(ctx, cursor, limit)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
resp := ListTasksResponse{Items: tasks}
if len(tasks) > limit {
last := tasks[limit-1]
next, _ := EncodeCursor(Cursor{CreatedAt: last.CreatedAt, ID: last.ID})
resp.NextCursor = next
resp.Items = tasks[:limit]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Offset 与 Cursor 的性能对比
为了直观地展示两种方案的差异,下面是一个模拟测试的对比:
| 数据量 | Offset 第1页 | Offset 第1000页 | Cursor 第1页 | Cursor 第1000页 |
|---|---|---|---|---|
| 1万条 | < 1ms | ~2ms | < 1ms | < 1ms |
| 10万条 | < 1ms | ~15ms | < 1ms | < 1ms |
| 100万条 | < 1ms | ~120ms | < 1ms | < 1ms |
| 1000万条 | < 1ms | ~800ms | < 1ms | < 1ms |
Cursor 分页每一页的查询时间基本恒定,因为它利用索引直接定位到起始位置。而 Offset 分页的时间随页码线性增长。
当然,Cursor 也有代价:不能跳转到任意页码,比如直接看第 50 页。另外 Cursor 是单向的(通常只支持下一页),如果需要上一页,需要额外设计反向 Cursor。
多条件组合查询与分页
真实业务中,分页很少单独使用,通常伴随着搜索条件,比如按状态筛选、按时间范围筛选等。
type TaskFilter struct {
Status string
StartTime time.Time
EndTime time.Time
}
func (s *TaskStore) ListTasksWithFilter(ctx context.Context, filter TaskFilter, cursor *Cursor, limit int) ([]Task, error) {
query := `
SELECT id, title, status, created_at
FROM tasks
WHERE 1=1`
var args []interface{}
if filter.Status != "" {
query += " AND status = ?"
args = append(args, filter.Status)
}
if !filter.StartTime.IsZero() {
query += " AND created_at >= ?"
args = append(args, filter.StartTime)
}
if !filter.EndTime.IsZero() {
query += " AND created_at <= ?"
args = append(args, filter.EndTime)
}
if cursor != nil {
query += " AND ((created_at < ?) OR (created_at = ? AND id < ?))"
args = append(args, cursor.CreatedAt, cursor.CreatedAt, cursor.ID)
}
query += " ORDER BY created_at DESC, id DESC LIMIT ?"
args = append(args, limit+1)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.Title, &t.Status, &t.CreatedAt); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
注意:搜索条件改变时,cursor 可能失效或语义发生变化。最安全的方式是 cursor 中编码当前查询的完整条件(或条件的哈希),在服务端验证查询条件是否匹配 cursor 的上下文。
Total 总数的取舍
很多前端分页组件需要 total 来显示总页数和页码导航。但 SELECT COUNT(*) 在大表上可能非常昂贵,尤其是有复杂 WHERE 条件时。
策略选择:
- 后台管理系统:用户习惯看到总页数,可以返回
total,但要做好性能监控,超过一定时间主动降级。 - 用户动态流、消息列表:只需要 “是否有下一页”,不需要总数,Cursor 分页最简洁。
- 折中方案:默认不返回总数,通过
include_total=true参数按需启用。
includeTotal := r.URL.Query().Get("include_total") == "true"
常见错误与 FAQ
Q1: 为什么我的分页翻页时会看到重复数据?
最常见的原因是排序不稳定。请确保 ORDER BY 子句以唯一字段结尾,比如 ORDER BY created_at DESC, id DESC。
Q2: Cursor 分页怎么支持上一页?
可以设计双向 cursor:next_cursor 和 prev_cursor。实现方式是在查询时同时保留首尾记录的信息,或查询时反转排序方向。但这样实现会变复杂,通常只在确实需要双向翻页时使用。
Q3: limit 设多大合适?
没有绝对标准。移动端列表通常 10-20 条,Web 端 20-50 条,后台数据表格 50-100 条。关键是必须设上限。
Q4: 分页查询遇到慢查询怎么办?
先用 EXPLAIN 检查执行计划,确认是否走了正确的索引。如果 ORDER BY 字段没有索引,分页查询会变成全表扫描加内存排序,性能极差。
Q5: 分页接口需要做缓存吗?
实时性强的列表通常不做缓存,因为数据变化频繁会导致缓存命中率极低。对于更新不频繁的数据(如历史报表),可以考虑分页结果缓存,但缓存时长要短,且以查询条件为缓存 key。
最佳实践总结
- 对所有分页参数做严格校验:特别是
limit和offset,不给客户端绕过限制的机会。 - 始终使用稳定排序:时间字段加唯一 ID 是最常见的稳定排序方案。
- 为高偏移量场景使用 Cursor 分页:用户动态流、消息中心、日志列表等优先考虑 Cursor。
- 建立覆盖排序的复合索引:让数据库能用索引顺序直接读取,避免文件排序。
- 限制可选排序字段:用白名单管理,不能让用户直接拼接到 SQL 中。
- 谨慎返回总数:总数在大表上不是免费的,只在确实需要的场景下计算。
- 测试边界情况:空列表、只有一页、最后一页、并发插入数据时的行为都应该有测试覆盖。
分页 API 的设计质量直接决定了列表功能的用户体验和系统性能。在 Go 项目中把分页想清楚,后续扩展和优化时会少踩很多坑。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。