OFFSET越翻越慢是因为数据库需逐行扫描并丢弃前N行,而非跳转;游标分页通过WHERE+ORDER BY基于上一页末尾记录查询,避免扫描,但要求排序字段唯一、索引顺序匹配、前后端游标理解一致。

为什么 OFFSET 越翻越慢,不是数据库“跳过”,而是“逐行丢弃”
MySQL/PostgreSQL 执行 OFFSET 100000 时,不会跳到第 100001 行开始读;它必须真实扫描前 100000 行、排序(如果没索引)、再丢弃。数据量到千万级,第 5000 页耗时超 3 秒是常态,不是 Go 层写法问题,是 SQL 执行计划本身缺陷。
常见错误现象:db.Offset(499999).Limit(20) 在压测中触发 context.DeadlineExceeded;同一页刷新两次,返回顺序不一致,甚至漏掉某条记录。
- 根本原因:OFFSET 是物理偏移,不是逻辑页码;数据插入/删除会改变行位置,导致分页漂移
- 性能断崖点通常出现在
OFFSET > 5000(MySQL)或OFFSET > 10000(PostgreSQL),取决于索引覆盖程度 - 别指望加个索引就能救 OFFSET——即使
ORDER BY created_at有索引,OFFSET仍需遍历索引树前 N 项
游标分页怎么写才不丢数据、不重复
游标分页本质是“基于最后一条已知记录继续往后取”,绕开 OFFSET 的物理扫描缺陷。但写错一个细节,就可能返回空结果或重复数据。
- 排序字段必须全局唯一、单调递增:
id最稳;created_at在高并发下可能重复,必须补id作二级排序,例如ORDER BY created_at DESC, id DESC -
WHERE条件方向必须和ORDER BY严格一致:若ORDER BY id ASC,则用WHERE id > ?;若ORDER BY id DESC,则用WHERE id - 首次请求不带游标参数,直接查最新 20 条;响应体里必须返回第 20 条的
id(或复合值)给前端下一页用 - 前端传来的
cursor值不能直接拼进 SQL——要先校验是否为正整数,非法值(如负数、非数字)应返回400 Bad Request,而非静默 fallback 到第一页
索引怎么建,才能让游标分页真正快起来
游标分页快不快,不取决于 Go 代码,而取决于 WHERE + ORDER BY 能否命中同一个索引。否则 WHERE id > ? 可能全表扫描,比 OFFSET 还慢。
立即学习“go语言免费学习笔记(深入)”;
- 单字段游标(如
id):建普通索引即可,CREATE INDEX idx_users_id ON users(id) - 时间戳游标(如
created_at):必须搭配主键兜底,索引要包含两个字段且顺序一致,CREATE INDEX idx_users_created_at_id ON users(created_at DESC, id DESC) - 避免在 WHERE 中混用不同方向:比如
ORDER BY created_at DESC, id ASC,但索引是(created_at DESC, id DESC),会导致索引失效 - 复合索引字段顺序必须匹配查询条件顺序:WHERE 先用
a,再用b,ORDER BY 也按a, b排,索引才能完全生效
GORM 游标分页容易踩的坑
GORM 封装了链式调用,但游标分页必须打破惯性思维——不能复用 Offset/Limit 的写法,也不能依赖 Count() 获取总数。
- 别在 Count 查询里复用带
Where("id > ?")的 DB 实例:它会忽略游标条件,导致总数不准;正确做法是单独构造无游标条件的计数查询,例如db.Model(&User{}).Where("status = ?", "active").Count(&total) -
Order()必须显式写出,且不能省略:没加Order("id ASC")的分页结果不可预测,数据库不保证无序返回顺序 - 不要试图“校验 cursor 是否真实存在”:游标只是推进位置的标记,
WHERE id > 12345查不到结果就返回空数组,前端停加载即可,后端无需额外查一遍 - UUID 主键不能直接当游标用:得改成复合游标,例如
WHERE (created_at, id) > (?, ?),对应索引也要是(created_at, id)
datetime 缺少毫秒),数据错乱就不可避免。


















