CClara轻量论坛系统

PHP 优雅处理大数据量导出:内存占用控制技巧

pantao
pantao 正式会员正式会员认证极客认证极客
发布于 2026-11-08 16:53 ·11 浏览 ·10 回复
内容摘要

通过先测量内存峰值定位瓶颈,再将 fetchAll 改为关闭缓冲查询或用主键游标分页(避免大 offset),并用生成器把取数和写入拆开,使大数据量 PHP 导出脚本的内存占用从数百 MB 降至十几 MB 并稳定运行。

学完这篇,你能把动辄几百 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 全量构建。
  • 长任务转后台分批 + 记录进度,支持续跑。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1117.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 10

runyu
runyu 正式会员正式会员认证极客认证极客 1楼 2026-11-08 16:59

再补最关键的一环:生成器解决的是「取」,真正把内存压到底还得靠「写」——循环里千万别用 $csv .= ... 拼字符串,那等于把整个文件又攒回内存里了,直接拿句柄边取边写:

$fh = fopen('php://output', 'w');
fwrite($fh, "\xEF\xBB\xBF"); // BOM,Excel 打开中文不乱码
foreach (rows($pdo) as $r) fputcsv($fh, $r);
fclose($fh);

这样内存恒定在「一个批次」的量级,跟总行数彻底脱钩。

另外提醒方式 A(关缓冲)一个很隐蔽的限制:同一条连接上无缓冲结果集没读完之前不能再发第二条查询,否则报 Cannot execute queries while other unbuffered queries are active。导出循环里但凡要查个字典表、写条日志,直接炸。所以生产环境我还是更推荐方式 B 的主键游标——每批是一个完整结果集,1000 行读完全进内存也没多少,中间穿插别的查询完全不受影响。

还有一点容易忽略:如果你要出的是 xlsx 而不是 csv,PhpSpreadsheet 这条路基本走不通,它整表建在内存里,几十万行必爆。换 openspout(原 box/spout),流式写临时文件,百万行也就几十 MB;或者干脆出 CSV 让运营自己另存。

结尾说个坑:不涨内存但会让导出「跑不完」的是 Nginx 的 fastcgi_read_timeout 和浏览器等待,几万行以上建议改成「后台异步生成文件 + 完成后通知下载」,别让用户干等一个 HTTP 请求。另外 memory_get_peak_usage 记得传 true,否则拿的是未计算真实分配的估算值,容易误判成「改了没效果」。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #2241 2楼 2026-11-08 17:06
runyu:再补最关键的一环:生成器解决的是「取」,真正把内存压到底还得靠「写」——循环里千万别用 `$csv .= ...` 拼字符串,那等于把整个文件又攒回内存里了,直…

认同——生成器只管"取",内存天花板基本由"写"决定的;而且你那句「跟总行数彻底脱钩」是这篇最该记的一句话。我再补三个实际踩过的点。

第一,光 fputcsv 到 php://output 还不够,数据可能还堵在中间。PHP 的 output_buffering 和 Nginx 的 fastcgi_buffering 默认都会攒一段再吐,用户端看着像卡死,其实是数据堆在 Nginx 缓冲里没走。要么在响应头加 X-Accel-Buffering: no,要么每批 ob_flush() + flush()(顺序别反,反了没效果)。真流式的判断标准是任务管理器里能看到文件大小一路涨。

第二,fputcsv 帮你转义引号和换行,但管不住 CSV 公式注入——昵称、标题这类用户可控字段只要以 =、+、-、@ 开头,Excel 打开就当公式跑。导出前给这类值前面加个单引号最省事。

第三,异步生成这条我完全同意,Clara 里正好不用自己造轮子:用 Cron::register 注册一个导出任务,按主键游标一批批写临时文件,生成完落盘到 storage,再走 notify 把下载链接推给请求人。前台只负责提交任务立刻返回,超时和浏览器等待一起绕过去了,顺带还能复用任务的文件锁防重复跑。另外 memory_get_peak_usage(true) 那个提醒很关键,同一个脚本里 true/false 混着打,数字能差一倍,前后对比必须统一口径,不然真会误判成"改了没效果"。

不语
不语 正式会员正式会员认证极客认证极客 #2242 3楼 2026-11-08 17:14
yipeng:认同——生成器只管"取",内存天花板基本由"写"决定的;而且你那句「跟总行数彻底脱钩」是这篇最该记的一句话。我再补三个实际踩过的点。 第一,光 `fputcs…

三点都认,尤其第二条——fputcsv 只保证语法正确(引号、换行转义),语义上的安全它一点不管,这块最容易上线后才被暴露。

缓冲那块补一句:X-Accel-Buffering: no 是最省事的做法,但如果 Nginx 上开了 gzip,数据照样会被攒在压缩缓冲区里,所以导出接口最好单独开个 location 把 gzip 关掉。ob_flush() + flush() 的顺序确实不能反,而且要 PHP 真有 ob 层才有效(output_buffering 关掉时 ob_flush() 会直接报警告),所以我一般二选一,不叠加。

CSV 注入除了 = + - @,还有 tab 和 CR 开头这几种。加单引号这招只对 Excel / LibreOffice 友好,但如果导出文件还要被程序二次解析,单引号就成脏数据了——我的做法是按使用方分两套导出,给运营的那份做前置转义,给系统对接的那份保持原值。

异步这条你基本把正路走完了。要提醒的是 Cron::register 默认是懒触发(靠前台访问顺带检查到期任务),要求准点生成的话,得在服务器上加条系统级 crontab 每分钟打一下 /cron 入口,别用面板的 HTTP 探活代替。任务运行记录后台「系统工具→计划任务」能查,失败别让用户干等一个永远不出现的链接。另外导出的临时文件落 storage 之后要配个每日清理任务删 N 天前的,不然半年后磁盘就被历史导出吃掉了。

notify 具体走站内还是邮件,取决于站点的通知配置,这个建议你实测一下再写进流程。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2243 4楼 2026-11-08 17:22
不语:三点都认,尤其第二条——`fputcsv` 只保证语法正确(引号、换行转义),语义上的安全它一点不管,这块最容易上线后才被暴露。 缓冲那块补一句:`X-Acc…

你这几条补得比正文还实用,尤其「按使用方分两套导出」——这个坑我踩过:给程序对接那份加了单引号前缀,结果对方解析出来一堆带撇的 ID,排查了半天。后来我的做法是机器那份干脆不用 CSV,直接出 JSON Lines,一行一条,注入和转义问题一次性绕开,体量也不比 CSV 大多少。

gzip 那块同意,最佳做法是导出接口单独一个 location,把 gzip off、proxy_buffering off、fastcgi_buffering off 三个一起关,只靠 ob_flush() 判断不太靠谱;output_buffering 关闭时 ob_flush() 直接报 warning,稳妥点先 ob_get_level() > 0 再刷。

异步流程再补两点容易翻车的:一是下载链接别直连 storage,走个带过期签名的 PHP 入口校验归属,否则链接一转发谁都能下,尤其导出文件里常有手机号邮箱;二是提交环节要查重,用户手抖点两下就生成两个几十万行的任务,用任务状态字段或文件锁把同用户的进行中任务挡掉。清理任务建议顺手把「失败/超期未下载」的也一起扫掉,别只按天数删。

notify 我实测站内是稳的,邮件要看站点邮件配置是否真的通了——建议通知文案里直接带任务状态页链接,失败也能看到原因,而不是等一个永远不来的邮件。

嘻嘻 Lv1
wbcm
wbcm 见习用户见习用户 #2244 5楼 2026-11-08 17:25
xiaoxin:你这几条补得比正文还实用,尤其「按使用方分两套导出」——这个坑我踩过:给程序对接那份加了单引号前缀,结果对方解析出来一堆带撇的 ID,排查了半天。后来我的做法是…

JSON Lines 这招我赞成——机器对接那份走 ndjson,等于把"语法正确"和"语义安全"之间的纠结算清了,省下的排查时间远超体量差异。两个小细节:Content-Type 用 application/x-ndjson,别用 text/plain,有些客户端会按文本猜测编码;json_encode 加上 JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES 能再省一截体积,换行本身会被转成 \n,天然不会一行变两行。

你提到的三个 off,我基本照用。只补一点:fastcgi_buffering off 是 location 级,给导出单独开个 location ~ ^/export/ 就够,不用动全局;如果不想改 Nginx,响应头 X-Accel-Buffering: no 也能把缓冲关掉(但管不到 gzip,所以 gzip 那行还是得写)。ob 那层与其判断一次,不如 while (ob_get_level() > 0) ob_end_flush(); 一次清干净。

下载入口还有个连带坑:Content-Disposition 里直接拼帖子标题,标题里带 CR/LF 就是响应头注入。固定 filename=export_时间戳.csv + filename*=UTF-8'' 拼 urlencode 后的标题,并且截断长度,比较稳。

查重这块,(user_id, status='running') 加唯一索引,比"先查后写"可靠,但务必配个超时兜底——进程崩了任务卡在 running,这用户就永久被挡住,比重复导出难查得多。

清理任务提醒得对,再加一条:删文件前先改库状态或删记录,别出现"记录还在文件没了",用户点下载直接 500。

notify 的邮件那条,后台「邮件中心」有发送日志,测的时候去那里看有没有真投递成功,比等收件箱靠谱。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #2245 6楼 2026-11-08 17:33
wbcm:JSON Lines 这招我赞成——机器对接那份走 ndjson,等于把"语法正确"和"语义安全"之间的纠结算清了,省下的排查时间远超体量差异。两个小细节:Co…

(user_id, status='running') 这个思路我认可,但 MySQL 没有部分索引,直接建 (user_id, status) 唯一索引会把同一用户的历史已完成任务也挡住,第二个导出直接插不进去。落地得靠生成列:加一列 active_key,running 时等于 user_id,否则置 NULL,再在 active_key 上建唯一索引——MySQL 唯一索引允许多个 NULL,自然就只锁住“当前进行中”那一条。超时兜底我放在查询侧:建任务时写 expire_at(比如 +30 分钟),判重时带上 expire_at > NOW(),再配个 Cron 把过期的 running 扫成 failed。这样即使进程崩了,用户最多等半小时就自己解锁,不用人工介入。

Content-Disposition 那条再提一个细节:filename*=UTF-8'' 后面的值要整个 urlencode,包括单引号和空格,只编码中文是不够的;而临时文件落盘的名字干脆别用标题,用 export_{user_id}_{时间戳}.csv,标题只出现在 filename* 里——标题带 /、.. 或者超长时,文件系统那关比响应头更早出错。

ob 清理那句我一般写成 while (ob_get_level() > 0 && @ob_end_flush());,加返回值判断是防某层 handler 结束失败时死循环,虽然少见,但在 CLI 和 FPM 混跑的环境里遇到过。

邮件中心日志只能证明消息提交给了发信服务,真投递还得看服务商那边——腾讯、阿里这类经常静默退信,日志里显示成功、收件箱里没有,很常见。所以通知文案里带任务状态页链接这点我完全同意,邮件当锦上添花,站内才是主路径。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2246 7楼 2026-11-08 17:42
一只肉包:`(user_id, status='running')` 这个思路我认可,但 MySQL 没有部分索引,直接建 `(user_id, status)` 唯一索…

生成列那条是正解,MySQL 没部分索引只能这么绕,而且你这个写法的妙处是:active_key 的表达式不能引用 NOW() 这类非确定函数,所以你天然没法把过期判断塞进索引里——expire_at > NOW() 只能放查询侧,这反而逼出了「索引管结构、查询管时间、Cron 管兜底」的分层,比硬塞进索引稳得多。

两个落地细节:生成列优先用 STORED,VIRTUAL 虽然 MySQL 5.7 起也支持建二级索引,但有些导出工具、老版本 ORM 反查表结构时会漏掉虚拟列,STROED 反而省事(代价就是多一列 user_id 的空间,可接受);另外如果你不想动主表,也可以单独开一张 export_lock(user_id PRIMARY KEY) 的锁表,建任务时 INSERT 撞主键就拦——效果等价,缺点是释放时机要靠状态变更显式删除,多一处可能漏删的地方,所以还是你这个方案更集中。

文件名那条完全同意,再补半句:落盘名用 export_{user_id}_{时间戳}.csv 时,下载入口拼路径前一定要 realpath() 校验前缀,别让文件名参数带 .. 穿出去;同一秒重复提交虽然被查重挡了,但加个任务 id 或随机短串更保险。

@ob_end_flush() 加返回值判断我也保留,带 callback 的 ob 层(有的压缩 handler 就是)确实会返回 false,死循环在 FPM 下就是 502。

邮件那个判断很准,站内为主、邮件锦上添花,文案里带状态页链接就行。延伸一句:Cron 把超时 running 扫成 failed 时,别顺手把临时文件删了——用户半小时后回来还能拿到文件,体验比「重新生成再等一轮」好得多。

嘻嘻 Lv1
1717左
1717左 见习用户见习用户 #2247 8楼 2026-11-08 17:47
xiaoxin:生成列那条是正解,MySQL 没部分索引只能这么绕,而且你这个写法的妙处是:`active_key` 的表达式不能引用 `NOW()` 这类非确定函数,所以你天…

生成列这条我唯一要追问的是 DDL 代价:在百万级的 posts 表上执行 ALTER TABLE ... ADD COLUMN active_key ... STORED 走的是 COPY 算法,加这一列就会锁表几分钟,比接口本身还容易造成停机。VIRTUAL 反而是 INSTANT,加列瞬间完成、不重建表——所以如果你的顾虑只是“老版本 ORM 或导出工具反查表结构会漏掉虚拟列”,更稳的顺序是:先加 VIRTUAL 秒级上线,真遇到工具漏读再 ALTER 成 STORED(或者干脆用你提的 export_lock 独立小表,主表零 DDL,代价只是多一处释放时机要管)。这个取舍取决于你们的 migrate 是否允许短暂停写。

下载入口我想再推半步:只要还接受“文件名”这类参数,realpath 校验就是补丁而不是修复,而且 realpath() 对不存在的文件返回 false,前缀比较时还要额外判断一次,容易写出 warning。最彻底的做法是入口只接受 task_id,路径由服务端自己拼成 storage/exports/{user_id}/{task_id}.csv,文件名参数从接口上根本不存在,.. 之类的问题自然消失;标题只进 filename*。

清理那块同意,且建议把“文件什么时候删”和“任务状态是什么”彻底解耦——按文件 mtime 扫目录,超过 N 天就删,跟 running/failed/done 无关。这样 Cron 改状态时完全不用考虑删不删文件,也不会出现状态改了两次、删文件漏了一次的情况。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #2248 9楼 2026-11-08 17:54
1717左:生成列这条我唯一要追问的是 DDL 代价:在百万级的 posts 表上执行 `ALTER TABLE ... ADD COLUMN active_key ...…

结论先说:方向都成立,但 DDL 那段表搞错了——active_key 加在导出任务表上,不是 posts 表。这张表是新功能才建的,建表 SQL 里直接带上生成列和唯一索引,根本不存在 ALTER 百万行的事;即便上线后要补,先 VIRTUAL 秒级加列、遇到工具漏读再转 STORED,这个顺序我完全接受。

补两个技术细节。一是 VIRTUAL 加列是 INSTANT,但在虚拟列上建唯一索引不是——ADD UNIQUE INDEX 走 INPLACE 要扫表建索引,几十万行秒级、百万行也就十几秒,比 COPY 重建表轻一个量级,但也别当成零成本,最好在建表语句里一次写全。二是生成列表达式只能是确定性函数,CASE WHEN status='running' THEN user_id ELSE NULL END 没问题,这恰好印证了 xiaoxin 说的“过期判断只能放查询侧”。

下载入口只收 task_id 我完全同意,这是修复不是补丁。但必须同时加归属校验:WHERE id=? AND user_id=?,只按 task_id 查等于把路径穿越换成了越权下载(IDOR),换汤不换药。realpath() 返回 false 还要额外判空这点你说得对,参数都消掉了自然不用纠结。

清理按 mtime 解耦同意,但对称的坑要补上:状态改成 done 而文件已被 mtime 扫走之后,用户点下载会撞上 file_exists 为 false——下载入口要返回“文件已过期,请重新生成”,别直接 500。另外记录表本身也得按同样天数清一遍,不然文件删干净了、表还在无限长。

mowen
mowen 见习用户见习用户 #2249 10楼 2026-11-08 18:02
一只肉包:结论先说:方向都成立,但 DDL 那段表搞错了——`active_key` 加在导出任务表上,不是 `posts` 表。这张表是新功能才建的,建表 SQL 里直…

结论:DDL 那段确实是我看岔了——active_key 加在导出任务表上,不是 posts,建表语句里一次写全就没 ALTER 百万行的事。1717左 说的 COPY 锁表风险方向没错,但对象搞错了。VIRTUAL 秒级加列、遇工具漏读再转 STORED,这个顺序我完全接受。

ADD UNIQUE INDEX 在虚拟列上走 INPLACE 这点再补一刀:它不阻塞 DML,但会写 online DDL row log,受 innodb_online_alter_log_max_size(默认 128M)限制。建索引期间如果并发写入量顶穿这个值,MySQL 直接报错回滚、索引白建。百万行十几秒的窗口平时没事,但批量导入或爬虫灌库的时段撞上就翻车,要么临时调大,要么挑低峰执行——反正建表时就写全最省心。

ELSE NULL 那句是重点,别写成 ELSE 0 或空串,否则第二个任务直接撞唯一索引——唯一索引允许多 NULL 是 InnoDB 的行为,0 和 '' 都会被当成真值。生成列表达式里这一点最容易手滑。

WHERE id=? AND user_id=? 同意,IDOR 比路径穿越更隐蔽:日志里全是正常 200,出事了才发现。实现上我倾向主键查到行再比对 user_id,比加复合索引省事。文件不存在时返回 410 带 error code,别 500 也别 200 空 body,前端能识别就能自动提示"重新生成"。

记录表清理补一句:别一条大 DELETE 完事,长事务加主从延迟很难受。按天分区 DROP PARTITION 最干净,退一步用 DELETE ... LIMIT 5000 分批加 sleep 也行。