分页Scope必须放在最后,因GORM Scope链式调用会修改db状态;Paginate若前置,后续Scope可能覆盖Offset/Limit或导致Count误算、panic;所有前置Scope应仅做无副作用操作。

Scopes组合多个条件时,分页Scope必须放在最后
因为GORM的Scope是链式调用,每个Scope都会修改当前*gorm.DB实例的状态。如果Paginate Scope写在前面,后续的StatusScope或DateRangeScope可能覆盖掉Offset/Limit,甚至让Count()误算(比如漏掉WHERE条件)。更危险的是,某些Scope内部调用了Joins或Group,会直接让Count()返回错误结果或panic。
正确顺序示例:
db.Scopes(StatusScope(1)).Scopes(DateRangeScope(start, end)).Scopes(Paginate(p)).Find(&list)
错误顺序(Paginate在前):
db.Scopes(Paginate(p)).Scopes(StatusScope(1)).Find(&list) // Offset/Limit可能被StatusScope干扰
- 所有前置Scope应只做
Where、Order等无副作用操作 - 避免在非分页Scope里调用
Count或First——它们会污染db状态 - 如果必须动态插入分页逻辑,用
db.Session(&gorm.Session{NewDB: true})新建干净实例再调用Paginate
Page结构体里Current和Size必须是*int
Gin的ShouldBindQuery对非指针字段绑定失败时会静默设为零值:page=0 → p.Current = 0 → Offset(-size) → GORM panic;size=0 → Limit(0) → 多数驱动返回全表数据。
用指针才能区分“未传参”和“传了非法值”:
type Page struct {
Current *int `form:"page"`
Size *int `form:"size"`
Total *int64 `json:"total"` // 输出字段,不参与绑定
}
- 在
PaginateScope内判断:if p.Current == nil || *p.Current - 同理处理
Size:if p.Size == nil || *p.Size -
Total必须是*int64,否则无法在Scope中写入;且不能加form标签,否则前端传?total=100会被解析
Count查询不能复用同一个*gorm.DB实例
常见错误是这样写:
func Paginate(p *Page) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
var total int64
db.Count(&total) // ❌ 错!复用db会导致WHERE/ORDER污染Find
return db.Offset(((*p.Current - 1) * *p.Size)).Limit(*p.Size)
}
}
此时Count()可能偷偷加了GROUP BY或丢掉ORDER,导致Find()查不到数据或总数不准。
- 安全做法:用
db.Model(&T{}).Where(...).Count(&total),显式指定模型和条件 - 或者新建干净实例:
db.Session(&gorm.Session{NewDB: true}).Model(&T{}).Count(&total) - 别依赖
db.Unscoped().Count()——它会绕过软删除,但不该影响分页总数逻辑
Order必须显式声明,且排序字段要有索引
没Order的分页不是bug,是数据库行为:MySQL/PostgreSQL不保证无序查询的行序稳定。主从延迟、MVCC快照、并发INSERT都可能导致同一页翻两次结果不同——漏数据或重复。
正确写法:
db.Order("id ASC").Scopes(Paginate(p)).Find(&users)
-
id必须有主键或唯一索引,否则ORDER BY id性能差 - 按时间分页用
created_at要小心:高并发下可能重复,建议组合created_at, id - 别用
ORDER BY RAND()分页——每次查询都全表扫描,不可行
分页本身不难,难的是在微服务多层封装、多Scope组合、多协程并发场景下,保持Count和Find的条件完全一致。最容易被忽略的,是那个看似无关紧要的Order——它不报错,但会让分页在生产环境悄悄出错。


















