分页必须用确定性组合排序,如order("created_at desc, id desc");分页参数需手动校验并限制范围;scopes应拆分为sortscope、paginatescope和countscope独立使用;总数查询须隔离上下文或手写子查询。

Order 必须写成确定性组合,单字段排序不顶用
只写 Order("created_at DESC") 看似合理,但高并发下毫秒级时间戳重复很常见,数据库内部排序无依据,翻页时同一条记录可能忽前忽后。MySQL 和 PostgreSQL 都不保证单字段重复值的物理顺序稳定。
正确做法是补一个唯一字段兜底:Order("created_at DESC, id DESC") 或 Order("status ASC, id ASC")。主键 id 是最稳妥的第二排序字段,它天然唯一、有索引、无歧义。
- 避免用
Order("RAND()")做分页——没法续查下一页,性能也差 - 别把
Order("updated_at DESC")当默认项,软删除场景下它和created_at可能错位 - 如果业务真要按非唯一字段排序(比如
score),必须加id或其他唯一列收尾,否则漏数据不是概率问题,是必然
分页参数校验不能靠 gin.ShouldBindQuery 自动兜底
c.ShouldBindQuery(&p) 对 int 字段传入非数字字符串(如 page=abc)会直接返回 error,但它不会拦住 page=-1 或 page_size=0 这类合法 int 但语义错误的值。GORM 拿到负数 Offset 或零 Limit 会 panic 或生成非法 SQL。
实操建议:
- 手动判空再转:
pageStr := c.Query("page"); if pageStr == "" { page = 1 } else { page, _ = strconv.Atoi(pageStr) } -
page小于 1 时强制设为 1,不接受 “第 0 页” 或负数页 -
page_size必须硬限制,比如pageSize := min(p.PageSize, 100),超限就截断,别让前端决定 DB 负载
Scopes 封装排序+分页逻辑时,Order 和 Limit/Offset 不能混在同一个 Scope 里
把 Order 和 Limit/Offset 塞进同一个 Scope 看似省事,但会破坏链式调用的可组合性。比如你写了 PageScope(2, 20) 内部调用了 db.Offset(20).Limit(20),再叠加 StatusScope("active"),结果是先分页再过滤,逻辑全乱。
更合理的拆分方式:
- 单独一个
SortScope(sortBy string, desc bool),只管Order,支持多字段拼接(如"created_at,id") - 另一个
PaginateScope(page, size int),只算Offset和Limit,且内部做maxSize截断 - 总数查询用独立
CountScope(),确保它不带任何 Limit/Offset
这样调用才可控:db.Scopes(StatusScope("active")).Scopes(SortScope("created_at,id", true)).Scopes(PaginateScope(p.Page, p.Size))。
总数查询 Count 必须隔离上下文,别信链式复用
db.Where("status = ?", "active").Limit(20).Offset(40).Count(&total) 查出来的 total 永远 ≤ 20,因为 GORM v2 的 Count 默认复用前面链上的 Limit 和 Offset。这不是 bug,是设计如此。
两个靠谱解法:
- 用新 session 隔离:
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", "active").Count(&total) - 复杂查询(含 JOIN 或子查询)直接手写子查询:
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u WHERE u.status = ?) t", "active").Scan(&total)
游标分页场景下,总数本身就没意义,这时候干脆别查 Count —— 用户要的是“下一页有没有”,不是“一共有多少页”。











