导出 50 万行订单直接 500:PHP CSV 流式导出的排查顺序与 Nginx/FPM 配置
后台导出 CSV 是 PHP 项目里很常见的功能。几千行数据时,先查出数组、拼成字符串、一次性返回,代码跑得毫无问题。数据涨到几十万行后,页面直接 500,日志里只剩一句内存不足:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted while exporting /admin/orders/export
旧写法:三份数据同时驻留内存
出问题的代码大致是这样:
$orders = $repo->findByDateRange($start, $end);
$rows = [];
$rows[] = ['订单号','用户ID','金额','状态','创建时间'];
foreach ($orders as $order) {
$rows[] = [$order['order_no'], $order['user_id'], $order['amount'], $order['status'], $order['created_at']];
}
$content = '';
foreach ($rows as $row) {
$content .= implode(',', $row) . "\n";
}
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="orders.csv"');
echo $content;
这里没有语法错误,问题在于同一批数据在内存里被保存了多份:数据库查询结果一份,$rows 数组一份,最终拼出来的 $content 大字符串又一份。
先别急着调 memory_limit
第一反应是把 memory_limit 从 128M 调到 512M 甚至更高,但这只是把崩溃点往后推。先在导出入口加临时日志,记录各阶段的内存峰值:
$orders = $repo->findByDateRange($start, $end);
logMemory('after query');
$rows = buildRows($orders);
logMemory('after rows');
$content = buildCsvContent($rows);
logMemory('after csv');
判断顺序:
after query就已经很高,说明一次性查询结果太大,问题在查询层。after rows/after csv继续大幅增长,说明中间映射和字符串拼接又各复制了一份数据。
实际定位下来,峰值来自三层叠加:查询层一次性拿回所有订单,数据库结果数组常驻内存;转换层映射成 CSV 行数组,第二份数据;输出层拼成一个大字符串,第三份数据。任何一层单独降下来都不够。
修复:分页读取 + 生成器 + fputcsv 流式写出
核心结构是:设置下载响应头,打开 php://output,先写表头,再分页读取,每拿到一行就用 fputcsv 写出去。
function orderRows(OrderRepository $repo, string $start, string $end, int $pageSize = 5000): \Generator {
$lastId = 0;
while (true) {
$orders = $repo->findAfterId($start, $end, $lastId, $pageSize);
if (!$orders) break;
foreach ($orders as $order) {
$lastId = (int) $order['id'];
yield [$order['order_no'], $order['user_id'], $order['amount'], $order['status'], $order['created_at']];
}
}
}
function downloadOrdersCsv(OrderRepository $repo, string $start, string $end): void {
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="orders.csv"');
header('X-Accel-Buffering: no');
$out = fopen('php://output', 'w');
fputcsv($out, ['订单号','用户ID','金额','状态','创建时间']);
foreach (orderRows($repo, $start, $end) as $row) {
fputcsv($out, $row);
}
fclose($out);
}
几个关键点:
- 分页不要用深分页
OFFSET,优先用递增主键或稳定游标。OFFSET越往后扫得越多,查询本身会先成为瓶颈。 - 生成器每次只产出一行,内存不再随总行数增长。
fputcsv负责处理字段里的逗号、引号、换行,比手写implode稳定得多。- 前面若有 Nginx 缓冲,按环境决定是否关闭,或者干脆改成异步。
验证
保留 memory_get_peak_usage() 日志,分别导出 1 万 / 10 万 / 50 万行,对比峰值是否随行数线性暴涨。流式改造后,峰值主要受单页查询大小影响,而不是总行数。
用 wc -l orders.csv 核对行数(含表头 = 数据行 + 1),再检查响应头和文件格式:字段里含逗号、引号、换行时仍应保持正确的列结构。
ThinkPHP 下 ob_flush 为什么没用
框架项目里踩的坑不太一样。ThinkPHP 默认会把整个响应内容先塞进 Response 对象缓冲区,等控制器方法执行完才统一输出。在控制器里直接 echo + ob_flush() + flush(),刷的往往是内层缓冲——中间件和视图层大概率已经开了 ob_start()。
要流式输出,导出逻辑必须脱离 return 响应链,用 exit/die 终止框架后续流程;同时在输出前设置:
ini_set('output_buffering', 'off');
ini_set('zlib.output_compression', 'Off');
ThinkPHP 6.1+ 提供了 think\Response\StreamResponse,专为流式设计,不缓存 body,直接绑定资源句柄,配合 fopen('php://output', 'wb') 就能边查边写边发。不要用 Response::create($data)->header(...)->send(),那是全量响应模式,$data 一进来就占内存。
正确节奏是:查一批 → fputcsv 写入 → 立即 fflush($fp) → 下一批,循环中不拼接大字符串。注意 Content-Type、Content-Disposition、Cache-Control: no-cache 必须在任何输出之前调用 header(),否则就是 headers already sent。
cursor() 和 chunkById() 别混用
chunkById() 看起来是分页,但每次仍要执行 COUNT(*) 加主键范围查询,数据量极大时越往后越慢,而且 chunk 内部还是会把一批结果 load 进内存。
Db::cursor() 返回的是 PDOStatement,配合 fetch() 是真正的逐行迭代,内存占用恒定在几百 KB:
$cursor = Db::table('orders')->cursor();
while ($row = $cursor->fetch()) {
// 处理单行
}
限制也要清楚:不能在 cursor() 查询里用 with() 做关联预加载,会强制转成数组加载。必须带关联数据时,改用子查询,或者先用 ID 批量 IN 查询,再在 PHP 侧合并。
Nginx 与 PHP-FPM:少配一项前面全白做
浏览器和 Nginx 都可能截断长连接,set_time_limit(0) 解决不了这个问题。Nginx 默认 proxy_read_timeout 是 60 秒,导出跑到一半连接就断了。
Nginx 侧至少要加:
proxy_buffering off;
proxy_buffer_size 128k;
proxy_busy_buffers_size 256k;
proxy_read_timeout 1800;
PHP-FPM 的 request_terminate_timeout 也要同步调大(如 1800),否则 FPM 进程会直接 kill 掉脚本。
另外,每输出约 1000 行后建议补一次心跳:
echo str_repeat(" ", 4096);
ob_flush();
flush();
防止代理或浏览器因为长时间没有数据而关闭连接。真正难的不是写出流式代码,而是确认每一层——PHP 输出控制、Web 服务器代理策略、浏览器接收行为——都允许并维持这条长连接。少配一个 proxy_buffering off,前面的优化都白做。
什么时候该用,什么时候不该用
流式导出适合这类场景:行数在几万到几十万、用户点击后需要立刻拿到文件、单次查询能靠主键游标稳定推进。它的代价是占用一个 FPM 进程和一条长连接直到导出结束。
如果导出经常跑到几分钟以上、需要断点重试、需要生成后通知用户下载,那就该换成异步:把任务丢进队列,后台生成文件落到对象存储,页面只返回任务 ID,完成后给下载链接。这样既不用把 proxy_read_timeout 和 request_terminate_timeout 一路调大,也不怕用户中途关掉浏览器把整个导出打断。