尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Laravel 百万级数据导出实战:xlsWriter 内存模式配置与验证

发布时间:2026/9/29 6:30:48

资讯中心
01
ARTICLE

Laravel 百万级数据导出实战:xlsWriter 内存模式配置与验证

Laravel 百万级数据导出实战:xlsWriter 内存模式配置与验证
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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。它本身不替代你的编辑器也不碰你的生产数据库只是提供一个统一的模型调用通道。你可以把它理解成「给 Laravel 项目配一个随时能问的助手」。具体接入分两步。第一步拿 Key进控制台创建 API Key控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite第二步如果你只是想在浏览器里快速验证某个 xlsWriter 参数怎么写直接用模型对话页面模型对话https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite如果你打算长期做这类编码优化甚至让 Agent 帮你批量改导出类可以看 Coding PlanCoding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite接入文档在这里里面有 OpenAI 兼容格式的调用示例Laravel 里用 Guzzle 就能发请求接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite如果你用 Claude Code 做开发也有对应的配置说明ClaudeCodeAnthropichttps://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-code-anthropicutm_campaignrewrite把 Key 配到.env里别硬编码TAOTOKEN_API_KEYsk-你的key TAOTOKEN_BASE_URLhttps://taotoken.net/api这样你在写导出类时遇到constMemory报错或者mergeCells坐标不对可以直接把错误栈丢给模型对话让它结合 xlsWriter 文档给你定位。这一步不是必须的但能省掉大量翻文档的时间。4. 可复制的固定内存模式配置骨架4.1 安装扩展与依赖先确认 xlsWriter 扩展装好。它是 PECL 扩展不是 composer 包pecl install xlswriter然后在php.ini里加extensionxlswriter.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-age0); 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参数决定是否走constMemorysetData用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 --rows1000000同时开另一个终端监控内存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 万行峰值内存8G8G 左右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_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite长期做这类编码优化的话Coding Plan 更适合能把导出类的重构、分批写入、异步队列这些活串起来Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewriteKey 在控制台拿接入文档里有完整的 OpenAI 兼容调用示例API Keyshttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite最后留一个我踩过的坑固定内存模式下setColumn设置的列宽如果超过 255 字符xlsWriter 会静默截断导出的文件列宽不对但不报错。检查方法是打开文件看列宽或者把宽度控制在 200 以内。这个坑花了我半天才定位到希望你能跳过。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。