分块查询需谨慎使用,否则导致内存飙升、漏数据或死循环;核心在于避免eloquent模型缓存、确保主键递增且有序、优先用db::table()->cursor()等轻量方式。

分块查询不是“用了就省内存”,用错反而让内存越查越高、漏数据、卡死。核心问题在于:Laravel 默认把每一批查出来的模型实例全塞进 Model::$instances 静态数组里,不手动清,PHP GC 清不掉;chunk() 用偏移量分页,数据有增删就会跳行或重复;chunkById() 要求主键严格递增,否则可能死循环。
chunk() 为什么内存不降反升
它只是把 get() 拆成多次执行,但每次返回的仍是完整 Eloquent 模型对象,且全部缓存在静态属性中:
- 每批回调里的
$users是User模型集合,每个实例都带访问器、关系、事件监听器 - 即使你没显式保存变量,
User::$instances仍持有全部引用,内存持续累积 - 加了
->withTrashed()或->withoutGlobalScopes()后更严重,因为构造逻辑变复杂,缓存更难释放
解决办法不是调大 chunk 尺寸,而是绕过模型层:DB::table('users')->select('id', 'name', 'email')->chunk(500, function ($rows) { ... }) —— 这样返回的是原生数组,无实例开销。
chunkById() 必须满足的三个前提
它靠 WHERE id > ? 推进,比 chunk() 稳定,但极易踩坑:
- 主键必须是自增整数(
INT或BIGINT),不能是 UUID、字符串或复合主键 - 表里不能有
id为NULL的行,否则WHERE id > last_id会漏掉 - 处理过程中不能有新记录插入到当前批次范围中间(比如并发写入 id=1005,而上一批停在 1000),否则会跳过
安全用法示例:User::where('status', 'active')->orderBy('id')->chunkById(1000, function ($users) { ... }, 'id')。注意必须显式写 orderBy('id'),否则 Laravel 不保证顺序。
真正低内存的分块:DB::table + cursor() 或 pluck()
如果你只读字段、不做模型操作(比如导出 CSV、统计积分、同步 ID 列表),别碰 Eloquent:
-
DB::table('users')->cursor()返回可遍历游标,逐行取,内存恒定 ≈ 1KB,适合百万级 -
DB::table('users')->pluck('id')只取单列值,返回Collection,比get()轻 80% 以上 - 需要关联字段?用
leftJoin+selectRaw拼好再cursor(),别with()
示例:DB::table('users')->leftJoin('profiles', 'users.id', '=', 'profiles.user_id')->select('users.id', 'users.email', 'profiles.bio')->cursor() —— 关联结果直接流式吐出,不建模型。
漏数据和死循环的隐蔽原因
这不是 bug,是设计使然:
-
chunk(100)底层是OFFSET 0 LIMIT 100→OFFSET 100 LIMIT 100,若第 1 批处理中有人删了前 50 条,第 2 批会从原第 150 条开始,中间 50 条永远不被处理 -
chunkById()在非自增主键上运行,比如uuid字段,WHERE uuid > 'abc'无序,下一批可能反复查同一段 - 没加索引的
ORDER BY created_at分块,MySQL 会全表扫描排序,慢到像卡死
关键点:只要涉及更新/删除操作,必须用 chunkById() + 主键索引;只要只读且字段固定,优先 DB::table()->cursor();任何场景下,别信“分块就自动省内存”——得看它返回的是数组还是模型实例。











