GORM无原生Paginate方法,应手写Limit+Offset分页:需校验page≥1、pageSize∈[1,100],显式Order排序,总数查询须用独立db.Session隔离,大偏移量应切游标分页。

GORM 没有 Paginate 方法,硬套第三方封装反而容易在升级后静默出错。直接用 Limit + Offset 最稳,但必须校验参数、显式排序、隔离总数查询——漏一个就可能线上丢数据或慢查询爆炸。
分页参数怎么安全解析和校验
前端传的 page 和 page_size 不能直接进 SQL。空字符串、负数、超大值(比如 page_size=999999)会触发 panic 或拖垮数据库。
-
page小于等于 0 时强制设为 1,别返回错误——用户点错页码不是 bug,是操作习惯 -
page_size必须硬限制范围,常见做法是截断到 1–100 区间,例如pageSize = clamp(p.PageSize, 1, 100) - 用
c.ShouldBindQuery(&p)绑定结构体比手动c.Query()更可靠,能统一拦截非法类型(如page=abc) - 解析前先判空:
if p.PageNum == 0 { p.PageNum = 1 },避免strconv.Atoi遇空字符串 panic
Limit 和 Offset 的顺序与计算逻辑
Offset 表示跳过多少条,不是“第几页”。第 3 页、每页 20 条,要跳过前 40 条,即 Offset(40),不是 Offset(3)。
- 计算公式固定为:
Offset((page - 1) * pageSize),page从 1 开始,这是语义共识 -
Offset必须在Limit前调用(虽然 GORM v2 中顺序可互换,但固定写法更利于协作和降级兼容) -
Limit(0)在 SQLite 下可能返回全表,在 MySQL 下报错,务必确保pageSize > 0 - 别把
pageSize直接透传给Limit,先和配置上限取min,例如limit := min(p.PageSize, config.MaxPageSize)
为什么 Order 是强制项,且不能只用 created_at
没有 Order 的分页结果不可预测:并发写入时同一页可能漏记录、重复出现,这不是 GORM 的问题,而是 SQL 标准行为。
- 必须显式写
Order("id ASC")或Order("created_at DESC, id DESC"),二级排序防时间重复 - 只写
Order("created_at DESC")很危险——多个记录时间相同时,数据库返回顺序不保证一致 - 排序字段必须有索引,否则分页性能随偏移量线性下降;联合查询时,
WHERE条件字段也应纳入索引覆盖 - 别用
Order("RAND()")做随机分页,无法翻页且性能极差
Count 总数查询为什么总不准
db.Where(...).Limit(...).Offset(...).Count(&total) 返回的永远 ≤ Limit 值,因为 Count 复用了前面链上的 Limit 和 Offset ——这是最常被忽略的坑。
- 简单场景用
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Count(&total)隔离上下文 - 带
Joins或复杂Preload时,手写子查询更可靠:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN ... WHERE ...) t").Scan(&total) - 如果业务允许(如信息流无限滚动),可省掉
COUNT,改查pageSize + 1条,用是否存在下一页来判断has_next - 别在同一个事务里反复执行
Count和Find,两次查询之间若数据变更,总数和当前页内容可能不一致
OFFSET 分页在偏移量超 10 万后必然变慢,这不是 GORM 能优化的,得切游标分页;而游标分页本身又依赖稳定排序和索引设计——所以真正关键的不是怎么写 Limit,而是你怎么建索引、怎么定义“下一页”的边界条件。


















