GORM分页必须手写Limit+Offset或游标分页;需校验page≥1、pageSize∈(0,100],显式Order排序,总数查询须独立db实例,大偏移量应切游标分页。

分页SQL在GORM中如何生成
GORM 的 Limit 和 Offset 不是语法糖,而是直接映射到 SQL 的 LIMIT 和 OFFSET 子句。它不做重写、不自动加子查询,也不尝试用窗口函数替代——你看到的 SQL 就是你得到的 SQL。
比如执行:db.Offset(20).Limit(10).Order("id ASC").Find(&users),在 PostgreSQL 上生成的就是:
SELECT * FROM "users" ORDER BY "id" ASC LIMIT 10 OFFSET 20
而在 MySQL 8.0+ 也一样;但注意:MySQL 5.7 及更早版本不支持 OFFSET 超过 max_join_size(默认 4M 行),超限时会报错 ERROR 1221 (HY000): Incorrect usage of LIMIT and OFFSET。
- SQLite 会把
LIMIT 0解释为“不限制”,不是空结果集,务必校验pageSize > 0 - SQL Server 驱动(
gorm.io/driver/sqlserver)会把Offset().Limit()翻译成OFFSET ? ROWS FETCH NEXT ? ROWS ONLY,这是标准 T-SQL 分页语法 - 如果你用的是自定义驱动(比如对接 TiDB 或 CockroachDB),只要它实现了
gorm.Dialector接口,Limit/Offset就仍走同一套逻辑,不会额外判断方言
修改分页行为必须重写 Dialector 的 BuildClause 方法
想让 GORM 对所有 Limit/Offset 查询自动加上 FOR UPDATE,或强制追加 WHERE deleted_at IS NULL,不能靠中间件或 Scope——因为这些只影响 Go 层链式调用,不触达 SQL 构建阶段。
真正生效的位置是 Dialector 的 BuildClause 方法。例如,要让 PostgreSQL 驱动在每次分页时都加 WITH TIES(当排序字段有重复值时保留并列项),得这样改:
type MyPostgres struct {
gorm.Dialector
}
<p>func (d MyPostgres) BuildClause(clause clause.Interface, stmt <em>gorm.Statement) {
if limit, ok := clause.(</em>clause.Limit); ok && stmt.Limit != nil {
// 在 LIMIT 后注入 WITH TIES(仅 PostgreSQL 支持)
stmt.AddVar(stmt, "WITH TIES")
}
d.Dialector.BuildClause(clause, stmt)
}但这只是示意——实际中 BuildClause 是内部调用链,你无法直接 patch 原生驱动。可行做法是:
- 用
gorm.Open时传入自定义Dialector实例,而非直接用postgres.Open(...) - 继承原驱动结构体,覆盖
BindVars或Explain等方法来干预 SQL 输出(风险高,需同步跟踪 GORM 主干变更) - 更稳妥的替代:在 DAO 层统一 wrap 查询,用
Session(&gorm.Session{PrepareStmt: true})配合Scopes注入公共条件,而非动底层
为什么不要在自定义驱动里重写 Offset 的语义
有人试图让 Offset(n) 在内部变成游标分页(如 WHERE id > ?),这是危险操作。GORM 的 Offset 是公开 API,上层业务代码可能依赖其语义做分页计算、前端页码渲染、甚至测试断言。一旦你在驱动层偷偷替换,会导致:
- 同一个
Offset(1000)在不同环境(dev/staging/prod)生成完全不同的 SQL,排查困难 -
Count()查询仍走原始OFFSET,总数和列表数据对不上 - Preload 关联查询的嵌套分页逻辑彻底失效(因为 Preload 是独立 query,不共享 offset)
- GORM v2.2.5+ 对
Offset做了 panic guard,传负数直接崩溃,你若绕过校验,等于主动引入 crash 点
游标分页该由业务层显式选择,比如提供 cursor string 参数,再解析成 WHERE id > ? 条件——而不是藏在驱动里让所有人意外中招。
第三方分页插件如何干扰 SQL 拼装
像 github.com/boz/go-paginator 或老版 github.com/gookit/goutil/pagination 这类库,它们不是驱动层修改,而是在 Find 前动态插入 Limit/Offset 调用。问题在于:
- 它们通常用
stmt.Clauses直接塞进clause.Limit,但 GORM v2.3+ 对 Clause 冲突做了 stricter check,如果已有Limit,新插入会 panic - 某些插件在调用
Count()时没清除已有的Limit,导致 count 结果永远 ≤ pageSize(见db.Limit(10).Count(&n)返回 10 而非全表数) - 它们往往忽略
Order是否存在,直接拼OFFSET,结果就是无序分页——数据库返回顺序不可控,翻页漏数据
最易被忽略的一点:Paginate 方法返回的 PageInfo 里 Total 字段,几乎全是先查一次 COUNT(*),再查数据。但如果你的主查询含 JOIN 或复杂子查询,COUNT 可能比主查询还慢,且无法利用索引覆盖。这时候,驱动层改 SQL 拼装毫无意义,瓶颈根本不在语法表达上。



















