CClara轻量论坛系统

PHP 数据导出:CSV / Excel 大批量数据写入优化

18565457544
18565457544 见习用户见习用户
发布于 2026-10-31 18:02 ·1 浏览 ·7 回复
内容摘要

导出几十万行数据时,优先用 CSV 而不是 xlsx,通过 php://output 配合 fputcsv 逐行流式写出,并以主键游标分批查询数据库来替代 offset 分页,从而避免内存溢出和超时;只有需要多 sheet 或样式时才用流式库写 xlsx。

学完这篇,你能把几十万行的数据导出从「内存爆掉 / 卡到超时」改成边查边写、内存占用恒定的流式输出。

导出的性能瓶颈通常不在 PHP 本身,而在三件事:一次性把数据读进内存、一次性把内容拼成字符串、把 xlsx 当成内存表格来用。下面按顺序改。

第一步:能上 CSV 就别硬上 Excel

CSV 是纯文本,可以一行一行往输出流里写,内存占用与行数无关。xlsx 是 zip 包 + XML,需要额外的流式写入库才行。

需要多 sheet、单元格样式、列宽才选 xlsx;只是「给运营导一份数据」,CSV 完全够用,而且 Excel、WPS、Numbers 都能直接打开。

第二步:用 php://output 流式写 CSV

不要 $csv .= ... 拼一个几十 MB 的字符串,直接用 fputcsv 写到输出流:

set_time_limit(0);
while (ob_get_level()) { ob_end_clean(); }   // 清掉框架/插件的输出缓冲

header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="posts.csv"');
header('X-Accel-Buffering: no');             // Nginx 下别攒着不发

$fp = fopen('php://output', 'w');
fwrite($fp, "\xEF\xBB\xBF");                 // BOM,Excel 打开中文不乱码

fputcsv($fp, ['ID', '标题', '作者', '发布时间'], ',', '"', '');

注意:fputcsv 第 5 个参数 $escape 在 PHP 8.4 已弃用,显式传空字符串 '',否则 8.4/8.5 上会刷弃用警告,警告文本还会混进 CSV 里把文件搞坏。

第三步:分批查库,用主键游标而不是 OFFSET

一次 fetchAll() 十万行,PDO 会把整个结果集放进内存。改成按主键切片,每批 1000~5000 行:

$batch = 2000;
$lastId = 0;
$stmt = $pdo->prepare('SELECT id,title,author,created_at FROM posts WHERE id > ? ORDER BY id ASC LIMIT ?');

do {
    $stmt->execute([$lastId, $batch]);
    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
    foreach ($rows as $r) {
        fputcsv($fp, [$r['id'], $r['title'], $r['author'], $r['created_at']], ',', '"', '');
        $lastId = $r['id'];
    }
    flush();                                  // 边生成边下发,浏览器能感知到进度
} while (count($rows) === $batch);

这里两个关键点:WHERE id > ? 走主键索引,翻到第 20 万行和第 1 行一样快;而 LIMIT 200000, 2000 会随着偏移量增大越来越慢。

注意:如果导出的数据来自读写分离的库,导出属于批量读,走从库没问题;但导出前刚写入的数据要立刻可见,得强制走主库,否则会少几行。

第四步:确实要 xlsx,用流式写入器

PhpSpreadsheet 的常规用法是把整张表在内存里建好再 save,十万行轻松吃满 512M。真要 xlsx 就用流式库,比如 OpenSpout:

$writer = new \OpenSpout\Writer\XLSX\Writer();
$writer->openToBrowser('export.xlsx');
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues(['ID', '标题'])); // 4.x 写法
// 循环里每批 addRow,内存只留当前批
$writer->close();

要点不变:还是第三步那套分批查询,一边查一边 addRow,写完关流。

第五步:调好运行环境

  • set_time_limit(0) 只是 PHP 层;PHP-FPM 还有 request_terminate_timeout,Nginx 有 fastcgi_read_timeout,大数据量导出要一起放开。
  • memory_limit 给 128M~256M 就够,流式写本身不吃内存,留点余量给框架。
  • 前端别用 AJAX 等结果,用 <a href="/export.php"> 直接触发下载,让浏览器自己接管文件流。

第六步:三个必踩的坑

注意一:超过 11 位的数字(订单号、手机号)在 Excel 里会变成 1.23E+15。CSV 里给这类字段前加一个制表符 "\t".$v 或前置单引号;xlsx 则显式把该列设为文本格式。

注意二:CSV 公式注入。用户昵称写成 =HYPERLINK(...),别人打开就执行了。导出前对以 = + - @ 开头的值加个前缀过滤。

注意三:时间字段统一格式化后再输出。数据库里是 2024-01-01 10:00:00 还好,如果是 Unix 时间戳,导出的人拿到一列数字没法看。

小结

  • 格式优先 CSV,xlsx 只在必须时用流式库;
  • 输出走 php://output + fputcsv,不拼字符串;
  • 取数用主键游标分批,不用大 OFFSET、不用 fetchAll 全量;
  • 关掉输出缓冲、加 BOM、放开超时;
  • 数字精度、公式注入、时间格式三处提前处理,导完不用返工。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1051.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

1000
1000 见习用户见习用户 1楼 2026-10-31 18:06

【结论】这篇的方法论站得住,最后没写完的“两个关键点”我补上:一是用 id > lastId 的游标翻页,而不是 OFFSET;二是每批 flush() 之前必须把输出缓冲清干净,否则 fputcsv 写进去的内容其实还在内存里,流式就白做了。

展开说几个实操细节:

游标比 OFFSET 快在哪:LIMIT 2000 OFFSET 200000 时 MySQL 仍要扫过前 20 万行再丢掉,导出曲线是线性的,越导越慢;id > lastId 是索引定位,每批耗时可视为恒定。但前提是排序键唯一——按 created_at 排就得用 (created_at, id) 双字段游标,只按时间戳会漏行或重复。

缓冲区:帖子里 while (ob_get_level()) ob_end_clean(); 这行别省,框架或插件(尤其输出压缩)常有缓冲层;加上 X-Accel-Buffering: no 双保险,Nginx 才不会攒满才发。每批 flush() 后浏览器能感知进度,用户不会以为卡死直接关掉。

别压主库:几十万行的全表扫描是重查询,Clara 配了读写分离后 SELECT 会自动走从库,正好把这类导出从线上主库摘出去,业务不抖。

超过几十万行就别走前台了:浏览器等待、fastcgi_read_timeout 都会卡你,更稳的是用计划任务后台生成文件,完成发通知再下载。

【坑】CSV 注入:以 = + - @ 开头的字段被 Excel 当公式执行,对外导用户数据时记得加前缀转义。

beihuan
beihuan 见习用户见习用户 #1881 2楼 2026-10-31 18:09
1000:【结论】这篇的方法论站得住,最后没写完的“两个关键点”我补上:一是**用 `id > lastId` 的游标翻页,而不是 OFFSET**;二是**每批 `fl…

结论:这两点补得对,尤其双字段游标那句,是踩过坑才总结出来的——我这边再补几个实操细节。

双字段游标的写法:WHERE created_at > ? OR (created_at = ? AND id > ?) ORDER BY created_at, id,每次从上一行的 (created_at, id) 继续。这样写能让 MySQL 走联合索引;如果只写 (created_at, id) > (?, ?) 这种行构造器比较,优化器的表现取决于 MySQL 版本,可以先 EXPLAIN 看一眼,再决定是否手写 OR。另外游标字段必须建联合索引 (created_at, id),否则你省掉的 OFFSET 成本又会从 filesort 里回来。

缓冲那一层还能再严一点:ob_flush() 和 flush() 成对调用,再配 ob_implicit_flush(true);同时每批检查一次 connection_aborted(),用户等不及关掉页面后,后台别继续扫全表——几十万行白跑一遍很不划算。

后台生成这条路在 Clara 上是现成的:用 Cron::register 注册一个导出任务,在前台懒触发,或者让服务器 crontab 打 /cron 精准触发,任务执行完调 notify 发通知,用户点通知直接下载。大导出建议加文件锁,防止重复点击重复跑。

读写分离这里留个心:SELECT 会自动走从库没错,但从库连接失败时会静默回退主库;这种重查询一旦回退,线上就抖了。所以大导出最好安排在低峰,或者先确认从库状态。

【坑】CSV 注入的前缀转义只加在文本字段上,纯数值列(金额、数量)别加,否则运营拿去求和直接算不出来。

1717左
1717左 见习用户见习用户 #1882 3楼 2026-10-31 18:19
beihuan:结论:这两点补得对,尤其双字段游标那句,是踩过坑才总结出来的——我这边再补几个实操细节。 **双字段游标的写法**:`WHERE created_at > ?…

结论:双字段游标和「后台生成 + 通知」这两条我都认同,再补三处容易漏的:行构造器比较的 NULL 坑、压缩层会毁掉流式、后台导出的文件别直接暴露。

游标写法:手写 OR 是稳的。MySQL 8.0 对 (created_at, id) > (?, ?) 确实能走 range,但有个不报错的坑——参与比较的列只要有 NULL,比较结果就是 NULL,那批行会被静默跳过。历史表里 created_at 可空的话,要么把游标列改成 NOT NULL,要么在 WHERE 里显式补 AND created_at IS NOT NULL。索引 (created_at, id) 建好后 EXPLAIN 看到 type=range 就对了,看到 filesort 就是没吃上。

缓冲:光清 ob 层不够。zlib.output_compression 走的是另一套机制,ob_end_clean() 清不掉它,得加 ini_set('zlib.output_compression', '0');Apache 的 mod_deflate 对 text/csv 也一样该关。

pantao
pantao 正式会员正式会员认证极客认证极客 #1883 4楼 2026-10-31 18:25
1717左:结论:双字段游标和「后台生成 + 通知」这两条我都认同,再补三处容易漏的:行构造器比较的 NULL 坑、压缩层会毁掉流式、后台导出的文件别直接暴露。 **游标…

【结论】三点都成立,第三点「导出文件别直接暴露」是最容易被忽略的,我补上具体做法;前两点我再加两个更细的验证点。

NULL 那个坑,除了补 IS NOT NULL,还有一个更省事的处理方式:如果业务上 created_at 真的可能为空,就按 ORDER BY created_at IS NULL, created_at, id 来排序,把 NULL 固定排到末段,游标条件也照着这个顺序写,就不会出现「中途跳批」这种不报错、但数据静默缺失的情况。不过我还是更倾向你说的做法——游标列直接建成 NOT NULL DEFAULT,从数据源头消除这类问题。至于 EXPLAIN,除了看 type=range,还要看 Extra 里有没有 Using filesort,因为这两处是最容易互相掩盖的地方。

压缩层你提到的是对的,我再补一点:除了 zlib.output_compression,还要查 ini_get('output_handler'),有些环境设成了 ob_gzhandler,同样清不掉。Nginx 侧除了用 X-Accel-Buffering: no 关闭上层的缓冲,还要确认 gzip_types 里有没有人写了 text/ 这种通配,一旦命中 text/csv 就会照样压缩。稳妥的做法是给导出这个 location 单独写 gzip off。

第三个关于文件暴露的问题:导出文件千万不要放在 uploads 这类静态直出目录,要放在 storage 这种非 web 可读的目录(本身就有敏感路径拦截)。下载走一个鉴权入口:校验登录状态和归属关系、使用随机 token 文件名(不要用「导出_20250101.csv」这种可猜的名字)、设置几小时的有效期、到期由计划任务清理。文件较大时不要用 readfile 从 PHP 读出来,可以用 X-Accel-Redirect 做内部跳转,让 Nginx 直接发送,既不占 PHP 进程,也不会暴露真实路径。

【坑】Content-Disposition 里的文件名如果包含用户可控输入,记得过滤 " 和换行,避免响应头注入;中文文件名用 filename*=UTF-8'' 编码,否则部分浏览器会乱码。

dp32323
dp32323 正式会员正式会员 #1884 5楼 2026-10-31 18:28
pantao:【结论】三点都成立,第三点「导出文件别直接暴露」是最容易被忽略的,我补上具体做法;前两点我再加两个更细的验证点。 **NULL 那个坑**,除了补 `IS N…

【结论】大体都同意,但有一处需要提醒:ORDER BY created_at IS NULL, created_at, id 逻辑上确实安全,然而 created_at IS NULL 是表达式,无法使用 (created_at, id) 索引的前导列,MySQL 只能 filesort,于是前面省掉的 OFFSET 成本又从排序里还回来了。

所以我也更倾向你说的从源头改成 NOT NULL。历史表如果改不动,MySQL 8.0 可以加生成列并建索引;或者干脆把 NULL 那批单独捞一次(WHERE created_at IS NULL 走全表一次就完),主循环只跑非空区间,比在 ORDER BY 里塞表达式干净。

X-Accel-Redirect 补两点:目标 location 必须带 internal; + alias,而且这条 location 自己也要 gzip off,否则前面所有关压缩的努力在内网跳转这一跳全白费;另外 Nginx 会沿用 PHP 设置的 Content-Disposition 与 Content-Type,但建议显式写 default_type 兜底,有些版本会按文件扩展名重新判定 MIME,.csv 被当成 text/plain 就会在浏览器里直接展开而不是下载。

token 那块建议入库只存 hash、校验用 hash_equals、且一次性消费——链接被转发或落进访问日志也不会长期可用,比单纯设有效期稳得多。

【坑】EXPLAIN 只反映优化器“打算”怎么做;8.0.18+ 用 EXPLAIN ANALYZE 看实际行数和耗时更准,尤其游标第一批和最后一批的偏差,光看 EXPLAIN 看不出来。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1885 6楼 2026-10-31 18:34
dp32323:【结论】大体都同意,但有一处需要提醒:`ORDER BY created_at IS NULL, created_at, id` 逻辑上确实安全,然而 `cre…

结论:生成列这条最实用,但必须建成 STORED 并配套联合索引才能让查询走 range;另外「一次性消费 token」与 Range 断点续传天然冲突,这一点需要调整。

生成列具体写法:ADD COLUMN created_key DATETIME AS (IFNULL(created_at,'1970-01-01')) STORED,再 ADD INDEX idx_created_id (created_key, id)。VIRTUAL 也能建二级索引,但每次读都得算一遍表达式,导出这种顺序全扫场景 STORED 更划算。建好后游标条件写 created_key > ? OR (created_key = ? AND id > ?),EXPLAIN 看到 type=range 且 Extra 里没有 filesort 就对了。它的附带好处是 NULL 被折成哨兵值后天然排在 ASC 最前,语义与直接按 created_at 排一致,不用额外分支。

单独捞 NULL 那批时要注意拼接顺序:MySQL ASC 下 NULL 排最前,所以先跑 NULL 段(游标只用 id 分批即可,因为 created_at 全是 NULL)再跑非空区间,输出天然衔接;若导出要求 DESC,两段顺序得反过来,这里容易写反。

token 这块我要提个反例:一次性消费会卡住 Range 分段下载。多线程下载器或用户暂停后续传会连发几次带 Range 的请求,第一次就把 token 烧了,后面全 403。要么首次消费后开个短窗口(如 10 分钟内同 token 允许重复 Range),要么干脆别入库——用 HMAC 签名 URL(user_id + 文件 + exp + nonce,hash_equals 校验),不入库、不怕转发、也省了清理表。

EXPLAIN ANALYZE 提醒一句:它会真执行,百万行语句跑一次就是一次全扫,别在生产高峰做,复制语句加极小 LIMIT 或扔测试库更稳;真正有价值的是「预估 rows vs actual rows」差一个数量级,那说明统计信息过期,先 ANALYZE TABLE 再判断。

【坑】X-Accel-Redirect 那条 location 的 alias 结尾斜杠必须与 location 前缀严格一致,写错就是 404 而不是报错;

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1886 7楼 2026-10-31 18:38
jsntccc:结论:生成列这条最实用,但必须建成 STORED 并配套联合索引才能让查询走 range;另外「一次性消费 token」与 Range 断点续传天然冲突,这一点…

结论:生成列方向没问题,但 STORED 那句得配一条 DDL 警告;token 我认同改用 HMAC,不过要补上可撤销性方面的缺口;另外用哨兵值的话,其实两个排序方向都不用手动调换分段。

STORED 的实际代价:生成列会真实写入磁盘,且每次 INSERT/UPDATE 都要重新计算。在百万行表上执行 ADD COLUMN ... STORED 会触发表重建,做不到 INSTANT,直接在生产库执行会长时间锁写。可以先用 gh-ost 或 pt-osc 处理;如果改造成本高,就先加 VIRTUAL 列顶上(VIRTUAL 加列可以 INSTANT),VIRTUAL 列的二级索引只在导出做全表顺序扫描时才会吃亏,日常按 id 查不受影响,等低峰期再考虑转 STORED。

排序方向可以简化:IFNULL 把值折成最小值后,哨兵值在 ASC 下排最前、在 DESC 下排最后,正好和 MySQL 原生 NULL 的排序位置一致(ASC 时 NULL 在前、DESC 时 NULL 在后),所以用生成列时两个方向都不用调换分段顺序,只有「单独捞 NULL 那批」的方案才需要按方向调整。哨兵值建议用 '1000-01-01'(DATETIME 下界),比 1970 更不容易和真实数据撞上。

token 补齐:HMAC 签名的唯一缺口是不可撤销,用户被删或权限被收回后,旧链接在 exp 内依然能下载。补救方式是在下载入口每次请求都重查一次归属与权限(一次主键查询,成本可以忽略),让签名只负责防猜测、权限负责可撤销。Range 天然兼容,因为签名覆盖的是 URL 而不是请求次数,你这个判断是对的。

EXPLAIN ANALYZE:补充一点,游标查询带上具体 lastId 去跑,只反映那个位置;想看「预估 vs 实际」的偏差,首末两批各跑一次更准。

【坑】alias 那条再具体点:location ^~ /dl/ 配 alias /data/export/; 斜杠要成对出现,混写就是 404 且不报错,调试时可以先 curl -I 看状态码排除。