数据量一旦冲到千万级,传统的”一条 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 进程内存占用。

可以看到,一次性模式的内存曲线是一条陡升的直线,在到达阈值后直接被切断;而分批模式的内存始终保持在一个恒定的低位水平,曲线是一条接近平坦的直线——这正是我们想要的效果。
三、方案一:基于主键的分块查询(推荐)
按自增主键做范围切分,每次只捞一个区间的数据。相比 LIMIT/OFFSET分页,它能避免深翻页时 MySQL 需要扫描并丢弃前面所有行的性能损耗,同时天然支持断点续传。
<?php
declare(strict_types=1);
class CsvExporter
{
public function __construct(
private \PDO $pdo,
private int $batchSize = 5000
) {}
public function export(string $table, string $filePath): void
{
$fp = fopen($filePath, 'w');
if ($fp === false) {
throw new \RuntimeException("无法打开文件: {$filePath}");
}
// 写入 UTF-8 BOM,保证 Excel 正确识别中文
fwrite($fp, "\xEF\xBB\xBF");
// 写表头
$headerStmt = $this->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
declare(strict_types=1);
class CursorExporter
{
public function __construct(private \PDO $pdo) {}
// 生成器:每次 yield 一行,内存占用 O(1)
public function rowGenerator(string $sql, array $params = []): \Generator
{
// 关闭客户端缓冲,数据留在 MySQL 服务端
$this->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
declare(strict_types=1);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Encoding: gzip');
header('Content-Disposition: attachment; filename="export_' . date('Ymd') . '.csv.gz"');
// 输出直接对接 gzip 流
$gz = gzopen('php://output', 'w9');
$stmt = $pdo->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 关键配置:
memory_limit = 512M
max_execution_time = 300Nginx 配置(在 server块或 location块中):
fastcgi_read_timeout 300s;
proxy_read_timeout 300s;如果是 PHP-FPM,还要同步检查 request_terminate_timeout,确保它不低于 PHP 侧的 max_execution_time。
代码层面可以在入口处临时放宽限制(仅限可信的后台任务):
set_time_limit(300);
ini_set('memory_limit', '512M');七、极大数据量的进阶方向
当数据量继续放大到亿级、或者需要支撑多用户并发导出时,同步导出已经不够用,应该转向异步架构:
-
消息队列 + CLI 消费者:Web 请求只负责投递任务到 Redis/RabbitMQ,后台 CLI 进程消费任务并生成文件。
-
生成后通知:文件就绪后通过站内信、邮件或 WebSocket 推送下载链接。
-
文件存储:成品 CSV 落到对象存储(OSS/S3),通过签名 URL 分发,避免 Web 服务器承受大文件下载压力。
这套架构虽然前期投入更大,但能把导出过程和 HTTP 请求彻底解耦,是高并发生产环境的必经之路。

