直接复用同一*gorm.db实例分页必然出错,因count与find互相污染条件;page结构体中current、size、total必须为指针以防御边界问题;order须显式声明且字段需索引;preload与分页互斥,应拆分查询或改用joins+窗口函数。

直接复用同一个 *gorm.DB 实例做分页,必然出错——Count 和 Find 会互相污染条件,不是“可能”,是“一定”。
为什么 db.Scopes(Page(p)).Find() 有时返回空结果
因为 Page Scope 内部若调用 db.Count(&total),它会继承当前 db 的全部状态:已加的 Where、Joins、甚至前面 Scope 塞进去的 LIMIT。MySQL 下 COUNT() OVER() 不生效时,GORM 会 fallback 到子查询,而子查询里若混了 LIMIT,总数就变成“limit 后的行数”。
- 典型现象:
db.Where("status = ?", 1).Scopes(Page(p)).Find(&list)查不到数据,但去掉Page(p)就能查到 - 根本原因:Count 查询偷偷带上了
LIMIT,导致total被设为 0 或极小值,后续逻辑可能跳过 Find - 正确解法:Scope 内必须用干净实例查总数,例如
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Count(&total)
Page 结构体里 Current 和 Size 必须是 *int
Gin 的 ShouldBindQuery 对非指针字段绑定失败时,会静默填 0。而 page=0 导致 Offset(-size),GORM 直接 panic;size=0 触发 Limit(0),在 SQLite 下返回全表,在 PostgreSQL 下行为未定义。
-
Current *int:允许你在 Scope 内判断if p.Current == nil || *p.Current ,然后安全赋默认值 1 -
Size *int:同理,if p.Size == nil || *p.Size 100,再截断为 10 -
Total *int64:必须是指针,否则无法在 Scope 中写入计算结果
Order 不显式声明,分页结果不可靠
GORM 不保证无序查询的物理顺序稳定。主从延迟、MVCC 版本快照、并发 INSERT 都会导致同一 OFFSET/LIMIT 查询两次返回不同记录——漏数据或重复。
- 错误写法:
db.Scopes(Page(p)).Find(&users) - 正确写法:
db.Order("id ASC").Scopes(Page(p)).Find(&users),且id字段必须有索引 - 业务需按时间排序?用
created_at DESC, id DESC,避免时间字段重复导致分页错位
别在分页 Scope 里混用 Preload
Preload("Orders") 在分页后执行,会先查出 10 个用户,再为这 10 个用户发一条 SELECT ... WHERE user_id IN (1,2,...,10) —— 看似合理,但实际加载的订单远超 10 条,且无法控制“每个用户只取最新 3 个”。
- 高频场景应拆成两步:先
Find用户 ID 列表,再用IN批量查关联数据 - 若必须单次完成,改用
Joins+Group By+ 窗口函数(如ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)),但需数据库支持 - 游标分页(
WHERE id > ?)天然不兼容 Preload,这点容易被忽略
最简分页封装的复杂点不在语法,而在状态隔离和边界防御——Count 和 Find 必须用彼此无关的 DB 实例,Order 字段必须可索引且无歧义,Preload 和分页永远是互斥选择。











