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; charsetutf-8); header(Content-Disposition: attachment; filenameorders.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.0MySQL 专用属性 $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()把小的关联数据提前取出来。四、太重就挪走异步导出当导出超过十几秒、或者行数达到百万级正确的做法是把导出从同步请求里挪出去接口只做校验和登记往任务表写一条「导出任务」记录状态排队中立即返回任务 ID。后台进程消息队列的消费者或者cron拉起的 CLI 脚本执行真正的导出把文件写到非 Web 根目录。客户端轮询「任务状态」接口完成后拿到一个带时效签名的下载地址。文件定期清理。这样做的收益不只是不卡任务可以重试、可以限流同时只跑 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_types1); // PHP 8.2readonly 类 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()。坑 5set_time_limit(0)之后仍然被超时杀掉。❌ PHP-FPM 池的request_terminate_timeout会直接终止工作进程set_time_limit()拦不住。 ✅ 长任务放到 CLI 异步执行必须同步跑时同步调大 FPM 的超时配置。坑 6导出文件放在 Web 根目录下。❌public/exports/orders.csv谁都能猜路径下载等于数据裸奔。 ✅ 文件放在 Web 根目录之外下载走一个校验权限和时效签名的脚本。坑 7在导出循环里做 N1 关联查询。❌ 导出 1 万行顺手查 1 万次用户名数据库连接被打满导出本身慢十倍。 ✅ 先一次性把维表数据取进内存维表通常很小或者用一次JOIN带出来。坑 8用内存型 Excel 库导出百万行。❌ 这类库需要在内存里构造整张工作表百万行必然爆内存。 ✅ 优先输出 CSV必须是 XLSX 时选用支持流式写出的方案例如基于 XML 流式写入的写库并在导出前评估体积。总结关注点结论取数方式分批 游标WHERE id :lastId禁掉大OFFSET内存占用与批大小成正比不与总行数成正比输出方式关掉输出缓冲 php://outputflush()Nginx 关fastcgi_buffering超时控制set_time_limit(0)只是其中一环FPM 的request_terminate_timeout优先级更高何时异步耗时超过十几秒或行数达百万级改成「任务登记 后台执行 轮询下载」安全导出文件不落在 Web 根目录下载要鉴权导出不卡的秘诀没有多复杂让内存里同时只存在一批数据让单次请求的时间短到可以被接受让重活离开同步请求。把这三条落到代码里十万行导出和一千行导出在资源占用上几乎是同一件事。
网站建设高端定制企业官网