OFFSET分页在滑动窗口场景下会出问题,因其基于绝对行号跳过而非数据状态,中间插入或删除记录会导致漏数或重复;且无索引或非唯一排序字段易引发顺序漂移,性能随OFFSET增大呈O(N)恶化。

为什么 OFFSET 分页在滑动窗口场景下会出问题
滑动窗口(比如无限滚动、下拉加载更多)依赖“上一页末尾位置”来推进查询,而 OFFSET 是基于绝对行号跳过,不是基于数据状态。一旦中间有新记录插入或旧记录删除,OFFSET((page-1)*pageSize) 就会漏掉或重复某条数据——这不是 GORM 的 bug,是 SQL 分页模型本身的缺陷。
典型现象:用户滑到第 50 页后刷新,发现第 49 页最后一条记录不见了;或者两条一模一样的记录在相邻两页都出现。
- 根本原因是数据库不保证无主键/无索引字段的排序稳定性
-
OFFSET越大,MySQL/PostgreSQL 扫描的行数越多,不是线性增长而是 O(N) 开销 - 并发写入频繁时,
ORDER BY created_at单字段排序可能因时间戳重复导致顺序漂移
用主键游标实现滑动窗口分页(推荐做法)
游标分页不依赖页码,只依赖上一页最后一条记录的排序字段值(如 id 或 created_at),每次查“比它大/小的下 N 条”。GORM 不提供原生支持,但可以干净地手写:
- 必须显式指定
ORDER BY id ASC(升序)或ORDER BY created_at DESC, id DESC(带二级排序防重复) - 前端需传
cursor(如上一页最后的id值),而不是page - 首次请求可不带
cursor,查前pageSize条;后续请求加WHERE id > ?或WHERE created_at - 查完后取结果切片最后一项的
id作为下一次cursor,不要用len(results)推算
示例代码片段:
var users []User
if cursor == 0 {
db.Order("id ASC").Limit(pageSize).Find(&users)
} else {
db.Where("id > ?", cursor).Order("id ASC").Limit(pageSize).Find(&users)
}
// 返回时附带 next_cursor = users[len(users)-1].ID(非空判断!)
GORM 中用 Scopes 封装游标逻辑是否靠谱
有人用 Scopes 把游标逻辑包成可复用函数,看起来整洁,但容易掩盖两个关键点:
- Scope 无法自动注入
WHERE条件所需的cursor值,仍得手动传参,和直写没本质区别 - 如果 scope 内部做了
Count,它没法绕过游标语义——游标分页本身就不该返回总条数(COUNT(*)和游标查询语义冲突) - 真正需要的是把
cursor、direction、sortField统一收口,而不是强行套 Scope
更轻量的做法是写一个独立函数,接受 *gorm.DB、cursor、limit、orderBy,返回 []T 和 nextCursor。别为了“封装”增加理解成本。
什么时候还不得不回到 OFFSET 分页
游标分页虽稳,但业务上有时绕不开页码跳转(比如用户手动输“第 200 页”)。这时只能妥协用 OFFSET,但必须加防护:
- 硬限制
page上限(如 ≤ 50),超出直接返回空或错误,不查库 -
pageSize严格限制在 1–100,避免OFFSET(1000000)这类恶意调用 - 对高频访问的页(如第 1–10 页),用缓存兜底;靠后的页默认不缓存,也不鼓励跳转
- 如果真要支持深分页,优先考虑归档冷数据、加覆盖索引,而不是优化 Go 层代码
最常被忽略的一点:无论用哪种分页,ORDER BY 字段必须有索引。没索引的 ORDER BY created_at 在百万表上会先排序再 LIMIT,性能比 OFFSET 还差。











