
本文详解 laravel 使用工厂批量生成百万级测试数据时出现内存耗尽问题的根本原因,并提供基于分批处理、关系预加载和原生批量插入的实战优化方案。
本文详解 laravel 使用工厂批量生成百万级测试数据时出现内存耗尽问题的根本原因,并提供基于分批处理、关系预加载和原生批量插入的实战优化方案。
在 Laravel 项目中,使用 Eloquent 工厂(Factories)进行大规模数据填充(如 seeding 百万级 Term 和关联 Question)时,开发者常遭遇“Allowed memory size exhausted”错误——即使逻辑上看似每次循环都重置变量(如 $terms),内存仍持续增长直至崩溃。根本原因并非 PHP 变量未释放,而是 Eloquent 的对象持久化机制与 N+1 查询模式叠加导致的内存与数据库双重瓶颈。
? 问题本质:1+N 查询 + 持久化对象累积
原始代码中:
$this->command->withProgressBar(array_fill(1, $count, ''), function ($item) {
$terms = Term::factory(10)->create(); // ✅ 创建 10 个 Term(触发 10 次 INSERT)
$terms->each(function ($term) {
$term->questions()->saveMany( // ❌ 对每个 Term 触发 10 次 Question INSERT(共 100 次/轮)
Question::factory(10)->make(['term_id' => $term->id])
);
});
});
- 每轮迭代创建 10 个 Term,再为每个 Term 创建 10 个 Question → 单轮即执行 110 次数据库写入(10 + 10×10);
- saveMany() 会为每个 Question 实例调用 save(),触发完整 Eloquent 生命周期(模型实例化、事件分发、属性赋值、查询构建等),所有模型对象在循环结束前驻留内存;
- withProgressBar 的闭包内无显式垃圾回收,Eloquent 模型及其关联关系(如 questions() 关系实例)持续累积,导致内存线性增长 —— 这正是 i ≈ 2000 时崩溃的直接原因。
✅ 正确解法:三阶优化策略
1️⃣ 分批控制(Batching)—— 降低单次内存压力
避免一次性创建过多模型实例,改用 chunk 或手动分块:
$count = 1_000_000;
$batchSize = 500; // 每批处理 500 个 Term(而非 10,000 轮 × 10)
for ($i = 0; $i count($batchSize)->create();
// 批量生成 Question 数据(不实例化模型)
$questions = [];
foreach ($terms as $term) {
for ($j = 0; $j $term->id,
'content' => fake()->sentence(),
'created_at' => now(),
'updated_at' => now(),
];
}
}
// 原生批量插入,绕过 Eloquent 开销
DB::table('questions')->insert($questions);
// 强制释放内存(可选但推荐)
unset($terms, $questions);
gc_collect_cycles();
}
2️⃣ 禁用模型事件与延迟加载(关键性能开关)
在 seeding 过程中关闭非必要开销:
// 顶部禁用事件监听器(防止 Model::creating 等触发) Event::fake(); // 关闭 Eloquent 的懒加载警告(避免调试开销) Relation::noConstraints(); // 若需保留部分事件,可选择性 fake 特定事件 Event::fake([SavedQuestion::class]);
3️⃣ 使用 DB::insert() 替代 saveMany()(最高性能)
saveMany() 是便利性 API,但代价是 10 倍于原生插入的内存与 CPU。对于纯数据填充场景,应优先使用:
// ✅ 推荐:构造数组后单次 insert(支持 1000+ 行)
DB::table('questions')->insert(
collect($terms)->flatMap(fn($term) =>
Question::factory(10)->make(['term_id' => $term->id])
)->map(fn($q) => $q->getAttributes())->toArray()
);
⚠️ 注意事项:
- insert() 不触发 Eloquent 事件、不调用 boot()、不处理 $casts 或 $dates,请确保数据格式合规;
- 单次 insert() 行数建议 ≤ 1000(MySQL 默认 max_allowed_packet 限制),超量需二次分块;
- 生产环境 seeding 前务必 Artisan::call('migrate:fresh') 清理并重建索引,避免插入时索引维护拖慢速度。
? 总结:从“能跑”到“高效”的关键转变
| 维度 | 原始方式 | 优化后方式 |
|---|---|---|
| 内存峰值 | 线性增长(>512MB) | 恒定低水位( |
| 执行时间 | 小时级(N+1 + ORM 开销) | 分钟级(批量 SQL + 内存复用) |
| 可维护性 | 逻辑清晰但性能隐蔽 | 显式控制批次、事务与资源释放 |
最终建议采用 “分批创建主表 → 批量构造子表数组 → 原生批量插入” 的三段式模式,并配合 gc_collect_cycles() 与 Event::fake(),即可安全、稳定、高效地完成百万级数据填充任务。










