正确做法是封装成可复用的 scope 函数:func paginate(page, pagesize int) func(db gorm.db) gorm.db { if page

直接用 Limit 和 Offset 能跑通,但线上一压就慢、一翻页就卡、一传非法参数就 panic——这不是 GORM 不行,是你没绕开它的默认分页陷阱。
别在 handler 里手写 Offset((page-1)*size)
这是最常见也最危险的写法。看似简洁,实则埋了三颗雷:
-
page=0或page=-1会导致Offset(-1),GORM 不报错但 SQL 执行异常(MySQL 返回空或报错) - 前端乱传
page=9999999,OFFSET超大后 MySQL 全表扫描,CPU 直接拉满 - 每次都要算
(page-1)*size,重复逻辑散落在多个 handler,改一处漏十处
正确做法是封装成可复用的 scope 函数:
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
if page 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
return func(db *gorm.DB) *gorm.DB {
return db.Offset(offset).Limit(pageSize)
}
}
然后在 handler 中干净调用:
var users []User err := DB.Scopes(Paginate(p.Page, p.PageSize)).Find(&users).Error
用 ShouldBindQuery 绑定结构体,别再手动 c.Query
手写 c.Query("page") + strconv.Atoi 容易漏校验、类型错位、空值 panic。结构体绑定才是 Gin 的标准解法:
type ListReq struct {
Page int `form:"page" binding:"required,gte=1,default=1"`
PageSize int `form:"page_size" binding:"required,gte=1,lte=100,default=20"`
Keyword string `form:"keyword" binding:"max=50"`
}
关键点:
-
binding:"required,gte=1"拦住page=0或缺失参数,不靠 if 判断 -
default=1只在参数完全未传时生效;若传了page=(空字符串),会触发required校验失败 - 字段必须首字母大写,否则
ShouldBindQuery静默失败且无提示
使用方式:
var req ListReq
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid query params"})
return
}
总数查询必须和业务查询共用相同 Where 条件
很多人写成这样:
DB.Model(&User{}).Count(&total) // ❌ 没带 status=active 条件
DB.Where("status = ?", "active").Scopes(Paginate(...)).Find(&users)
结果 total 是全量数,users 却是过滤后的,前端显示「共 1000 条,当前第 1 页(20 条)」,用户翻到第 50 页直接 404。
正确姿势是复用同一个查询链:
db := DB.Where("status = ?", "active")
var total int64
db.Count(&total) // ✅ 条件一致
var users []User
db.Scopes(Paginate(req.Page, req.PageSize)).Find(&users)
注意:Count 会重置 Limit/Offset,所以放心先查总数再查数据。
大数据量时,OFFSET 分页已失效,该切游标了
当单表超百万行、或 OFFSET > 10w 时,SELECT ... LIMIT 20 OFFSET 100000 在 MySQL 中实际要扫描 100020 行,响应从 20ms 涨到 1.2s。
游标分页不是“升级选项”,而是硬性要求:
- 必须依赖严格单调字段(如
id、created_at),不能用ORDER BY name - 前端传
cursor=12345(上一页最后一条的 id),后端查WHERE id > 12345 ORDER BY id LIMIT 20 - GORM 写法:
db.Where("id > ?", cursor).Order("id ASC").Limit(20).Find(&users) - 游标分页无法跳页(不支持“跳到第 17 页”),但能稳定扛住千万级数据
真正容易被忽略的是:游标字段的索引必须存在且高效。如果用 created_at 做游标,但没给它建联合索引(比如 (status, created_at)),性能照样崩。











