1. 从 20 分钟超时到 50 秒导出:百万行 Excel 的真实困境
Laravel 项目里做 Excel 导出,很多人第一反应是maatwebsite/excel,它封装得好、上手快,写个Export类加FromCollection就能跑。但供应链、财务、订单这类系统一旦单表数据突破百万级,这套组合就会暴露两个致命问题:导出慢到超时,以及内存直接打满把 PHP-FPM 拖垮。我遇到过的场景是 5 万条数据导出要 20 分钟,30 万条直接 504,百万级根本不敢点。
这篇文章聚焦的就是这个场景:Laravel 中使用 xlsWriter 导出百万行 Excel 的内存优化。我会对比 phpexcel 时代的内存瓶颈,给出可复制的固定内存模式配置骨架,以及导出后如何验证文件正确、内存是否真的降下来。适合已经写过基础导出、但被大数据量卡住的 Laravel 开发者。核心检索词先摆出来:laravel、xlsWriter、phpexcel、Excel、内存模式,这几个词贯穿全文。
先说结论,避免你走弯路。xlsWriter 是 C 扩展,底层直接写 xlsx 二进制流,不走 PHP 对象树,所以它天生比 phpexcel 省内存。但省内存不等于不占内存,默认模式下它仍然会把部分数据缓存在内存里。真正让百万行导出从 8G 降到 2G 的,是固定内存模式(constMemory)加上 Laravel 侧的游标查询和查询日志禁用。这三件事缺一不可,只做 xlsWriter 替换,内存问题依然存在。
下面按「问题定位 → 环境准备 → 配置骨架 → 验证 → 排障」的顺序展开,每一步都给可复制的代码和命令。
2. 为什么 phpexcel 在百万行场景必然崩
2.1 phpexcel 的内存模型
phpexcel(以及它的继任者 PhpSpreadsheet)是纯 PHP 实现,它把整个工作簿抽象成一棵对象树:Workbook → Worksheet → Cell → Style。每写一个单元格,就 new 一个 Cell 对象,附带样式、数据类型、坐标等属性。一个 Cell 对象在 PHP 里大约占几百字节到 1KB,百万行乘以 20 列就是 2000 万个 Cell 对象,光对象本身就要吃掉几个 G,再加上 PHP 的 zval、哈希表开销,8G 内存是保守估计。
更麻烦的是,phpexcel 默认会把所有数据先攒在内存里,最后save()时才一次性写盘。这意味着导出过程中内存只增不减,峰值出现在写盘前那一刻。你看到的「导出到 90% 卡死」,往往就是内存耗尽被 OOM Killer 干掉。
2.2 xlsWriter 的差异
xlsWriter 是 libxlsxwriter 的 PHP 封装,用 C 写 xlsx。它的写入是流式的:insertText直接把数据写进文件缓冲区,不构造 PHP 对象树。所以同样百万行,xlsWriter 的 PHP 侧内存占用主要来自你传给它的数组,而不是单元格对象。
但这里有个坑:如果你用fileName()模式,xlsWriter 内部仍会维护一份行索引和共享字符串表,数据量越大这份表越大。constMemory()模式则把这份表也省掉,代价是牺牲一部分写入速度。这就是为什么开启固定内存模式后,导出时间从 50 秒涨到 1 分 5 秒,但内存从 8G 降到 2G 多。
注意:固定内存模式不是「零内存」,它只是把内存占用控制在一个常量级别,不随行数线性增长。你仍然要控制单次导出的数据规模。
3. TaoToken 前置:把模型对话和编码辅助接进来
在动手改代码之前,先解决一个实际问题:这类内存优化涉及大量参数试错,比如constMemory的 sheet 名、setColumn的宽度、setFormula的行偏移,靠翻文档效率很低。我的做法是把 TaoToken 的模型对话接进来,遇到报错直接贴给它分析,比自己搜快很多。
TaoToken 是一个模型调用入口,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。它本身不替代你的编辑器,也不碰你的生产数据库,只是提供一个统一的模型调用通道。你可以把它理解成「给 Laravel 项目配一个随时能问的助手」。
具体接入分两步。第一步拿 Key,进控制台创建 API Key:
- 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
- API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
第二步,如果你只是想在浏览器里快速验证某个 xlsWriter 参数怎么写,直接用模型对话页面:
- 模型对话:https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite
如果你打算长期做这类编码优化,甚至让 Agent 帮你批量改导出类,可以看 Coding Plan:
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
接入文档在这里,里面有 OpenAI 兼容格式的调用示例,Laravel 里用 Guzzle 就能发请求:
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
如果你用 Claude Code 做开发,也有对应的配置说明:
- ClaudeCodeAnthropic:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude-code-anthropic&utm_campaign=rewrite
把 Key 配到.env里,别硬编码:
TAOTOKEN_API_KEY=sk-你的key TAOTOKEN_BASE_URL=https://taotoken.net/api这样你在写导出类时,遇到constMemory报错或者mergeCells坐标不对,可以直接把错误栈丢给模型对话,让它结合 xlsWriter 文档给你定位。这一步不是必须的,但能省掉大量翻文档的时间。
4. 可复制的固定内存模式配置骨架
4.1 安装扩展与依赖
先确认 xlsWriter 扩展装好。它是 PECL 扩展,不是 composer 包:
pecl install xlswriter然后在php.ini里加:
extension=xlswriter.so验证扩展加载:
php -m | grep xlswriter输出xlswriter就说明装好了。接着装 phpexcel,注意这里用 phpexcel 只是为了拿它的数字格式常量,不是用它导出:
composer require phpoffice/phpexcel 1.8注意:phpexcel 已经停止维护,但它的
PHPExcel_Style_NumberFormat常量在 xlsWriter 里仍然好用。如果你不想引入这个包,可以自己定义格式字符串,比如'@'表示文本,'0.00'表示两位小数。
4.2 封装一个 XlsWriter 导出类
下面这个类是我实际项目里用的骨架,去掉了业务耦合,保留核心方法。关键点是setFileName的第三个参数$memoryMode,传true就走constMemory。
<?php namespace App\Support\Excel; use Vtiful\Kernel\Excel; use Vtiful\Kernel\Format; class XlsWriter { private $defaultWidth = 16; private $defaultHeight = 30; private $exportType = '.xlsx'; private $maxHeight = 1; private $fileName = null; private $defaultFormulaTop = 2; private $maxDataLine = 2; private $defaultCellFormat = 'general'; private $allowCellFormat = [ 'general' => \PHPExcel_Style_NumberFormat::FORMAT_GENERAL, 'text' => \PHPExcel_Style_NumberFormat::FORMAT_TEXT, ]; const CELL_ACT_MERGE = 'merge'; const CELL_ACT_BACKGROUND = 'background'; const ACT_MERGE_START = 'start'; const ACT_MERGE_END = 'end'; private $allowCellActs = [self::CELL_ACT_MERGE, self::CELL_ACT_BACKGROUND]; private $cellActs = []; private $xlsObj; private $fileObject; private $format; private $boldIStyle; private $colManage; private $lastColumnCode; public function __construct() { $path = public_path('download/xlsExcel'); if (!file_exists($path)) { mkdir($path, 0777, true); } $this->xlsObj = new Excel(['path' => $path]); } /** * @param string $fileName 文件名 * @param string $sheetName 首个 sheet 名 * @param bool $memoryMode 是否开启固定内存模式 */ public function setFileName(string $fileName = '', string $sheetName = 'Sheet1', bool $memoryMode = false) { $fileName = empty($fileName) ? (string)time() : $fileName; $fileName .= $this->exportType; $this->fileName = $fileName; if ($memoryMode) { $this->fileObject = $this->xlsObj->constMemory($fileName, $sheetName); } else { $this->fileObject = $this->xlsObj->fileName($fileName, $sheetName); } $this->format = new Format($this->fileObject->getHandle()); } public function setHeader(array $header) { if (empty($header)) { throw new \Exception('表头数据不能为空'); } if (is_null($this->fileName)) { $this->setFileName(time()); } $colManage = $this->setHeaderNeedManage($header); $this->colManage = $this->completeColMerge($colManage); $this->lastColumnCode = $this->getColumn(end($this->colManage)['cursorEnd']) . $this->maxHeight; $this->queryMergeColumn(); } public function setData(array $data) { $indexRow = $this->maxHeight + 1; $indexCol = 0; foreach ($data as $row => $datum) { foreach ($datum as $column => $value) { if (is_array($value)) { $val = $value[0]; $act = $value[1]; $pos = $this->getColumn($indexCol) . $indexRow; $availableActs = array_intersect($this->allowCellActs, array_keys($act)); foreach ($availableActs as $availableAct) { $index = $act['uniqueId'] ?? $indexCol; switch ($availableAct) { case self::CELL_ACT_MERGE: $this->cellActs[$index][self::CELL_ACT_MERGE][$act[$availableAct]] = $pos; $this->cellActs[$index][self::CELL_ACT_MERGE]['val'] = $val; break; case self::CELL_ACT_BACKGROUND: $this->cellActs[$index][self::CELL_ACT_BACKGROUND][] = [ 'row' => $row, 'column' => $column, 'color' => $act[$availableAct], 'val' => $val, ]; break; } } } else { $this->fileObject->insertText($row + $this->maxHeight, $column, $value); } $indexCol++; } $indexRow++; $indexCol = 0; } $this->queryCellActs(); $this->maxDataLine = $this->maxHeight + count($data); } public function setFreezeHeader() { $this->fileObject->freezePanes($this->maxHeight, 0); } public function setFilter($line = 'A1') { $this->fileObject->autoFilter("$line:{$this->lastColumnCode}"); } public function setBoldHeader() { $this->boldIStyle = $this->format->bold()->toResource(); $this->fileObject->setRow("A1:{$this->lastColumnCode}", $this->defaultHeight, $this->boldIStyle); } public function setDefaultFormatData() { $setData = new Format($this->fileObject->getHandle()); $this->fileObject->defaultFormat( $setData->align(Format::FORMAT_ALIGN_CENTER, Format::FORMAT_ALIGN_VERTICAL_CENTER) ->border(Format::BORDER_THIN) ->toResource() ); } public function output() { return $this->fileObject->output(); } public function excelDownload($filePath) { $fileName = $this->fileName; $userBrowser = $_SERVER['HTTP_USER_AGENT'] ?? ''; if (preg_match('/MSIE/i', $userBrowser)) { $fileName = urlencode($fileName); } else { $fileName = iconv('UTF-8', 'GBK//IGNORE', $fileName); } header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header('Content-Disposition: attachment;filename="' . $fileName . '"'); header('Content-Length: ' . filesize($filePath)); header('Content-Transfer-Encoding: binary'); header('Cache-Control: must-revalidate'); header('Cache-Control: max-age=0'); header('Pragma: public'); if (ob_get_contents()) { ob_clean(); } flush(); if (copy($filePath, 'php://output') === false) { throw new \Exception($filePath . ' 地址出问题了'); } @unlink($filePath); exit(); } private function setHeaderNeedManage($header, $col = 1, &$cursor = 0, &$colManage = [], $parent = null, $parentList = []) { foreach ($header as $head) { if (empty($head['title'])) { throw new \Exception('表头数据格式有误'); } if (is_null($parent)) { $parentList = []; $col = 1; } else { foreach ($colManage as $value) { if ($value['parent'] == $parent) { $parentList = $value['parentList']; $col = $value['height']; break; } } } $column = $this->getColumn($cursor) . $col; $format = $this->allowCellFormat[$this->defaultCellFormat]; if (!empty($head['format'])) { if (!isset($this->allowCellFormat[$head['format']])) { throw new \Exception("不支持的单元格格式{$head['format']}"); } $format = $this->allowCellFormat[$head['format']]; } $colManage[$column] = [ 'title' => $head['title'], 'cursor' => $cursor, 'cursorEnd' => $cursor, 'height' => $col, 'width' => $this->defaultWidth, 'format' => $format, 'mergeStart' => $column, 'hMergeEnd' => $column, 'zMergeEnd' => $column, 'parent' => $parent, 'parentList' => $parentList, ]; if (!empty($head['children']) && is_array($head['children'])) { $col += 1; $parentList[] = $column; $this->setHeaderNeedManage($head['children'], $col, $cursor, $colManage, $column, $parentList); } else { $cursor += 1; } } return $colManage; } private function completeColMerge($colManage) { $this->maxHeight = max(array_column($colManage, 'height')); $parentManage = array_column($colManage, 'parent'); foreach ($colManage as $index => $value) { if (!is_null($value['parent']) && !empty($value['parentList'])) { foreach ($value['parentList'] as $parent) { $colManage[$parent]['hMergeEnd'] = $this->getColumn($value['cursor']) . $colManage[$parent]['height']; $colManage[$parent]['cursorEnd'] = $value['cursor']; } } $checkChildren = array_search($index, $parentManage); if ($value['height'] < $this->maxHeight && !$checkChildren) { $colManage[$index]['zMergeEnd'] = $this->getColumn($value['cursor']) . $this->maxHeight; } } return $colManage; } private function queryMergeColumn() { foreach ($this->colManage as $value) { $this->fileObject->mergeCells("{$value['mergeStart']}:{$value['zMergeEnd']}", $value['title']); $this->fileObject->mergeCells("{$value['mergeStart']}:{$value['hMergeEnd']}", $value['title']); if ($value['cursor'] != $value['cursorEnd']) { $value['width'] = ($value['cursorEnd'] - $value['cursor'] + 1) * $this->defaultWidth; } $formatCell = new Format($this->fileObject->getHandle()); $boldStyle = $formatCell->number($value['format'])->toResource(); $toColumnStart = $this->getColumn($value['cursor']); $toColumnEnd = $this->getColumn($value['cursorEnd']); $this->fileObject->setColumn("{$toColumnStart}:{$toColumnEnd}", $value['width'], $boldStyle); } } private function queryCellActs() { if (empty($this->cellActs)) { return; } foreach ($this->cellActs as $actNote) { $tmpActStyle = new Format($this->fileObject->getHandle()); if (isset($actNote[self::CELL_ACT_BACKGROUND])) { foreach ($actNote[self::CELL_ACT_BACKGROUND] as $item) { $tmpActStyle->background($this->backgroundConst($item['color'])) ->border(Format::BORDER_THIN); $this->fileObject->insertText( $item['row'] + $this->maxHeight, $item['column'], $item['val'], '', $tmpActStyle->toResource() ); } } if (isset($actNote[self::CELL_ACT_MERGE])) { if (!empty($actNote[self::CELL_ACT_MERGE][self::ACT_MERGE_START]) && !empty($actNote[self::CELL_ACT_MERGE][self::ACT_MERGE_END])) { $tmpActStyle->align(Format::FORMAT_ALIGN_CENTER, Format::FORMAT_ALIGN_VERTICAL_CENTER) ->border(Format::BORDER_THIN); $this->fileObject->mergeCells( "{$actNote[self::CELL_ACT_MERGE][self::ACT_MERGE_START]}:{$actNote[self::CELL_ACT_MERGE][self::ACT_MERGE_END]}", $actNote[self::CELL_ACT_MERGE]['val'], $tmpActStyle->toResource() ); } } } $this->cellActs = []; } private function backgroundConst($color) { $const = [ 'black' => Format::COLOR_BLACK, 'blue' => Format::COLOR_BLUE, 'brown' => Format::COLOR_BROWN, 'cyan' => Format::COLOR_CYAN, 'gray' => Format::COLOR_GRAY, 'green' => Format::COLOR_GREEN, 'lime' => Format::COLOR_LIME, 'magenta' => Format::COLOR_MAGENTA, 'navy' => Format::COLOR_NAVY, 'orange' => Format::COLOR_ORANGE, 'pink' => Format::COLOR_PINK, 'purple' => Format::COLOR_PURPLE, 'red' => Format::COLOR_RED, 'silver' => Format::COLOR_SILVER, 'white' => Format::COLOR_WHITE, 'yellow' => Format::COLOR_YELLOW, ]; return $const[$color] ?? $color; } private function getColumn($num) { return Excel::stringFromColumnIndex($num); } }这个类里最关键的三个方法:setFileName的$memoryMode参数决定是否走constMemory;setData用insertText逐格写入,不攒对象;queryCellActs把合并和背景色延迟到数据写完后统一处理,避免边写边改样式导致内存膨胀。
4.3 Laravel 侧的游标查询与日志禁用
光换 xlsWriter 不够,数据从数据库取出来的方式也得改。默认的->get()会把整个结果集加载成 Eloquent 集合,百万行直接爆内存。改成cursor():
use Illuminate\Support\Facades\DB; DB::connection()->disableQueryLog(); $query = DB::table('account_receivable') ->where('created_at', '>=', $startDate) ->where('created_at', '<=', $endDate) ->orderBy('store_id') ->orderBy('account_title_id'); $data = []; foreach ($query->cursor() as $row) { $data[] = [ $row->store_name, $row->account_title, $row->month, $row->amount, $row->wait_amount_sum, $row->actually_amount, ]; }cursor()底层用yield,每次只从 PDO 取一行,内存占用是常量级。disableQueryLog()更重要,Laravel 默认把每条 SQL 存进内存日志,百万行查询就是百万条日志,光这个就能吃掉几个 G。
4.4 调用入口
把上面拼起来,导出动作长这样:
public function export(Request $request) { DB::connection()->disableQueryLog(); $fileName = '应收账表格导出' . date('YmdHis'); $writer = new XlsWriter(); // 第三个参数 true 开启固定内存模式 $writer->setFileName($fileName, '应收账汇总', true); $header = [[ 'title' => '应收账明细', 'children' => [ ['title' => '项目'], ['title' => '日期'], ['title' => '摘要'], ['title' => '应收金额'], ['title' => '是否已开票'], ['title' => '已收金额'], ['title' => '未收金额'], ['title' => '备注'], ], ]]; $writer->setHeader($header); $writer->setFreezeHeader(); $writer->setDefaultFormatData(); $rows = []; foreach ($this->buildQuery($request)->cursor() as $row) { $rows[] = [ $row->store_name, $row->account_title, $row->month, $row->amount, $row->wait_amount_sum, $row->actually_amount, ]; } $writer->setData($rows); $writer->setBoldHeader(); $writer->setFilter('A2'); $filePath = $writer->output(); $writer->excelDownload($filePath); }注意$rows这里仍然是个数组,百万行的话这个数组本身也占内存。如果你要极致省内存,应该把setData改成接收生成器,边取边写。但 xlsWriter 的insertText是逐格调用,你可以直接在foreach里调insertText,不攒$rows。这是下一步优化点,后面排障部分会讲。
5. 验证请求与成功结果
5.1 用 Artisan 命令跑一次导出
别在浏览器里点,浏览器有超时限制。写个 Artisan 命令:
php artisan make:command ExportTestExcel在handle里调用导出逻辑,然后跑:
php artisan export:test-excel --rows=1000000同时开另一个终端监控内存:
while true; do ps -o rss= -p $(pgrep -f 'export:test-excel') 2>/dev/null | awk '{printf "%.2f MB\n", $1/1024}'; sleep 2; done5.2 预期结果对照
| 指标 | phpexcel 默认 | xlsWriter 默认 | xlsWriter 固定内存 |
|---|---|---|---|
| 5 万行耗时 | 20 分钟 | 1 秒多 | 1 秒多 |
| 100 万行耗时 | 超时/OOM | 约 50 秒 | 约 1 分 5 秒 |
| 100 万行峰值内存 | 8G+ | 8G 左右 | 2G 多 |
| 是否可导出 | 否 | 是 | 是 |
这个表是我实测下来的量级,具体数字跟列数、样式复杂度有关。关键看趋势:固定内存模式用十几秒的时间换了几 G 的内存,对服务器稳定性来说非常值。
5.3 验证文件正确性
导出完成后别急着交付,先验证:
# 看文件大小,百万行 xlsx 通常在几十 MB 到几百 MB ls -lh storage/app/download/xlsExcel/ # 用 unzip 检查 xlsx 内部结构是否完整 unzip -l 应收账表格导出20240101120000.xlsx | head -20再用 LibreOffice 或 Excel 打开,重点检查三处:表头合并是否正确、冻结窗格是否生效、筛选按钮是否出现。如果文件能打开但样式错乱,多半是mergeCells坐标算错了。
6. 本篇常见错排查
6.1 constMemory 报「sheet name already exists」
固定内存模式下,constMemory($fileName, $sheetName)的 sheet 名不能和后续addSheet重名。如果你先constMemory建了Sheet1,又addSheet('Sheet1'),就会报这个错。解决方法是第一个 sheet 名用业务名,后续addSheet用不同名字。
6.2 内存没降下来
检查三件事:disableQueryLog()有没有调;查询是不是还在用->get();setData传的数组是不是一次性攒了百万行。前两个最常见。第三个如果确实要攒,考虑改成分批:每 1 万行调一次setData,但注意setData内部的行号是从maxHeight + 1重新算的,分批调会覆盖。正确做法是直接操作fileObject->insertText,自己维护行号。
6.3 导出文件打不开或提示损坏
多半是output()之后又往文件里写了东西,或者excelDownload里copy失败但没抛异常。检查output()返回的路径是否存在,以及filesize是否大于 0。另外ob_clean()之前如果有输出,会导致文件头被污染。
6.4 中文文件名乱码
iconv('UTF-8', 'GBK//IGNORE', $fileName)这行在部分环境会失败。如果乱码,改成rawurlencode或者直接用英文文件名加时间戳,前端再重命名。
6.5 公式不计算
setFormula插入的公式,Excel 打开时可能显示为 0 或空白,需要手动触发重算。这是 xlsx 格式的特性,不是 bug。如果必须自动算,可以在公式里用{start}和{end}占位,xlsWriter 会替换成实际行号,但计算仍由 Excel 完成。
6.6 时间跨度控制
百万行导出对服务器压力大,建议在业务层限制导出范围。比如最多导出 365 天数据,超过就提示用户缩小范围。这不是技术限制,是保护措施。我试过让用户一次导三年,结果内存直接顶到 4G,加了时间限制后稳定在 2G 以内。
7. 语义一致收尾:把导出能力接进你的工作流
到这里,Laravel + xlsWriter 的百万行导出骨架已经完整了:固定内存模式配置、游标查询、日志禁用、验证方法、排障清单。你可以直接把这套代码复制到项目里,改改表头和数据映射就能用。
如果你在调参过程中遇到constMemory的边界问题,或者想让人帮你 review 导出类的内存占用,可以用 TaoToken 的模型对话快速验证:
- 模型对话:https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite
长期做这类编码优化的话,Coding Plan 更适合,能把导出类的重构、分批写入、异步队列这些活串起来:
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
Key 在控制台拿,接入文档里有完整的 OpenAI 兼容调用示例:
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
最后留一个我踩过的坑:固定内存模式下,setColumn设置的列宽如果超过 255 字符,xlsWriter 会静默截断,导出的文件列宽不对但不报错。检查方法是打开文件看列宽,或者把宽度控制在 200 以内。这个坑花了我半天才定位到,希望你能跳过。