LIMIT+OFFSET在大数据量下变慢是因为数据库执行OFFSET时必须真实扫描并丢弃前N行,I/O和CPU开销随偏移量线性增长;应优先采用基于主键或时间戳的游标分页,如WHERE id > ? ORDER BY id LIMIT 20,并确保排序字段有索引。

为什么 LIMIT + OFFSET 在大数据量下会变慢
因为数据库执行 OFFSET 100000 时,必须先扫描并跳过前 10 万行,再取后续数据。即使有索引,B+ 树也要逐层定位到“第 100001 条”的物理位置,I/O 和 CPU 开销随偏移量线性增长。这不是 GORM 的问题,是 SQL 标准分页的固有缺陷。
常见错误现象:db.Offset(99999).Limit(20).Find(&users) 在 50 万行表中耗时从 50ms 涨到 2.3s;监控看到数据库 CPU 飙高,慢查询日志频繁出现带大 OFFSET 的语句。
- MySQL 8.0+ 可用
ORDER BY id LIMIT 20 OFFSET 99999,但性能仍劣于游标 - PostgreSQL 的
FETCH FIRST 20 ROWS ONLY语法等价于 LIMIT,不解决 OFFSET 问题 - SQLite 对大 OFFSET 几乎不可用,容易触发磁盘 I/O 超时
用 FindInBatches 实现基于主键的游标分页
FindInBatches 不是简单封装循环,它内部强制按主键排序(默认 ORDER BY id),每次查完记录最大 ID,下一批用 WHERE id > ? 起查——完全避开 OFFSET。
实操要点:
- 必须确保主键字段(如
id)有索引,否则WHERE id > ?会全表扫 - 不能在调用前手动加
Order("created_at DESC"),否则FindInBatches会 panic:它只认主键顺序 - 若业务必须按时间分页,改用复合游标:
WHERE (created_at, id) > (?, ?) ORDER BY created_at, id,需联合索引支持 - 示例代码:
db.FindInBatches(&users, 100, func(tx *gorm.DB, batch int) error { // 处理每批 users return nil })
手写游标分页:兼容任意排序字段
当 FindInBatches 的主键限制不适用(比如要按 updated_at 分页),就得自己构造游标条件。核心是把“第 N 页”转成“上一页最后一条的排序值”。
假设按 updated_at DESC, id DESC 排序,上一页最后一条是 updated_at=2026-08-20 10:30:00, id=5000,下一页查询应为:
db.Where("(updated_at, id)
- PostgreSQL 原生支持行值比较
(a,b) ;MySQL 5.7+ 也支持,但需开启sql_mode兼容 - SQLite 不支持行值比较,得拆成:
WHERE updated_at - 务必对排序字段建联合索引:
INDEX idx_updated_id (updated_at, id),否则游标失效
Count 查询与分页主查询必须分离
很多人试图复用同一个 *gorm.DB 实例做 Count 和 Find,结果 Count 带了 LIMIT 或漏了 WHERE 条件,总数不准。
正确姿势:
- 用
db.Session(&gorm.Session{NewDB: true})新建干净实例跑Count,避免链式调用污染 - 或显式用
db.Model(&User{}).Where(...).Count(&total),不依赖当前 db 状态 - 如果用了
Preload关联,Count必须单独查主表,否则 JOIN 会导致重复计数 - 不要在分页 Scope 里直接
db.Count()—— 它可能把GROUP BY或HAVING错带到主查询里
最易被忽略的一点:游标分页根本不需要 Count。前端显示“下一页”即可,总数对用户体验无实质帮助,还拖慢接口。真要总数,缓存它,别每次查。











