gorm 本身没有 paginate 方法,分页只能手动组合 limit 和 offset;必须显式 order 排序、严格校验参数、独立执行总数查询,否则易导致数据重复、丢失或性能崩溃。

直接说结论:GORM 本身没有 Paginate 方法,所谓“分页”只是 Limit 和 Offset 的组合调用;它不自动处理排序、不校验参数、不隔离总数查询——这些全得你手动补,漏一个就可能查错、翻慢、返回重复或漏数据。
为什么 Limit + Offset 在 GORM 里必须显式写 Order
数据库对无序结果集不保证行顺序,OFFSET 是按“当前执行时的物理顺序”跳行。同一张表执行两次 db.Limit(10).Offset(20).Find(&users),若中间有写入/删除,很可能返回不同记录,甚至漏掉某条。
- 必须加
Order("id ASC")或Order("created_at DESC, id DESC"),且该字段要有索引 - 多字段排序时,
WHERE条件字段要参与复合索引,例如WHERE status = ? ORDER BY created_at DESC, id DESC,对应索引应为INDEX idx_status_created_id (status, created_at, id) - GORM 不会帮你推导隐式排序,
FindInBatches虽默认按主键排,但仅限于批处理场景,不能替代列表分页
GORM 的 Count 查询为什么经常不准
db.Where("status = ?", "active").Limit(20).Offset(40).Count(&total) 这种链式写法,Count 会忽略 Limit 和 Offset,但是否继承 Where 取决于 GORM 版本和 session 复用逻辑,极易出错。
- 简单场景用
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", "active").Count(&total)隔离条件 - 含
Joins或Preload的复杂查询,手写子查询更可靠:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE p.active = ?) AS t", true).Scan(&total) - 前端不需要总页数(比如无限滚动),就别查
COUNT(*)——省一次全表扫描,QPS 常能翻倍
Offset 越大越慢,不是 GORM 的锅,是 SQL 引擎的硬限制
执行 SELECT * FROM users LIMIT 20 OFFSET 100000 时,MySQL/PostgreSQL 必须真实扫描并丢弃前 10 万行,无法跳过。即使 id 有主键索引,B+ 树仍需逐节点跳转定位,I/O 和 CPU 开销线性上涨。
- 数据量超 10 万后,第 500 页 ≈ 扫 10 万行,第 5000 页 ≈ 扫 100 万行
- 高并发下,MVCC 版本差异还可能导致同个
OFFSET返回不一致结果 - 游标分页才是正解:
db.Where("id > ?", lastID).Order("id ASC").Limit(20),前提是lastID来自上一页最后一条记录,且id字段有索引、单调、非空
真正容易被忽略的是:游标分页要求前端透传 last_id(不是页码),后端不做任何转换;而索引是否生效,必须用 EXPLAIN 看 key 和 key_len,不能只靠“我建了索引”就放心。











