本文目录导读:

在 PHP 中处理冷数据(不常访问的历史数据)导出,核心目标是降低内存占用和避免超时,以下是几种主流且高效的方案,按推荐程度排序:
使用生成器 + 数据库游标(最推荐)
这是 PHP 处理百万级数据导出的标准做法,通过 yield 逐行产出数据,配合数据库的 unbuffered query,内存占用恒定在几十 KB 左右。
<?php
/**
* 流式导出冷数据到 CSV
* 适用场景:数据量巨大(>10万行),普通服务器
*/
// 1. 核心:生成器函数,逐行从数据库拉取(不会一次性加载到内存)
function yieldRows(PDO $pdo, string $sql): Generator {
// 关键:使用 PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = false (MySQL)
// 或 PostgreSQL 的 cursor(事务内使用)
$stmt = $pdo->prepare($sql);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield $row; // 生成一行,用完即丢
}
$stmt->closeCursor();
}
// 2. 导出到文件(避免直接输出到浏览器导致内存炸)
function exportColdData(string $filePath, string $tableName): void {
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', 'user', 'pass', [
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false, // 关键配置:不缓冲查询结果
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// 打开文件句柄(写入模式)
$fp = fopen($filePath, 'w');
fputcsv($fp, ['ID', '姓名', '创建时间']); // 表头
// 使用生成器逐行遍历
$gen = yieldRows($pdo, "SELECT id, name, created_at FROM {$tableName}");
foreach ($gen as $row) {
// 写入 CSV(每写一行都立即刷到磁盘)
fputcsv($fp, $row);
}
fclose($fp);
echo "导出完成,文件大小: " . round(filesize($filePath) / 1024 / 1024, 2) . " MB";
}
// 调用
exportColdData('/tmp/cold_data_2023.csv', 'orders_archive_2023');
?>
优点:
- 内存占用恒定(无论数据量多大)
- 不会触发 PHP
memory_limit限制 - 文件直接写磁盘,不占 PHP 进程内存
数据库差异:
- MySQL:必须在 PDO 连接时设置
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false,且一次只能有一个活跃的 unbuffered 查询。 - PostgreSQL:需要开启事务并使用游标(
DECLARE cursor),或直接使用COPY TO命令(最快)。 - SQLite:可以使用
sqlite3的unbuffered模式。
MySQL 原生导出(最高性能)
如果数据源是 MySQL/MariaDB,甚至可以不用 PHP 处理数据,直接调用 MySQL 的 SELECT ... INTO OUTFILE 导出服务器本地文件,PHP 只负责触发命令和提示下载。
<?php
// 使用 MySQL 原生导出(速度极快,几乎不占 PHP 内存)
$pdo = new PDO('mysql:host=localhost;dbname=archive', 'root', 'pass');
$pdo->exec("
SELECT id, name, created_at
INTO OUTFILE '/tmp/orders_export.csv'
FIELDS TERMINATED BY ','
OPTIONALLY ENCLOSED BY '\"'
LINES TERMINATED BY '\n'
FROM orders_archive_2023
WHERE created_at < '2022-01-01'
");
// PHP 把 /tmp/orders_export.csv 通过 HTTP 输出给用户
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="export.csv"');
readfile('/tmp/orders_export.csv'); // readfile 是流式读取,不占内存
// 或者直接移动文件到下载目录
rename('/tmp/orders_export.csv', '/var/www/downloads/export_2023.csv');
?>
关键限制:
- MySQL 的
secure_file_priv变量限制了可以写入的目录(默认可能是 NULL,需要SHOW VARIABLES LIKE 'secure_file_priv';查看)。 - 文件只能写到 MySQL 服务器所在的服务器上(如果是远程数据库,文件不在你的 Web 服务器上)。
- 对 SQL 语法要求较高。
使用扩展库或工具(辅助)
如果不想手动处理游标,可以使用这些封装好的库:
-
league/csv:处理 CSV 写入,支持fputcsv的流式操作,内置Writer对象,可以配合生成器使用。 -
box/spout:一个 PHP 库,用于流式读取/写入 CSV、Excel (ODS/XLSX),特点是支持大文件,不需要memory_limit。use Box\Spout\Writer\Common\Creator\WriterEntityFactory; $writer = WriterEntityFactory::createCSVWriter(); $writer->openToFile('/tmp/output.csv'); // 同样是流式处理,内部处理大文件 foreach ($dataGenerator as $row) { $writer->addRow(WriterEntityFactory::createRowFromArray($row)); } $writer->close();
优化 PHP 配置 + 分批 + 后台任务
如果上述方案都不适用(例如数据库不支持游标),可以退而求其次:
- 使用
LIMIT分批查询(避免OFFSET深度翻页性能问题,用游标条件代替)。 - 结合 HTTP 404 重定向:让用户点击 3 次,每次导出 1/3 的数据。
- 将导出任务放入 Redis 队列,由 CLI 脚本(
php cli.php)在后台执行,生成完文件后用户再下载。
// 分批查询的伪代码
$lastId = 0;
$fp = fopen('/tmp/export.csv', 'w');
do {
$rows = $pdo->query("SELECT * FROM archive WHERE id > {$lastId} ORDER BY id LIMIT 10000")->fetchAll();
if (empty($rows)) break;
foreach ($rows as $row) {
fputcsv($fp, $row);
$lastId = $row['id']; // 关键:用 id 代替 OFFSET
}
// 如果内存仍然高,可加 unset($rows);
$continue = count($rows) == 10000;
} while ($continue);
fclose($fp);
| 场景 | 推荐方案 | 内存占用 |
|---|---|---|
| 有 MySQL,且数据量百万级 | MySQL INTO OUTFILE |
极低 |
| 有 MySQL,但远程或需要实时过滤 | PDO unbuffered + 生成器 |
恒定 |
| PostgreSQL 数据库 | COPY TO 或 事务+游标 |
极低 |
| 数据在文件里(CSV/JSON)需要转换 | SplFileObject 流式读取 |
低 |
| 必须使用共享主机(无法配置 PDO 选项) | LIMIT + 游标轮询 |
可控制 |
关键注意事项:
- 设置脚本超时:
set_time_limit(0);或ignore_user_abort(true); - 输出缓冲:
while (ob_get_level()) ob_end_clean();清掉之前的输出缓冲。 - 数据库连接:使用
PDO或mysqli的非缓冲查询时必须确保没有其他活跃查询。 - 最终文件下载:用
readfile()代替file_get_contents(),避免第二次占用内存。 - 文件锁定:如果导出文件是生成在 Web 目录,注意加
.htaccess或权限,防止别人直接访问。
选择方案时,优先考虑方案一(生成器+游标),因为它最通用且可控,若数据库权限允许则直接使用方案二(原生导出)达到性能极致。