gorm中limit+offset分页易漏数据、重复且性能差,因数据库需扫描丢弃前n行;count()复用带limit/offset的db实例导致总数恒≤limit值,须用db.session(&gorm.session{newdb: true})新建实例独立查询。

直接用 Limit + Offset 做分页,配合动态 Where 筛选,在 GORM 中能跑通,但线上一压就出错——漏数据、重复、慢到超时。这不是配置问题,是 SQL 模型和并发写入下的必然结果。
为什么 Count(&total) 总是比实际少?
因为 Count() 复用了带 Limit/Offset 的链式 DB 实例。GORM 不会自动清掉这些限制条件,db.Where("status = ?", "active").Limit(20).Offset(100).Count(&total) 实际查的是“跳过 100 条后最多取 20 条里的总数”,结果恒 ≤ 20。
- 必须新建一个干净的
*gorm.DB实例做总数查询,例如db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Count(&total) - 如果用了
Joins或Preload,Count()可能因关联表没加Distinct而虚高;此时应手写子查询:db.Raw("SELECT COUNT(*) FROM (SELECT DISTINCT u.id FROM users u JOIN orders o ON u.id = o.user_id WHERE ...) t").Scan(&total) - 别在同一个事务里先
Count再Find:两次查询之间若有新记录插入,总数和当前页数据就对不上
筛选条件拼接时 Where 链容易污染
多个 if 分支反复调 client.Where(...),看似自然,但一旦某个分支没走,上一次的 Where 条件仍残留。尤其在复用 client 变量做不同接口时,极易跨请求泄漏条件。
- 每次分页查询前,用
db.Session(&gorm.Session{NewDB: true})重新初始化查询实例,不复用外部变量 - 避免在方法参数里传入已链式调用过的
*gorm.DB,改传原始*gorm.DB或func(*gorm.DB) *gorm.DB闭包 - 像
ProductName这类模糊查询,要用Like而非=,且注意 SQL 注入风险:client.Where("name LIKE ?", "%"+param.ProductName+"%"),别用fmt.Sprintf拼字符串
Offset 分页在 page > 50 时开始不可靠
不是代码写错了,是 MySQL/PostgreSQL 的物理扫描机制决定的:OFFSET 10000 表示数据库必须逐行扫描并丢弃前 10000 行,哪怕只取 20 条。当有并发 INSERT/DELETE 时,同一页可能漏掉刚插入的记录,或重复出现已被删除的旧记录。
- 排序字段必须有索引,且
Order()必须显式写出,例如Order("created_at DESC, id DESC")—— 单用created_at不够,时间相同会导致顺序不确定 - page 参数校验不能只做
if p.Page ,还要硬限制最大页码,比如 <code>if p.Page > 200 { p.Page = 200 },防恶意刷 - 真正稳定的做法是切游标分页:前端传
last_id或cursor=2024-01-01T12:00:00Z:12345,SQL 改为WHERE id > ? ORDER BY id LIMIT 20
分页参数解析必须防御性处理
前端 URL 里 page=abc 或 page_size=0 是常态,Gin 的 ShouldBindQuery 会返回 error,但很多人忽略它,直接 fallback 到默认值,导致非法输入静默通过。
- 用
r.URL.Query().Get("page")手动取值,空字符串或非数字时明确返回 400,不兜底 -
page_size必须硬限制上限(如 100),超过即截断或报错,绝不能透传给Limit() - 别信
PageNum int结构体标签自动转整数——空值、负数、超大数都会让strconv.Atoipanic,要提前判空再转
最常被忽略的一点:游标分页不是“性能优化选项”,而是数据一致性底线。只要业务允许用户翻到第 100 页以上,或者数据每分钟都在写入,就必须放弃 Offset。GORM 不提供银弹,它只暴露 SQL 的真实成本。











