☰
PHP 8.2接口怎么实现数据导出功能不卡
2026/10/1 10:38:43 网站建设 项目流程

前言

导出接口"卡"的表现很有辨识度:点一下「导出全部订单」,浏览器转圈十几秒,最后502 Bad Gateway;或者接口本身返回了,但同一时间其他请求全部排队,页面整站变慢;再狠一点,PHP-FPM 的日志里出现Allowed memory size of 134217728 bytes exhausted,进程被 OOM 杀掉。

根因基本是三本账没算清。内存账:fetchAll()把整张表变成 PHP 数组,一行至少几十字节到几百字节的开销,十万行就是几十 MB,还会在复制、拼接时翻倍。时间账:一次请求里做完"查全部 + 拼格式 + 写磁盘/写响应",同步占用一个 PHP-FPM 工作进程几十秒到几分钟。连接账:数据库连接被这个请求一直占着,max_connections很快被导出请求吃光。

解法的思路是两个字:流和分。流,是边查边写,内存里永远只留一行或一批;分,是把一次大查询拆成多批,并且把"重活"从同步请求里挪出去。顺带说一下,PHP 8.2 在这里的作用是语法层面的——本文示例用readonly类(PHP 8.2 引入)来放导出配置,导出逻辑本身与版本无关。

本文讲清三件事:怎么把导出改造成流式、深分页为什么越翻越慢、以及什么时候该把导出变成异步任务。

一、为什么会卡:三本账

先看反例,这是最常见的那种写法:

<?php // PHP 8.0+ —— 典型反例 $rows = $pdo->query('SELECT * FROM orders')->fetchAll(); // 全部进内存 $csv = "id,amount,created_at\n"; foreach ($rows as $r) { $csv .= "{$r['id']},{$r['amount']},{$r['created_at']}\n"; // 字符串再复制一份 } header('Content-Type: text/csv'); header('Content-Length: ' . strlen($csv)); // 还得先全算出来 echo $csv;

这段代码在数据量小的时候毫无问题,一旦行数上万就开始现原形:内存里有数组、有拼接中的字符串、有最终字符串三份数据;strlen($csv)要求全部算出后才能发响应头,客户端要等到最后一刻才收到第一个字节。整个过程中还占着一个 PHP-FPM 工作进程和一条数据库连接。

环节反例做法流式做法
取数fetchAll()全量进内存逐行/分批fetch()
拼接字符串追加,多次复制fputcsv()直接写输出流
响应全部算完再echo边算边flush()
内存峰值与总行数成正比与批大小成正比
耗时上限由整个导出决定分片/异步,单次都短

二、流式输出:让数据边产生边离开

PHP 输出的第一道关卡是输出缓冲(Output Buffering)。默认output_buffering在 Web 环境下常常是开的,加上框架自己的缓冲,echo出来的内容会先攒在内存里,直到请求结束才发给客户端——流式写就白做了。所以开写之前先把缓冲层清空:

<?php // PHP 8.0+ while (ob_get_level() > 0) { ob_end_flush(); // 逐层关闭并冲刷 } ob_implicit_flush(true); // 之后每次 echo 都自动 flush,不再攒着 set_time_limit(0); // 脚本不因 max_execution_time 中断 header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="orders.csv"'); $out = fopen('php://output', 'wb'); fwrite($out, "\xEF\xBB\xBF"); // Excel 友好

补充两点容易忽略的:set_time_limit(0)管不到 PHP-FPM 的request_terminate_timeout,那是 FPM 在池配置层面直接杀进程,优先级更高,长任务要一并调大或干脆改成异步。另外,流式响应不要设置Content-Length(长度未知),让连接自己用分块传输结束。

Nginx 侧也有缓冲。如果不想让 Nginx 把整个响应攒完再发给浏览器,需要在对应 location 关掉:

location = /orders/export { fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_buffering off; include fastcgi_params; }

三、分批取数:别用大 OFFSET 翻页

分批的第一步是别用LIMIT 10000 OFFSET 200000。OFFSET的语义是"先扫出前 N 行再丢掉",翻到第 20 万行时数据库要实实在在地扫过 20 万行,越翻越慢。分批导出应该用游标分页(keyset pagination):

<?php // PHP 8.0+ $lastId = 0; $batch = 1000; $stmt = $pdo->prepare( 'SELECT id, amount, created_at FROM orders WHERE id > :lastId ORDER BY id LIMIT :lim' ); while (true) { $stmt->bindValue(':lastId', $lastId, PDO::PARAM_INT); $stmt->bindValue(':lim', $batch, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); if ($rows === []) { break; } foreach ($rows as $r) { fputcsv($out, [$r['id'], $r['amount'], $r['created_at']], ',', '"', '', "\r\n"); } $lastId = (int) end($rows)['id']; // 用上一批的最大主键做游标 flush(); }

游标分页要求排序字段唯一且单调(主键最合适),并且每批都命中索引,复杂度与翻页深度无关。

MySQL 下还有一条路:把 PDO 设成不使用缓冲查询,让驱动一行行从服务器拉取,而不是先把整个结果集取回客户端:

<?php // PHP 8.0+(MySQL 专用属性) $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false, ]); $stmt = $pdo->query('SELECT id, amount FROM orders'); while (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) { fputcsv($out, [$row['id'], $row['amount']]); }

代价是:这条连接在结果集取完之前不能执行别的查询(会报Cannot execute queries while other unbuffered queries are active),所以别再拿它去查关联表,要么加fetchAll()把小的关联数据提前取出来。

四、太重就挪走:异步导出

当导出超过十几秒、或者行数达到百万级,正确的做法是把导出从同步请求里挪出去:


  1. 接口只做校验和登记,往任务表写一条「导出任务」记录(状态:排队中),立即返回任务 ID。

  2. 后台进程(消息队列的消费者,或者cron拉起的 CLI 脚本)执行真正的导出,把文件写到非 Web 根目录。

  3. 客户端轮询「任务状态」接口,完成后拿到一个带时效签名的下载地址。

  4. 文件定期清理。


这样做的收益不只是"不卡":任务可以重试、可以限流(同时只跑 2 个导出)、可以给用户看进度、失败时能精准报错。CLI 进程还不受max_execution_time与 FPM 超时的双重约束。

有一个反直觉的细节:导出请求被客户端中断时,PHP 默认会继续跑完(ignore_user_abort默认关闭时,脚本在下次输出时才会发现连接断开)。如果导出很贵,应该主动检测:

<?php // PHP 8.0+ if (connection_aborted()) { // 客户端已经走了,尽早收尾,别浪费资源 fclose($out); exit; }

代码实战:完整可运行示例

下面这个 CLI 脚本需要PHP 8.2+(用readonly类装配置),只依赖pdo_sqlite。它先用一次性fetchAll()导出,再用游标流式导出,分别打印实时内存占用——这两个数字请以你自己机器上的实测为准,重点看的是「第二个数字是否不随行数增长」。

注意这里用的是memory_get_usage()而不是memory_get_peak_usage():后者是进程级的最大值,方案 A 的高峰会被永久记在里面,拿它去比较两种方案并不公平。要比较峰值,应该把两段各自放到独立进程里跑。

<?php declare(strict_types=1); // PHP 8.2+(readonly 类) readonly class ExportConfig { public function __construct( public string $path, public int $batch = 1000, public string $delimiter = ',', ) { } } $pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $pdo->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER, created_at TEXT)'); // 造 5 万行数据(事务里批量写,很快) $pdo->beginTransaction(); $ins = $pdo->prepare('INSERT INTO orders (amount, created_at) VALUES (?, ?)'); for ($i = 1; $i <= 50000; $i++) { $ins->execute([$i * 7 % 100000, sprintf('2026-01-%02d', $i % 28 + 1)]); } $pdo->commit(); $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR; // ---------- 方案 A:一次性取回 ---------- $base = memory_get_usage(true); // 基线,便于看增量 $rows = $pdo->query('SELECT id, amount, created_at FROM orders')->fetchAll(); $csv = "id,amount,created_at\r\n"; foreach ($rows as $r) { $csv .= "{$r['id']},{$r['amount']},{$r['created_at']}\r\n"; } file_put_contents($tmp . 'a.csv', $csv); printf("A fetchAll : 行数=%d 实时内存=%d 字节 相对基线增量=%d 字节\n", count($rows), memory_get_usage(true), memory_get_usage(true) - $base); unset($rows, $csv); // 释放,避免污染后面的测量 gc_collect_cycles(); // ---------- 方案 B:游标流式 ---------- $cfg = new ExportConfig(path: $tmp . 'b.csv', batch: 1000); $base = memory_get_usage(true); $mark = 0; // 记录处理过程中的实时占用上界 $out = fopen($cfg->path, 'wb'); fwrite($out, "\xEF\xBB\xBF"); fputcsv($out, ['id', 'amount', 'created_at'], $cfg->delimiter, '"', '', "\r\n"); $lastId = 0; $countB = 0; $stmt = $pdo->prepare( 'SELECT id, amount, created_at FROM orders WHERE id > :lastId ORDER BY id LIMIT :lim' ); $stmt->bindValue(':lim', $cfg->batch, PDO::PARAM_INT); while (true) { $stmt->bindValue(':lastId', $lastId, PDO::PARAM_INT); $stmt->execute(); $batch = $stmt->fetchAll(); if ($batch === []) { break; } foreach ($batch as $r) { fputcsv($out, [$r['id'], $r['amount'], $r['created_at']], $cfg->delimiter, '"', '', "\r\n"); } $lastId = (int) $batch[count($batch) - 1]['id']; $countB += count($batch); $mark = max($mark, memory_get_usage(true)); // 取整个过程中的实时占用上界 } fclose($out); printf("B 游标流式 : 行数=%d 实时内存上界=%d 字节 相对基线增量=%d 字节\n", $countB, $mark, $mark - $base); printf("输出文件: %s (%d 字节)\n", $cfg->path, filesize($cfg->path));

运行后重点看两件事:两个方案导出的行数都是 50000(结果等价),而方案 B 的相对基线增量只与batch有关,把batch保持 1000 不变、把数据量翻十倍,这个数字几乎不动。这就是流式导出的核心收益。

常见坑点

坑 1:用fetchAll()导出大表。❌ 内存峰值与总行数成正比,几万行就开始Allowed memory size ... exhausted。 ✅ 用游标分页逐批fetchAll(),或 MySQL 下关掉缓冲查询逐行fetch()。

坑 2:以为while (ob_get_level()) ob_end_flush();就一定能流式输出。❌ 框架的中间件、gzip压缩层都可能在你自己关闭缓冲之后重新开启缓冲,echo的内容照样攒着。 ✅ 流式输出前确认所有缓冲层已关闭(必要时再关一次),并让 Nginx 的fastcgi_buffering也关掉。

坑 3:深分页用大OFFSET。❌LIMIT 1000 OFFSET 500000会让数据库扫描并丢弃 50 万行,翻得越深越慢,最后直接超时。 ✅ 用「上一批最大主键」做游标(WHERE id > :lastId),保证每批都走索引。

坑 4:在无缓冲查询的连接上再查别的表。❌ 报Cannot execute queries while other unbuffered queries are active,而且往往在循环中途才炸。 ✅ 关联数据提前用小查询取到数组里,或者用完立即closeCursor()。

坑 5:set_time_limit(0)之后仍然被超时杀掉。❌ PHP-FPM 池的request_terminate_timeout会直接终止工作进程,set_time_limit()拦不住。 ✅ 长任务放到 CLI 异步执行;必须同步跑时同步调大 FPM 的超时配置。

坑 6:导出文件放在 Web 根目录下。❌public/exports/orders.csv谁都能猜路径下载,等于数据裸奔。 ✅ 文件放在 Web 根目录之外,下载走一个校验权限和时效签名的脚本。

坑 7:在导出循环里做 N+1 关联查询。❌ 导出 1 万行顺手查 1 万次用户名,数据库连接被打满,导出本身慢十倍。 ✅ 先一次性把维表数据取进内存(维表通常很小),或者用一次JOIN带出来。

坑 8:用内存型 Excel 库导出百万行。❌ 这类库需要在内存里构造整张工作表,百万行必然爆内存。 ✅ 优先输出 CSV;必须是 XLSX 时选用支持流式写出的方案(例如基于 XML 流式写入的写库),并在导出前评估体积。

总结

关注点结论
取数方式分批 + 游标(WHERE id > :lastId),禁掉大OFFSET
内存占用与批大小成正比,不与总行数成正比
输出方式关掉输出缓冲 +php://output+flush(),Nginx 关fastcgi_buffering
超时控制set_time_limit(0)只是其中一环,FPM 的request_terminate_timeout优先级更高
何时异步耗时超过十几秒或行数达百万级,改成「任务登记 + 后台执行 + 轮询下载」
安全导出文件不落在 Web 根目录,下载要鉴权

导出不卡的秘诀没有多复杂:让内存里同时只存在一批数据,让单次请求的时间短到可以被接受,让重活离开同步请求。把这三条落到代码里,十万行导出和一千行导出在资源占用上几乎是同一件事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询