学完这篇,你能把动辄几百 MB 内存的 PHP 导出脚本,压缩到十几 MB 以内稳定跑完——哪怕要导出几十万行数据。
导出的内存问题,九成不是数据太大,而是写法太"顺手":fetchAll() 一次全捞、字符串一路 .= 拼接、Excel 库先把整张表建在内存里。下面按顺序改,每一步都能立竿见影看到内存下降。
第一步:先量,别凭感觉优化
在脚本开头和关键节点打一下内存,确认到底卡在哪:
echo memory_get_usage() / 1048576, " MB\n";
// 关键节点再打一次
echo memory_get_peak_usage(true) / 1048576, " MB 峰值\n";
峰值(peak)比当前值更有意义,它告诉你脚本最狠的一瞬间吃了多少。先跑一次只看不导出的版本,拿到基线数字,后面每改一步都复测一次。
第二步:把 fetchAll 换成游标或分页
PDO 默认是缓冲查询——query() 那一刻,结果集已经全部进了 PHP 进程内存,fetchAll() 只是又复制一份成数组。两种解法:
方式 A:关掉缓冲(MySQL)
$pdo = new PDO($dsn, $user, $pass, [
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false,
]);
$stmt = $pdo->query("SELECT id,title,content FROM posts");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// 逐行处理
}
方式 B:按主键分页(更稳,推荐)
$lastId = 0;
while (true) {
$rows = $pdo->query("SELECT id,title FROM posts WHERE id > {$lastId} ORDER BY id LIMIT 1000")->fetchAll();
if (!$rows) break;
foreach ($rows as $r) { /* 处理 */ }
$lastId = end($rows)['id'];
}
注意:不要用 LIMIT 100000, 1000 这种 offset 分页。offset 越大 MySQL 越要先扫过前面所有行,导出到后面会越来越慢,甚至超时。用 WHERE id > 上一批最大 id 的游标分页,速度全程恒定。
第三步:用生成器把"取"和"写"拆开
yield 是控制内存最省事的工具:数据不落数组,取一行用一行。
function rows(PDO $pdo, int $batch = 1000): Generator {
$lastId = 0;
while (true) {
$rows = $pdo->query("SELECT id,title,content FROM posts WHERE id > $lastId ORDER BY id LIMIT $batch")->fetchAll();
if (!$rows) return;
foreach ($rows as $r) yield $r;
$lastId = end($rows)['id'];
}
}
foreach (rows($pdo) as $row) {
fputcsv($out, [$row['id'], $row['title']]);
}
这样内存里同时只有一批 1000 行的数据,跟总行数无关。十万行和一百万行的峰值几乎一样。
第四步:边算边写,别拼大字符串
最常见的隐形炸弹是先把整个 CSV 攒成字符串再输出:
$csv = '';
foreach ($rows as $r) { $csv .= implode(',', $r) . "\n"; } // 千万别这么写
改成直接写到输出流,浏览器那边同时开始下载:
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="export.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF"); // BOM,防 Excel 中文乱码
foreach (rows($pdo) as $r) {
fputcsv($out, [$r['id'], $r['title']]);
}
fclose($out);
注意:输出前先 while (ob_get_level()) ob_end_clean();,把框架或 ob_start() 留下的输出缓冲清干净,否则数据可能被攒在缓冲区里,既不实时也白占内存。同时 set_time_limit(0); 防超时,ignore_user_abort(true); 防用户关页面导致导出写一半。
第五步:Excel 别用 PHPExcel
PHPExcel / PhpSpreadsheet 的常规用法是把每个单元格建成对象树,几万行就能吃掉几个 G。导出场景改用流式方案:
- 只要表格:直接出 CSV(用
fputcsv,上面那套照搬),Excel 能打开。
- 必须是 .xlsx:用
XLSXWriter 这类流式库,它边写边往临时文件追加,内存占用基本恒定。
- 要样式又想零依赖:手写 SpreadsheetML(XML)流式输出,也不难。
第六步:大导出交给后台跑
如果导出耗时超过几十秒,别硬扛在 HTTP 请求里。改成:请求只负责建任务记录 → 后台按时分批跑 → 前端轮询进度 → 完成后给下载链接。任务表里记 last_id 和进度,中断了下次从 last_id 继续,天然支持断点续传。写到临时文件再整体提供下载,也比边算边推更稳。
注意:分批任务一定要记录"已处理到哪一行",并且每批处理完才更新进度。中途挂掉时不会重复发数据,也不会漏。
小结
- 先测
memory_get_peak_usage,拿基线再优化,别盲改。
- 查询用游标分页(
WHERE id > last_id),禁用 fetchAll 和深 offset。
- 用
yield 生成器串起取数与写出,内存与数据总量解耦。
- 直接写
php://output,fputcsv 逐行落盘,输出前清空 ob_ 缓冲。
- Excel 用流式库或 CSV,别碰对象树式的 PhpSpreadsheet 全量构建。
- 长任务转后台分批 + 记录进度,支持续跑。