GORM 官方无 Paginate 方法,所有同名封装均为第三方或自研实现;v2.2.0+ 后 Scope 行为变更易致 Count 与 Find 条件污染,引发 Total 少算、Find 查空或 panic。

为什么不能直接用 Paginate 方法
GORM 官方从未提供 Paginate 这个方法,所有带这个名字的封装都是第三方或自研实现。v2.2.0+ 后 GORM 的 Scope 行为有调整,老版本插件(如早期 github.com/guregu/dynamo 风格分页)容易在 Count 和 Find 之间污染条件,导致 Total 少算、Find 查空或 panic。
- 常见错误现象:
db.Scopes(Page(p)).Find(&list)返回空切片,但去掉Page(p)就能查出数据 - 根本原因:同一个
*gorm.DB实例被多次链式调用修改内部状态,Count()可能悄悄加了LIMIT或漏掉WHERE - 别信“自动计算 Offset”——它只是帮你算
(page-1)*size,但不校验page和size是否合法
Page 结构体里哪些字段必须是指针
Current 和 Size 必须是 *int,Total 必须是 *int64。Gin 的 ShouldBindQuery 对非指针字段绑定失败时会静默设为零值:0 传给 Offset 会导致负偏移,Limit(0) 在 SQLite 中可能返回全表,在 MySQL 中则被忽略——两种都危险。
-
Current *int:允许你在Scope内判断if p.Current == nil || *p.Current <= 0 { *p.Current = 1 } -
Size *int:同理做上限截断,例如if p.Size == nil || *p.Size > 100 { *p.Size = 100 } -
Total *int64:它是输出字段,Scope要写入它;且不能和Current/Size混在一个结构体里直接绑定 query,否则前端传?page=1&size=20&total=123会把total当输入参数解析
如何安全地在 Scope 里查总数
总数查询必须独立于主查询执行,且不能复用当前 db 链。最稳妥的方式是显式用 db.Model(&T{}).Where(...).Count(&total),而不是 newDB.Count(&total) —— 因为后者仍依赖 newDB.Statement 的上下文,一旦前面 Scopes 加了 Joins 或 Group,Count 就会错。
- 正确写法:
db.Model(&User{}).Where(db.Statement.Clauses).Count(&total)(复制当前条件) - 更推荐:在
PageScope 内新建干净实例:countDB := db.Session(&gorm.Session{NewDB: true}),再countDB.Model(&User{}).Where(...).Count(&total) - 千万别在
Scope外提前调db.Count(),那会污染后续Find的ORDER BY和SELECT字段
为什么 Order 必须显式声明且放最前
数据库不保证无序查询的行顺序稳定。尤其在主从复制、MVCC 或并发插入场景下,同一条 OFFSET/LIMIT 查询两次可能返回不同记录——不是 GORM bug,是 SQL 标准行为。GORM v2 不会自动补 ORDER BY,你漏写了,就真没排序。
- 错误写法:
db.Scopes(Page(p)).Find(&users)(无 Order) - 正确写法:
db.Order("id ASC").Scopes(Page(p)).Find(&users),且id字段必须有索引 - 按时间排序要小心:
created_at在高并发下可能重复,建议组合created_at, id,例如Order("created_at DESC, id DESC") - 如果用了多个 Scopes(如
StatusScope+DateRangeScope),Order必须在它们之前调用,否则可能被覆盖
真正难的不是写分页逻辑,而是让 Count 和 Find 用完全一致的 WHERE 条件、完全隔离的执行上下文、且排序字段在任何数据变更下都不漂移。这些细节一漏,线上就出现“第 3 页少一条”或者“翻页后数据重复”这类难以复现的问题。


















