在用 Laravel 开发项目时,seeders 和 factories 是生成测试数据的常用工具。但当数据量达到几十万甚至上百万条时,很多开发者会发现一个奇怪的现象:填充脚本越跑越慢,内存占用持续攀升,最终抛出 Allowed memory size of xxx bytes exhausted 的致命错误。这并不是 PHP 本身的问题,而是我们在使用 Laravel 时的一些习惯写法在大数据量场景下把内存一点点吃光了。本文将系统分析内存泄漏的根源,并给出一系列经过实战验证的优化方案。

内存到底被什么吃掉了:三个常见泄漏源头
第一个也是最容易被忽视的源头是 ORM 模型实例的堆积。很多人写填充逻辑时习惯用 User::all() 或者 User::get() 把目标表数据一次性取出来再处理。Eloquent 会为每一行记录创建一个完整的模型对象,每个对象除了字段数据外,还携带关系定义、事件系统、脏数据追踪等元信息,单个对象的实际内存开销远超想象。十万行数据轻松吃掉几个 GB 的内存。
第二个源头是查询日志。Laravel 在调试模式下会把每一条执行过的 SQL 语句和绑定参数记录到内存中的查询日志里。批量插入十万次,日志里就堆积十万条 SQL 文本。更麻烦的是,如果填充过程触发了事务,事务相关的上下文信息也会被持有。可以用 DB::disableQueryLog() 提前关闭,也可以在填充前检查环境配置。
第三个源头是模型事件和观察者的连锁反应。如果模型注册了 Observer,或者启用了 cache()、queue() 之类的监听逻辑,每创建一条记录都会触发一堆回调,这些回调持有的闭包和外部引用会阻止垃圾回收器释放内存。填充纯测试数据时,这些业务逻辑完全是多余的负担。
核心优化手段:分批处理与惰性加载
解决内存问题的核心思路只有一个:任何时刻内存中只持有当前正在处理的一小块数据。Laravel 提供了几个现成的工具来实现这个思路。
最经典的是 chunkById,它按主键范围分批查询,避免了一次性加载全表。与之配合的还有 Laravel 8 之后引入的 lazy collections(惰性集合),通过 cursor() 方法让查询结果变成一个只有一条记录在内存中的流式集合:
// 错误示范:一次性加载全表,内存直接爆掉
$users = User::all(); // 十万条记录全部实例化
foreach ($users as $user) {
// 处理逻辑
}
// 正确做法一:chunkById 分批处理
User::chunkById(1000, function ($users) {
foreach ($users as $user) {
// 每次只有 1000 条在内存中
}
});
// 正确做法二:惰性集合,内存中永远只有一条记录
User::cursor()->each(function ($user) {
// 流式处理,单条占用
});
两者的区别在于适用场景。chunkById 适合需要对每批做整体操作的情况,而 cursor() 更简洁,代码写起来和普通集合没区别,但底层只维护一个 PDO 游标。需要注意的是,cursor() 处理过程中不要执行会修改结果集的操作,否则可能出现数据重复或遗漏。
插入端的提速:从逐条 insert 到批量组装
很多人填充数据时直接用 factory 的 create() 方法循环创建,每条记录一次 SQL,一百万条就是一百万次数据库往返。瓶颈不在内存而在网络和 SQL 解析开销。正确的做法是用 make() 只生成数据不落库,然后攒够一批用 DB::table()->insert() 一次性写入:
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Factories\Sequence;
// 错误示范:一百万次 insert,速度极慢
User::factory()->count(1000000)->create();
// 优化方案:make 生成 + 分批批量插入
$batchSize = 1000;
$total = 1000000;
User::factory()
->count($total)
->make()
->chunk($batchSize)
->each(function ($batch) {
// 关闭模型事件,跳过观察者逻辑
$data = $batch->map(function ($user) {
return $user->getAttributes(); // 只要纯数组,不要模型对象
})->toArray();
DB::table('users')->insert($data);
});
这里有个关键细节:getAttributes() 拿到的是纯数组,绕过了 Eloquent 的类型转换和访问器。如果字段需要 mutator 处理(比如密码加密),要么在 factory 的 definition 里就处理好,要么改用 toData() 类似方法保留转换逻辑。批量插入时还要注意单条 SQL 的体积限制,max_allowed_packet 默认值可能不够,批次大小建议控制在 500 到 2000 条之间,根据单行数据体积实测调整。
另一个容易被忽略的优化点是关闭模型事件。批量插入走 DB::table() 本身就不触发模型事件,这是它的天然优势;但如果必须用 Eloquent,可以用 withoutEvents() 包裹执行逻辑,减少回调开销。
命令行环境下的额外调优技巧
填充数据通常通过 artisan command 执行,命令行环境有几个额外的调优空间。首先是给命令本身加上更宽松的内存限制,可以在命令类里用 ini_set('memory_limit', '1024M') 临时提升,这属于兜底手段,不是根治方案,但能避免脚本在收尾阶段崩掉。
其次是善用进度条和定期强制垃圾回收。长时间运行的脚本里,PHP 的垃圾回收并不总是及时的,可以在每个批次处理完后手动调用 gc_collect_cycles(),配合进度条输出,既能观察执行状态,也能让内存曲线更平稳:
public function handle()
{
DB::disableQueryLog(); // 关闭查询日志,防止 SQL 堆积
$bar = $this->output->createProgressBar(1000);
$bar->start();
for ($i = 0; $i < 1000; $i++) {
$batch = User::factory()->count(1000)->make()
->map(fn ($u) => $u->getAttributes())
->toArray();
DB::table('users')->insert($batch);
unset($batch); // 主动释放批次变量
gc_collect_cycles(); // 强制垃圾回收
$bar->advance();
}
$bar->finish();
$this->info("\n填充完成");
}
最后提一下事务的正确用法。把几百个批次包在一个大事务里确实能提速,因为减少了磁盘刷写次数,但大事务本身会占用数据库端资源,也可能让连接长时间持有锁。实践中更稳妥的做法是每个批次一个小事务,或者干脆依赖存储引擎的批量写入优化,具体取决于你使用的数据库类型。MySQL 的 InnoDB 在小事务批量写下表现已经足够好,而 PostgreSQL 用户可以额外考虑 COPY 命令,那是另一种量级的导入速度。
方案对比与选型建议
综合来看,不同方案的取舍可以归纳如下:factory()->create() 逐条创建适合几百条的小规模场景,代码最简洁;make 加批量插入适合绝大多数中等规模填充,速度提升通常在十倍以上;chunkById 和 cursor() 主要解决读取端的内存问题,通常和批量写入配合使用;如果是千万级数据的初始化导入,建议直接绕开 PHP 层,使用数据库原生的导入工具或 COPY 语法。
排查内存问题时不要凭感觉,用 memory_get_usage() 在每个批次后打点输出,就能清楚看到内存是平稳的锯齿状还是持续爬升的斜线。锯齿状说明批次释放正常,斜线则说明存在引用泄漏,重点检查闭包捕获、静态数组累积和事件监听器。掌握了这套思路,Laravel 处理大数据量填充就不再是令人头疼的事了。
Laravel数据填充内存泄漏Laravel性能优化修改时间:2026-09-04 07:42:43