学完这篇,你能把几十万行的数据导出从「内存爆掉 / 卡到超时」改成边查边写、内存占用恒定的流式输出。
导出的性能瓶颈通常不在 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、放开超时;
- 数字精度、公式注入、时间格式三处提前处理,导完不用返工。