Go 分页 API 设计与实现:Offset、Cursor、稳定排序与性能对比

深入讲解 Go 分页 API 的完整设计与实现,涵盖 Offset 与 Cursor 两种方案的原理、适用场景、性能对比及实战代码,帮助你构建高性能、高稳定的列表接口。

列表接口在数据量少时很容易写成"查全部"。但随着业务增长,数据量从几百条变成几百万条,无分页的接口会让数据库承受巨大压力,前端也会因此加载过多无用数据,用户体验急剧下降。分页是 API 设计的基本功,也是服务端性能保护的重要手段。

Go 实现分页 API 本身并不难,真正的挑战在于如何把参数校验、排序稳定性、响应结构、一致性保证和性能优化都设计清楚。本文用任务列表接口作为贯穿示例,深入讲解 Offset 分页和 Cursor 分页的完整实现、选型依据和最佳实践。

为什么分页不只是加个 LIMIT

很多初学者认为分页就是在 SQL 末尾加上 LIMIT 20 OFFSET 0 这么简单。事实上,一个完整的分页系统至少涉及以下问题:

  1. 参数校验:用户可能传入 limit=-1limit=99999999,不校验会直接导致数据库被拖垮。
  2. 排序稳定性:如果排序字段不唯一,翻页时可能出现记录"漂移",同一行数据在一页和下一页之间反复出现。
  3. 数据一致性:列表在查询过程中被插入或删除时,用户翻页是否会看到重复数据或漏掉数据?
  4. 性能退化:Offset 分页在大数据量时性能会显著下降,而 Cursor 分页虽然性能更好,但无法支持跳转到任意页码。
  5. 响应结构:是否需要返回总数?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_cursorprev_cursor。实现方式是在查询时同时保留首尾记录的信息,或查询时反转排序方向。但这样实现会变复杂,通常只在确实需要双向翻页时使用。

Q3: limit 设多大合适?

没有绝对标准。移动端列表通常 10-20 条,Web 端 20-50 条,后台数据表格 50-100 条。关键是必须设上限。

Q4: 分页查询遇到慢查询怎么办?

先用 EXPLAIN 检查执行计划,确认是否走了正确的索引。如果 ORDER BY 字段没有索引,分页查询会变成全表扫描加内存排序,性能极差。

Q5: 分页接口需要做缓存吗?

实时性强的列表通常不做缓存,因为数据变化频繁会导致缓存命中率极低。对于更新不频繁的数据(如历史报表),可以考虑分页结果缓存,但缓存时长要短,且以查询条件为缓存 key。

最佳实践总结

  1. 对所有分页参数做严格校验:特别是 limitoffset,不给客户端绕过限制的机会。
  2. 始终使用稳定排序:时间字段加唯一 ID 是最常见的稳定排序方案。
  3. 为高偏移量场景使用 Cursor 分页:用户动态流、消息中心、日志列表等优先考虑 Cursor。
  4. 建立覆盖排序的复合索引:让数据库能用索引顺序直接读取,避免文件排序。
  5. 限制可选排序字段:用白名单管理,不能让用户直接拼接到 SQL 中。
  6. 谨慎返回总数:总数在大表上不是免费的,只在确实需要的场景下计算。
  7. 测试边界情况:空列表、只有一页、最后一页、并发插入数据时的行为都应该有测试覆盖。

分页 API 的设计质量直接决定了列表功能的用户体验和系统性能。在 Go 项目中把分页想清楚,后续扩展和优化时会少踩很多坑。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南