gorm无内置paginate,必须手动组合limit和offset:offset=(page-1)*page_size且page≥1、page_size>0需校验截断;总数查询须独立执行;必须显式order排序,大数据量应改用游标分页。

GORM 没有内置 Paginate 方法,所谓“通用分页”必须手动组合 Limit 和 Offset,并严格校验参数、隔离总数查询、强制排序——否则线上一跑就漏数据或查崩库。
怎么算 Offset 和 Limit 才不出错
Offset 不是“第几页”,而是“跳过多少条”。第 3 页、每页 20 条,得 Offset(40),不是 Offset(3)。公式固定为 (page - 1) * page_size,且 page 必须 ≥ 1,page_size 必须 > 0。
-
page小于等于 0 时,直接设为 1(别返回 400,用户点错页码很常见) -
page_size超过上限(如 100)时,用min(p.PageSize, 100)截断,防止恶意构造page_size=999999 - 传入
Limit(0)在 SQLite 下会返回全表,在 MySQL 下可能 panic,务必校验非零正整数 - 不要用
c.Query("page")直接转 int,空字符串或非数字会 panic;优先用c.ShouldBindQuery(&p)配合结构体 tag 校验
为什么 COUNT 总是返回 0 或错误的数
因为 db.Where(...).Limit(n).Offset(m).Count(&total) 的 Count 会复用前面的 Limit 和 Offset,结果永远 ≤ n。总数查询必须和列表查询完全隔离。
- 用
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Count(&total)新建独立会话 - 复杂 JOIN 场景下,
Count可能因去重逻辑出错,改用子查询更稳:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE ...) AS t").Scan(&total) - 如果业务允许(如 Feed 流),可省掉
COUNT,只查page_size + 1条,靠是否多出一条来判断has_next
ORDER BY 缺失会导致分页结果漂移
没显式 Order 的分页,数据库不保证行序稳定。同一请求执行两次,可能重复或漏掉记录,尤其在有并发写入时。
- 必须加
Order("id ASC")或Order("created_at DESC, id DESC")—— 时间字段单独排序不可靠,相同时间戳的记录顺序不确定 - 排序字段必须有索引,否则
OFFSET扫描成本随偏移量线性上升,10 万偏移后响应从毫秒变秒级 - 禁止
Order("RAND()")做随机分页,它无法翻页,且每次全表扫描 - 前端传来的
sort字段要白名单校验,比如只允许["id", "name", "created_at"],防 SQL 注入或无索引字段滥用
Preload 关联 + 分页 = 数据量爆炸
Preload("Orders") 用在分页里,GORM 先查 10 个用户,再为这 10 个用户各发一条 SELECT ... WHERE user_id IN (?),实际加载的订单远超 10 条,且无法限制“每个用户只取最新 3 个”。
- 分页主查询禁用
Preload,改用 N+1 拆解:先查用户列表,再用IN批量查关联数据(WHERE user_id IN (?, ?, ?)) - 若需带关联字段分页(如用户 + 最新订单时间),用
Joins+Group+ 子查询,但要注意 MySQL 版本对窗口函数的支持 - 高并发场景下,考虑把关联数据冗余到主表(如
latest_order_at),避免 JOIN 压力传导到分页链路
最易被忽略的是:OFFSET 分页在大数据量下本质是性能反模式。当单表超百万行、偏移量过 5 万,优化重点不在 Go 层封装,而在换游标分页、加覆盖索引、或业务层接受“不显示总页数”。











