Count查询必须与分页查询完全隔离,因GORM的Count()默认复用Limit/Offset,导致结果错误;正确做法是拆分独立查询或使用db.Session(&gorm.Session{NewDB: true})隔离,复杂JOIN场景应手写子查询,大数据量优先采用游标分页。

Count查询必须和分页查询完全隔离
GORM 的 Count() 方法默认复用链式调用中已设置的 Limit() 和 Offset(),所以 db.Where("status = ?", "active").Limit(10).Offset(20).Count(&total) 返回的 total 永远 ≤ 10。这不是 bug,是 GORM v2 的行为设计。
正确做法是拆出独立的查询实例:
- 先构建条件:
query := db.Where("status = ?", "active") - 再分别调用:
query.Count(&total)和query.Limit(size).Offset(offset).Find(&list) - 或者用新 session 隔离:
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Count(&total)
复杂 JOIN 场景下别信 Count(),手写子查询更稳
当分页主查询含 JOIN、GROUP BY 或子查询时,GORM 的 Count() 极易生成语义错误的 COUNT SQL(比如对 JOIN 后结果去重计数,或漏掉 WHERE 条件)。
直接手写子查询控制力更强:
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE u.status = ?) AS t", "active").Scan(&total)
注意点:
-
SELECT 1是惯用写法,避免字段膨胀,也绕过模型字段映射问题 - 子查询外层必须有别名(
AS t),否则 PostgreSQL 会报错 - MySQL 8.0+ 支持 CTE,但跨数据库兼容性不如内联子查询
大数据量下优先放弃 total,改用 has_next 判断
当表行数超 50 万,COUNT(*) 本身就会成为瓶颈——MySQL 要全表扫描或依赖索引统计(不精确),PostgreSQL 的 reltuples 也不实时更新。
更务实的做法是查 size + 1 条,靠长度判断是否还有下一页:
- 执行
query.Limit(size + 1).Offset(offset).Find(&list) - 若
len(list) > size,则has_next = true,且只返回前size条给前端 - 前端用游标(如最后一条的
id)发起下一页请求,后端转成WHERE id > ? ORDER BY id - 这个模式天然规避了
COUNT和OFFSET双重性能陷阱
游标分页不是“高级技巧”,而是大数据量的默认选项
一旦单表数据超过 10 万行,OFFSET 就不该出现在生产 SQL 中。MySQL 执行 OFFSET 50000 时,仍需定位并丢弃前 5 万行,I/O 和 CPU 开销线性增长。
游标分页的核心是把“第 N 页”转化为“从某条记录之后取 N 条”:
- 排序字段必须有索引,首选主键
id ASC;时间字段需补二级排序,如created_at DESC, id DESC - 前端传入上一页最后一条的
cursor(比如"id=12345"),后端解析为WHERE id > 12345 - GORM 写法:
db.Where("id > ?", lastID).Order("id ASC").Limit(size).Find(&list) - 注意:不能混用
OFFSET和游标,二者逻辑冲突;也别用ORDER BY RAND(),它无法支持游标
真正容易被忽略的是排序字段的稳定性——哪怕加了 ORDER BY created_at DESC,高并发写入下毫秒级重复极常见,漏数据或重复的根源往往就在这里。











