本文目录导读:

这是一个很实用的需求,在大数据量导出(如导出几万、几十万条记录到 Excel/CSV)时,直接一次性查询并写入,极易导致内存溢出或脚本执行超时。
实现数据分段导出的核心思想是:分批查询 + 分批写入 + 即时输出(或分文件)。
以下是几种主流的实现方案和关键代码示例:
使用生成器 (Generator) + 流式输出 (CSV)
这是最常见、内存占用最低的方法,利用 PHP 生成器,每次只从数据库读取一行,并立即写入输出流。
适用场景: 导出 CSV 文件(文本格式),对内存要求极严苛。
<?php
// 1. 设置超时和内存(虽然为了安全,但好的代码不依赖大内存)
set_time_limit(0);
ini_set('memory_limit', '512M'); // 适当放大,但后面靠算法控制
// 2. 模拟数据库查询生成器
function fetchRowsInGenerator($offset, $limit) {
// 假设是 PDO 连接
global $pdo;
$stmt = $pdo->prepare("SELECT id, name, email FROM users LIMIT :offset, :limit");
$stmt->bindParam(':offset', $offset, PDO::PARAM_INT);
$stmt->bindParam(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
// 使用 yield 逐行返回,而不是 fetchAll()
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield $row;
}
}
// 3. 设置输出头,直接输出到浏览器
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="users_'.date('Ymd').'.csv"');
$output = fopen('php://output', 'w');
// 写入 BOM 头,解决 Excel 乱码
fwrite($output, "\xEF\xBB\xBF");
fputcsv($output, ['ID', '姓名', '邮箱']);
// 4. 分段导出逻辑
$pageSize = 1000; // 每页处理1000条
$offset = 0;
// 先获取总条数(或一直查直到结果为空)
$total = $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn();
$exported = 0;
while ($exported < $total) {
// 每一轮循环,只获取1000条
$generator = fetchRowsInGenerator($offset, $pageSize);
foreach ($generator as $row) {
fputcsv($output, $row);
$exported++;
}
$offset += $pageSize;
// 可选:为浏览器刷新缓冲区,防止超时(对于大型导出很重要)
ob_flush();
flush();
// 如果总数据量极大,可以考虑在这里做一次简单的睡眠,避免数据库压力过大
// usleep(100000); // 0.1秒
}
fclose($output);
exit;
?>
优点: 内存占用恒定(只占一行数据的内存)。 缺点: 不适合 Excel (xlsx) 格式,因为 xlsx 是二进制结构。
分文件导出 + 打包下载
当需要导出为 Excel (.xlsx) 或数据量特别大(如 50万条)时,建议生成多个临时文件,最后打包成 ZIP。
适用场景: 对格式要求高(xlsx),或需要压缩传输。
<?php
set_time_limit(0);
ini_set('memory_limit', '1024M');
require_once 'vendor/autoload.php'; // PhpSpreadsheet
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
// 1. 创建临时文件夹
$tempDir = sys_get_temp_dir() . '/export_' . uniqid();
mkdir($tempDir, 0777, true);
// 2. 定义分段参数
$pageSize = 5000; // 每5000条一个文件
$page = 1;
$offset = 0;
// 假设获取总条数
$total = 150000;
$filePaths = [];
while ($offset < $total) {
// 2.1 查询数据
$data = $pdo->query("SELECT * FROM users LIMIT $offset, $pageSize")->fetchAll(PDO::FETCH_ASSOC);
if (empty($data)) break;
// 2.2 创建新 Excel 对象
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// 写入表头(只在第一个文件写,或都写)
$sheet->fromArray(['ID', '姓名', '邮箱'], NULL, 'A1');
// 写入数据
$sheet->fromArray($data, NULL, 'A2');
// 2.3 保存临时文件
$filename = "users_part_{$page}.xlsx";
$filePath = $tempDir . '/' . $filename;
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
$filePaths[] = $filePath;
// 清理当前 Excel 对象,释放内存
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
$offset += $pageSize;
$page++;
}
// 3. 打包所有分片为 ZIP
$zip = new ZipArchive();
$zipFile = $tempDir . '/all_users.zip';
$zip->open($zipFile, ZipArchive::CREATE);
foreach ($filePaths as $file) {
$zip->addFile($file, basename($file));
}
$zip->close();
// 4. 输出 ZIP 文件给浏览器
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="users_export.zip"');
header('Content-Length: ' . filesize($zipFile));
readfile($zipFile);
// 5. 清理临时文件
array_map('unlink', $filePaths);
rmdir($tempDir);
exit;
?>
优点: 兼容 xlsx 格式;失败后只需重新导出失败的分片(理论上支持断点续传)。 缺点: 需要磁盘空间存临时文件;需要用户解压。
异步任务 + 轮询下载(推荐用于生产)
对于超大规模数据(几十万条以上),任何“即时下载”都可能因为浏览器等待而超时,最佳实践是把导出做成异步任务。
流程图:
- 用户点击导出。
- 后端生成一个
task_id,将导出任务放入消息队列(或数据库任务表)。 - 后台进程(Cron / Supervisor)拉取任务,分批次将数据写入文件。
- 用户轮询
/status?task_id=xxx,直到任务状态变为completed。 - 用户通过
/download?task_id=xxx下载最终文件。
关键代码片段(核心逻辑):
// 任务处理器(后台进程执行)
function processExportTask($taskId) {
// 1. 更新任务状态为 'processing'
updateTaskStatus($taskId, 'processing');
$filePath = storage_path("exports/{$taskId}.csv");
$handle = fopen($filePath, 'w');
$offset = 0;
$batchSize = 1000;
while (true) {
$data = fetchData($offset, $batchSize);
if (empty($data)) break;
foreach ($data as $row) {
fputcsv($handle, $row);
}
$offset += $batchSize;
// 更新进度(可选)
updateTaskProgress($taskId, $offset);
}
fclose($handle);
// 2. 更新任务状态为 'completed'
updateTaskStatus($taskId, 'completed');
}
优点: 不影响用户体验(点击后关闭页面,后台继续);支持大文件;易于扩展。 缺点: 实现复杂,需要 Redis/数据库做任务管理。
性能对比与建议
| 方案 | 内存占用 | 响应速度 | 格式支持 | 开发成本 | 推荐数据量 |
|---|---|---|---|---|---|
| 生成器+CSV | 极低 | 快(流式) | CSV | 低 | < 10万行 |
| PhpSpreadsheet 分片 | 中等 | 慢(文件IO) | XLSX | 中 | 5-50万行 |
| 异步任务 | 低 | 延迟(邮件/URL) | 任意 | 高 | > 10万行 |
注意事项
- MySQL
LIMIT的性能问题: 当offset非常大时(如 100万),LIMIT 1000000, 1000会非常慢。优化方案:- 使用 游标分页:
SELECT * FROM users WHERE id > last_id ORDER BY id ASC LIMIT 1000,利用主键索引,速度极快。 - 代码示例替换:
function fetchRowsAfterId($lastId, $limit) { $stmt = $pdo->prepare("SELECT * FROM users WHERE id > ? ORDER BY id ASC LIMIT ?"); $stmt->execute([$lastId, $limit]); foreach ($stmt as $row) yield $row; }
- 使用 游标分页:
- 超时设置: 始终在脚本开头执行
set_time_limit(0);和ini_set('memory_limit', '1024M');。 - 防止重复/丢失数据: 如果导出过程中数据库有新数据插入,使用游标分页(基于ID)不会影响数据完整性,因为新数据的ID更大。
根据你的具体需求(数据量大小、是否需要 Excel 格式、系统并发量),选择最合适的方案,如果是刚刚开始,建议从方案一(生成器+CSV) 入手,最简洁且性能可靠。