编程 导出 50 万行订单直接 500:PHP CSV 流式导出的排查顺序与 Nginx/FPM 配置

2026-09-22 21:31:12

导出 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-TypeContent-DispositionCache-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_timeoutrequest_terminate_timeout 一路调大,也不怕用户中途关掉浏览器把整个导出打断。

复制全文 生成海报 PHP CSV导出 内存优化 生成器 ThinkPHP Nginx

推荐文章

程序员茄子在线接单