学完这篇,你能拿到一套从「50MB Excel 一读就 512MB 内存超限」到「几百 MB 文件稳定流式处理」的完整改造路径,包含具体命令、参数和代码片段。
第一步:先量化瓶颈,别盲目改代码
在动手前,先在你的读取脚本开头和结尾各插一行:
$t0 = microtime(true);
// ... 读取逻辑 ...
error_log(sprintf('耗时 %.2fs 峰值内存 %.1fMB', microtime(true)-$t0, memory_get_peak_usage(true)/1048576));
三种典型结果对应三条路:
- 峰值内存 ≈ 文件体积的 10~30 倍 → 内存问题,走第三、四步的流式方案;
- 内存正常但耗时长 → CPU 问题,看是否开了 Xdebug(
php -i | grep xdebug),线上必须关掉;
- 都还行只是偶尔超时 → 调
max_execution_time,或在 CLI 下跑。
注意:本地 php.ini 和 FPM 池里的 memory_limit 是两套配置,改完记得 systemctl reload php-fpm(宝塔面板在「软件商店 → PHP → 配置修改」里改)。
第二步:PhpSpreadsheet 只读必要数据
PHPExcel 早已停止维护,换成 PhpSpreadsheet:
composer require phpoffice/phpspreadsheet
关键是三个开关,缺一个效果都打折:
use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
$reader = new Xlsx();
$reader->setReadDataOnly(true); // 不解析样式、公式、图表
$reader->setLoadSheetsOnly(['订单明细']); // 只加载需要的 sheet
$spreadsheet = $reader->load($path);
$rows = $spreadsheet->getActiveSheet()->toArray(null, true, false, false);
再加一个 ReadFilter 把列卡死,避免用户上传带 1 万列的脏表:
$reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter {
public function readCell($columnAddress, $row, $worksheetName = '') {
return $row <= 100000 && in_array($columnAddress, range('A','L'), true);
}
});
这套组合通常能把内存压到原来的 1/3 左右,但它仍然是全量建对象,文件过百 MB 还是会炸。
注意:setReadDataOnly(true) 后日期列会变成数字序列号,需要自己 \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject() 转回来,别直接入库。
第三步:换流式读取器,内存不再随行数涨
真正解决内存的是流式库。推荐 OpenSpout(原 box/spout):
composer require openspout/openspout
$reader = new \OpenSpout\Reader\XLSX\Reader();
$reader->open($path);
foreach ($reader->getSheetIterator() as $sheet) {
if ($sheet->getName() !== '订单明细') continue;
foreach ($sheet->getRowIterator() as $row) {
$cells = $row->getCells(); // 只保留当前这一行
$data = array_map(fn($c) => $c->getValue(), $cells);
yield $data; // 交给外层批量入库
}
}
$reader->close();
内存曲线从此是一条平线(通常 10~30MB),与文件行数无关。代价是不支持样式和随机跳行,但导入场景基本用不到。
注意:OpenSpout v4 的命名空间和 v3 不同(v3 用 ReaderEntityFactory)。装错版本会报类找不到,用 composer show openspout/openspout 确认版本后对照官方文档写。
第四步:极端场景直接扔给命令行
xlsx 本质上就是个 zip 包,里面 xl/worksheets/sheet1.xml 是行数据。如果连扩展都不想装,转 CSV 是最省事的:
libreoffice --headless --convert-to csv --outdir /tmp /path/to/big.xlsx
然后 PHP 端流式读:
$fp = fopen('/tmp/big.csv', 'r');
while (($row = fgetcsv($fp)) !== false) {
// 处理 $row
}
fclose($fp);
也可以自己用 XMLReader 打开 zip://文件路径#xl/worksheets/sheet1.xml 逐节点读,配合 simplexml_load_string($xmlReader->readOuterXML()) 单行解析。这条路最快但要自己处理 sharedStrings.xml 里的字符串索引,没有硬需求别上。
注意:LibreOffice 转出的 CSV 是 UTF-8 无 BOM,但用户上传的 CSV 常是 GBK。读之前先 mb_detect_encoding 判断并 mb_convert_encoding,否则中文全是乱码。
第五步:入库环节别拖后腿
读取优化完了,写入拖垮整体很常见。三条规则:
- 关自动提交,分批 commit:每 500~1000 行一次
commit(),比逐行提交快十倍以上;
- 复用预处理语句:
$stmt->execute($params) 循环调用,别在循环里拼 SQL;
- CLI 跑长任务:Web 请求里
set_time_limit(0) 也扛不住浏览器超时,用 php import.php 走命令行,配合 nohup 后台跑。
$pdo->beginTransaction();
$i = 0;
foreach ($rows as $row) {
$stmt->execute($row);
if (++$i % 1000 === 0) { $pdo->commit(); $pdo->beginTransaction(); }
}
$pdo->commit();
最后记得在循环里 unset($row),大数组残留是内存不降的常见原因。
小结
- 先量化:
memory_get_peak_usage(true) 判断是内存还是 CPU 问题,线上关掉 Xdebug;
- PhpSpreadsheet 三件套:
setReadDataOnly(true) + setLoadSheetsOnly() + ReadFilter,能省 2/3 内存但仍有上限;
- 上百 MB 文件用 OpenSpout 流式读取,内存与行数解耦;
- 实在不行转 CSV 用
fgetcsv,或命令行 libreoffice --headless;
- 入库分批提交、复用预处理语句、长任务走 CLI。