findinbatches是gorm处理百万级导出唯一可靠的内存分片方案,因其基于主键游标分批查询,避免oom和数据库连接耗尽;而find+for-range会一次性加载全部数据到内存,极易触发内存溢出、连接池阻塞及超时失败。

FindInBatches 是 GORM 处理百万级导出时唯一靠谱的内存分片入口,不用它,基本等于主动申请 OOM。
为什么不能用 Find + for-range 一次性加载
很多人写导出逻辑时直接 db.Find(&results),再遍历 results 写 Excel 或 CSV。这在数据量超 10 万后就极危险:GORM 会把全部记录反序列化为 Go struct 实例,每条记录哪怕只有 5 个字段,也至少占用几百字节;百万条轻松吃掉 500MB+ 堆内存。更糟的是,数据库连接会被长时间独占,其他请求可能卡在 waiting for connection 状态。
常见错误现象:runtime: out of memory、context deadline exceeded(因超时被中间件 kill)、PostgreSQL 报 too many clients already。
- 即使加了
LIMIT,没配合ORDER BY id,后续批次无法定位起始位置 - 没设
batchSize,默认是 1000,对大字段日志表仍可能单批压垮内存 - 回调函数里做了非流式操作(比如拼接大字符串、往 map 里塞全量 ID),抵消分片效果
FindInBatches 必须显式指定 ORDER BY
GORM 的 FindInBatches 默认按主键排序,但仅限你没手动写 Order() 时才生效。一旦你写了 Order("created_at DESC"),它就不再自动补 ORDER BY id,而游标机制依赖单调递增/递减的排序字段来推进 —— 否则下一批可能漏数据或重复。
正确写法必须带确定性排序:
err := db.Order("id ASC").FindInBatches(&users, 5000, func(tx *gorm.DB, batch int) error {
// 处理这批 users
return nil
})
- 排序字段必须有索引,否则
WHERE id > ?查询会变全表扫描 - 避免用
ORDER BY RAND()或表达式(如ORDER BY ABS(id)),游标无法推算边界 - 如果业务强依赖时间顺序导出,且
created_at有重复值,得组合索引(created_at, id)并显式Order("created_at ASC, id ASC")
batchSize 不是越大越好,5000 是多数场景的甜点值
设成 100 太碎,网络和事务开销高;设成 50000 可能单批内存峰值翻倍。实测 MySQL + JSON 字段日志表,batchSize=5000 时单 goroutine 峰值内存约 80MB,GC 压力可控;拉到 20000 后,GC 频次上升 3 倍,P99 导出延迟跳升 40%。
- 字段越宽(比如含
TEXT、JSON),batchSize 应越小,建议从 1000 起调 - 如果导出要写磁盘文件,batchSize 最好是文件系统块大小(如 4KB)的整数倍,减少 write() 系统调用次数
- 别在回调里启动新 goroutine 处理当前批次 ——
FindInBatches本身不并发,自己并发反而容易打爆 DB 连接池
导出链路中真正容易崩的不是查询,而是写入缓冲区
查出来只是第一步。用 github.com/xuri/excelize/v2 或 go-excel 直接 SetRow 百万行,内存照样爆 —— 它们内部缓存未 flush 的 sheet 数据。必须搭配流式写入策略:
- 每处理完一批,立刻调用
f.WriteToBuffer或f.SaveAs到临时文件,再清空该批次内存 - 避免把所有批次结果 accumulate 到一个
[]map[string]interface{}里再统一写 - HTTP 响应导出时,用
io.Pipe边查边写,响应 Header 里加Content-Transfer-Encoding: binary,防止代理截断
最常被忽略的一点:FindInBatches 回调里的 tx 是带事务上下文的,如果你在回调里开了新 DB 连接或用了不同 *gorm.DB 实例,游标状态不会传递,下一批可能从头查起。











