### [PHP千万级数据导出内存溢出?试试分批处理方案](https://www.huociguo.com/article/82) **Published:** 2026-07-08T16:22:03 **Author:** 米了 **Excerpt:** 数据量一旦冲到千万级,传统的”一条 SQL 查完、一次性写入文件”的导出方案基本都会撞上 Allowed memory size exhausted这道墙。问题的根源不在 PHP 本身,而在”一次性把数据全部搬进内存”这种执行模式。本文给出 数据量一旦冲到千万级,传统的”一条 SQL 查完、一次性写入文件”的导出方案基本都会撞上 `Allowed memory size exhausted`这道墙。问题的根源不在 PHP 本身,而在”一次性把数据全部搬进内存”这种执行模式。本文给出一套可落地的分批处理方案,覆盖数据库读取、流式写入、超时配置、压缩下载四个关键环节,并配一张对比图帮助理解两种模式的内存走势差异。 ## 一、为什么一次性导出必崩 PHP 的每个脚本都在 `memory_limit`划定的内存配额内运行,默认值通常是 128M 或 256M。当代码执行 `$pdo->query($sql)->fetchAll()`时,结果集会整体被加载进 PHP 数组,再加上 PHP 数组本身的哈希表开销,实际占用往往是原始数据的 1.2–1.3 倍。 也就是说,哪怕每行数据只有 200 字节,1000 万行原始数据就有约 2GB,加上数组开销会突破 2.5GB——远远超出默认限制,脚本直接被内核杀掉,浏览器侧只会看到一个永远转不完的加载圈。 除了内存,还有两个隐性雷区: - **执行超时**:千万级数据的查询+写入,远超过 `max_execution_time`的默认 30 秒。 - **Web 服务器超时**:Nginx 的 `fastcgi_read_timeout`默认 60 秒,PHP 还没跑完,Nginx 已经把连接断了,前端收不到任何响应头。 ## 二、两种导出模式的内存走势对比 下面这张图直观对比”一次性导出”与”分批流式导出”的内存占用走势,横轴是处理进度,纵轴是 PHP 进程内存占用。 ![](https://api.huociguo.com/wp-content/uploads/2026/07/20260709001943642-081610243f-1.png "20260709001943642-081610243f-1") “一次性导出”与”分批流式导出”两种模式的内存占用走势对比图 可以看到,一次性模式的内存曲线是一条陡升的直线,在到达阈值后直接被切断;而分批模式的内存始终保持在一个恒定的低位水平,曲线是一条接近平坦的直线——这正是我们想要的效果。 ## 三、方案一:基于主键的分块查询(推荐) 按自增主键做范围切分,每次只捞一个区间的数据。相比 `LIMIT/OFFSET`分页,它能避免深翻页时 MySQL 需要扫描并丢弃前面所有行的性能损耗,同时天然支持断点续传。 ```php pdo->query("SHOW COLUMNS FROM {$table}"); $columns = $headerStmt->fetchAll(\PDO::FETCH_COLUMN); fputcsv($fp, $columns, ',', '"', ''); // 取最小主键 $minStmt = $this->pdo->query("SELECT MIN(id) FROM {$table}"); $lastId = (int) $minStmt->fetchColumn(); if ($lastId === 0) { fclose($fp); return; } do { $stmt = $this->pdo->prepare( "SELECT * FROM {$table} WHERE id >= ? ORDER BY id ASC LIMIT ?" ); $stmt->execute([$lastId, $this->batchSize]); $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); if (empty($rows)) { break; } foreach ($rows as $row) { fputcsv($fp, array_values($row), ',', '"', ''); } // 取下一批起点 $lastId = (int) end($rows)['id'] + 1; // 主动释放,避免长时间运行累积 unset($rows); gc_collect_cycles(); } while (true); fclose($fp); } } ``` **关键点**: - 批次大小 `batchSize`在 5000–20000 之间较稳妥,太小会增加查询次数,太大会让单次内存回弹。 - `id`字段必须有索引,否则每次范围查询都会退化成全表扫描。 - `gc_collect_cycles()`在长循环中建议周期性调用,避免 PHP 的垃圾回收滞后。 ## 四、方案二:PDO 无缓冲查询 + 生成器 如果业务上无法用主键切分(比如要处理的是跨表 JOIN 的结果),可以用 PDO 的无缓冲查询配合 PHP 生成器,做到”逐行读取、逐行写出”。 PHP 官方文档明确指出,生成器最大的价值就在于”不必在内存里预先构建整个数组”,从而避免触及内存上限。 ```php pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false); $stmt = $this->pdo->prepare($sql); $stmt->execute($params); while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { yield $row; } $this->pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true); } public function export(string $sql, string $filePath, array $params = []): void { $fp = fopen($filePath, 'w'); foreach ($this->rowGenerator($sql, $params) as $index => $row) { if ($index === 0) { fputcsv($fp, array_keys($row), ',', '"', ''); } fputcsv($fp, array_values($row), ',', '"', ''); // 每 1 万行 flush 一次,防止输出缓冲积压 if ($index % 10000 === 0) { fflush($fp); } } fclose($fp); } } ``` **使用无缓冲查询的注意事项**: - 在结果集完全消费完之前,同一个连接不能再发起其他查询。 - 长事务场景下,游标会占用 MySQL 服务端资源,可能引发 `IOPS`飙升和磁盘临时表膨胀,不适合超高并发的导出接口。 - 更适合 CLI 模式下跑离线任务,而不是 Web 请求。 ## 五、方案三:浏览器直出 + Gzip 压缩 如果是 Web 端触发的导出,可以跳过落盘环节,直接把 CSV 流推给浏览器,并开启 Gzip 压缩——文本型 CSV 的压缩率通常很高,能显著减少网络传输时间。 ```php query("SELECT * FROM orders"); $isHeaderWritten = false; while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { if (!$isHeaderWritten) { gzwrite($gz, "\xEF\xBB\xBF"); // BOM gzwrite($gz, implode(',', array_keys($row)) . "\r\n"); $isHeaderWritten = true; } gzwrite($gz, implode(',', array_map( fn($v) => '"' . str_replace('"', '""', (string)$v) . '"', array_values($row) )) . "\r\n"); } gzclose($gz); exit; ``` > ⚠️ 发送下载响应头之后,脚本里不能再有任何额外输出(包括错误、警告、尾部空格),否则下载的文件会损坏。 ## 六、超时与服务器配置 光改代码还不够,必须把 PHP 和 Web 服务器的超时阈值一起抬上去,否则会出现”PHP 还在跑,Nginx 已经断了”的尴尬局面。 **php.ini 关键配置**: ```ini memory_limit = 512M max_execution_time = 300 ``` **Nginx 配置**(在 `server`块或 `location`块中): ```nginx fastcgi_read_timeout 300s; proxy_read_timeout 300s; ``` 如果是 PHP-FPM,还要同步检查 `request_terminate_timeout`,确保它不低于 PHP 侧的 `max_execution_time`。 代码层面可以在入口处临时放宽限制(仅限可信的后台任务): ```php set_time_limit(300); ini_set('memory_limit', '512M'); ``` ## 七、极大数据量的进阶方向 当数据量继续放大到亿级、或者需要支撑多用户并发导出时,同步导出已经不够用,应该转向异步架构: - **消息队列 + CLI 消费者**:Web 请求只负责投递任务到 Redis/RabbitMQ,后台 CLI 进程消费任务并生成文件。 - **生成后通知**:文件就绪后通过站内信、邮件或 WebSocket 推送下载链接。 - **文件存储**:成品 CSV 落到对象存储(OSS/S3),通过签名 URL 分发,避免 Web 服务器承受大文件下载压力。 这套架构虽然前期投入更大,但能把导出过程和 HTTP 请求彻底解耦,是高并发生产环境的必经之路。 **Tags:** CSV导出, PHP, 内存溢出, 分批处理, 千万级数据导出 **Categories:** PHP教程 ---