结论:用 FindInBatches,别用 Limit+Offset 做大数据导出;因后者需全表扫描丢弃前 Offset 行,导致 CPU 飙高、响应变慢、OOM,而 FindInBatches 基于主键游标分批,高效可控但需注意不支持 Preload、Distinct/GroupBy 及事务内慎用。

直接说结论:用 FindInBatches,别用 Limit+Offset 做大数据导出。 否则导出几百万行时,数据库 CPU 会飙高、响应变慢,服务内存也可能 OOM——这不是理论风险,是线上真实挂过好几次的坑。
为什么 Limit+Offset 在导出场景下会崩
导出不是“查第 N 页”,而是“扫全表”。当 Offset 超过 10 万,MySQL 就得扫描并丢弃前面所有行,哪怕你只想要最后 100 条。实际执行计划里会出现 Using filesort 和大量 rows_examined,DBA 看到都会皱眉。
- 每翻一页,扫描量线性增长,第 10000 页 ≈ 扫描 999900 行
- 事务持续时间拉长,容易触发锁等待或
Lock wait timeout exceeded - Go 进程内存不释放:每次
Find(&users)都新建切片,旧批次没被 GC 及时回收,OOM 往往发生在第 30–50 批
FindInBatches 的正确调用姿势
它不是“自动分页”,而是“游标式批处理”——必须依赖主键(默认 id)有序推进,且不能带非确定性排序(比如 ORDER BY RAND())。
- 必须显式指定
ORDER BY id,否则 GORM 会自动补,但补得是否符合你预期?不如自己写清楚 - 回调函数里处理完一批数据后,要主动清空切片(
users = users[:0]),避免底层数组被意外复用 - 批次大小建议设为 1000–5000:太小 → 查询次数多、网络开销大;太大 → 单次内存压力高、GC 延迟明显
示例:
var users []User
err := db.Order("id").FindInBatches(&users, 2000, func(tx *gorm.DB, batch int) error {
// 处理当前批次,例如写 CSV
for _, u := range users {
writeCSVRow(u)
}
users = users[:0] // 关键:重置切片长度,释放引用
return nil
})
容易被忽略的三个硬限制
FindInBatches 看似简单,但有三处不写文档、一踩就错:
- 不支持
Preload关联查询:它内部是按 ID 游标重发 SQL,Preload会破坏这个逻辑,报invalid memory address或静默漏数据 - 不能和
Distinct、GroupBy混用:游标依赖单列单调递增,聚合后 ID 不再连续可推演 - 事务内慎用:如果外层开了事务,
FindInBatches的每个批次都在同一事务里,长事务易导致 binlog 膨胀、主从延迟,建议导出前先db.Session(&gorm.Session{NewDB: true})新建无事务句柄
真正难的不是写对语法,而是想清楚“这批数据是否允许跳过中间某条”——比如导出时上游正在写入,FindInBatches 基于 ID 推进,可能漏掉刚好插入在两个批次间隙的记录。这种一致性边界,得结合业务容忍度来定,不是 ORM 能替你决定的。


















