ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Laravel大文件导出超时解决方案:异步队列与进度轮询实战

Laravel大文件导出超时解决方案:异步队列与进度轮询实战 1. 先说我为什么把“调大超时时间”这条最顺的路直接否掉了前两个月接到一个紧急工单运营同事要导三周的订单明细页面转了四十多秒直接变成504用户侧看到的就是一个转圈几十秒后报错的页面。我看了眼订单表当时大概一千两百万行如果按全字段默认导出生成的Excel体积至少几百MB这个体量已经不是调一调max_execution_time能解决的问题了。这不是个例。Laravel项目但凡跑了一两年导出功能迟早会撞上大文件导出这道坎请求从第10秒开始被PHP执行时间限制盯着浏览器等得失去耐心用户反复点击“导出”服务器被无效请求反复轰炸。更糟的是传统同步导出在PHP进程内一边查库一边拼文件内存峰值轻松逼近memory_limit一旦超过直接白屏或返回500。我的结论非常明确在Web请求里同步生成大文件这条路本身就是错的。请求超时的本质不是“超时参数设得不够大”而是整个交互模型错了。导出一个1GB的文件可能需要几分钟但一个HTTP请求按常规设置只能活30秒两者天然冲突。与其在超时参数上缝缝补补不如把“生成文件”这件事从HTTP请求里彻底拆出去让用户看到的是一个真实、平滑、可预期的进度提示。1.1 一次504背后导出慢只是表面现象先复盘一下那次504的完整链路。运营在前端点导出浏览器发送一个POST请求到Laravel控制器开始执行查询ORM一次把几十万行数据塞进内存再通过Excel类库逐行构建单元格。这个过程里CPU、内存、磁盘IO同时在飙但客户端只能干等。Nginx默认的fastcgi_read_timeout是60秒PHP-FPM有request_terminate_timeoutPHP本身还有max_execution_time。任何一个先到点连接就会被强杀。504只是表象真正的问题是大文件导出根本不应该放在请求周期内完成。你可能会说那就调大这些超时参数比如都改成600秒。但别忘了PHP-FPM的进程是有限的一个worker被这个导出请求占住6分钟其他正常请求就只能排队等worker释放。流量稍微大一点整个站点都会跟着卡住。这不是在解决问题是在拿整站稳定性换一个导出功能。1.2 内存和执行时间的双重困境同步导出的第二个死穴是内存。假设导出20万行、每行50个字段用Maatwebsite这类Excel类库在内存里构建对象一行的开销往往在1KB以上20万行就是200MB起步这还没算Excel组件本身的缓存结构。一旦数据量到百万行级别memory_limit几乎必爆。有人会想到分块读取chunk()一次取5000行逐块写入CSV而不是Excel。这个思路本身没问题但放在HTTP同步请求里依然要等全部块处理完才能返回响应用户看到的仍然是一个长时间没反应的页面而且没有中途反馈谁也不知道后台是真的在跑还是已经卡死了。1.3 异步轮询为什么能同时解决体验和稳定性最终我选择的是“队列异步生成 前端轮询进度”这套模型。请求进来以后只做三件事创建一条导出任务记录、把生成逻辑丢进队列、立刻返回任务ID。真正查库、写文件、压缩的脏活累活全部由队列Worker在后台执行。前端拿到任务ID之后每隔几秒请求一次进度接口从任务表里读取processed、total、progress把百分比渲染到进度条上。任务完成后再把下载链接暴露给前端用户看到的从“转菊花”变成了“2% → 47% → 100% → 下载文件”体验完全不一样。这套模型的本质是把同步等待改成了异步轮询把一次性长请求拆成了多次短请求每个HTTP请求都在几百毫秒内返回服务器压力小也不存在请求超时的问题。后面所有代码和坑都是围绕这个模型展开的。2. 方案选型的完整思考路径从“能跑”到“扛得住”在动手写代码之前我先把备选方案挨个摆出来算了一笔账这一步非常值得。有很多人一上来就写轮询但轮询也有好几种写法不同的方案在资源占用、实现复杂度、可维护性上差别很大。2.1 先算一笔账导出千万行到底需要多少时间和内存我做了一个基础估算。假设要导出的订单表有1000万行单行数据导出为CSV后平均约100字节那么CSV文件就是1GB左右。如果用Excel格式体积会放大3到5倍光是处理过程中生成的临时XML就够喝一壶了。所以大文件导出的首选格式一定是CSV实在要求Excel再考虑转档。时间上逐行查库并写入CSV按每秒钟写5000行估算1000万行大约需要30分钟。这个数字让我彻底放弃了同步方案——没有任何用户会愿意在浏览器里等30分钟。而异步方案就无所谓Worker跑30分钟前端只需要显示“已处理 34%”就行。内存上方案选择的影响更大。一次性加载1000万行哪怕每行只是一个数组都不是常规服务器能扛的。必须分块读取、逐批处理、边读边写才能把内存峰值压到100MB以内。2.2 几种落地方案的横向对比我排除了几个不靠谱的方案之后剩下的选择其实不多这里用一张表说清楚方案优点致命缺点我的判断同步请求直接返回文件流实现简单超时、内存爆、无进度反馈只适合百万行以内直接排除同步请求 缩短分块写入代码改动小用户还是得干等体验没变治标不治本异步队列 前端轮询稳定、通用、进度可展示需要额外维护任务状态最终采用异步队列 WebSocket推送实时性极好重、需要维护长连接杀鸡用牛刀轮询完全够2.3 最终落地的调用链路整个链路是这样的前端发起导出请求 → 控制器写入exports任务表状态pending→ 把任务ID返回给前端 → 后端dispatch一个GenerateExportJob到队列 → Worker开始分批查库写CSV每处理完一批更新一次进度 → 全部完成后压缩为zip、更新任务状态为completed → 前端轮询到completed后显示下载链接。这套链路里每个环节都是独立的任何一步失败都能在任务表里留下痕迹不会出现“请求超时后不知道文件生成到哪了”的尴尬。接下来我从Session这个最容易翻车的点说起。3. Laravel Session在进度追踪里我踩过的三个坑一开始我天真地想把进度直接写进Session让轮询接口从Session里读省一张表。事实证明这是一个大坑而且坑得很有代表性值得单独拿出来说。3.1 Session的写入时机与文件锁Laravel默认的Session驱动是file它在请求生命周期开始时会通过SessionManager启动并锁定对应的session文件直到整个HTTP请求结束才统一写入并释放锁。这意味着什么呢如果你在普通控制器里写session([export_progress 50])这个值不会立刻落盘而是在响应发送前的 terminate 阶段才写入。第一次读不到、第二次也读不到大多数时候读到的是上一次请求结束时的旧数据。更麻烦的是Session文件锁。同一个用户发起的并发请求会尝试读取同一个session文件而文件锁会让后到的请求阻塞等待前一个请求完整结束。你想想这个画面导出请求正在长时间运行前端轮询进度接口被session锁挡住进度条永远卡在0%直到导出完成、锁释放咔进度直接跳到100%。轮询了个寂寞。3.2 我当时是怎么排查轮询被锁住的现象是这样的我用curl单独请求进度接口接口秒回数据正常。但前端浏览器里轮询却一直pending时间线变成了几十秒。第一反应以为是Nginx或PHP-FPM的连接数满了查了日志发现根本没到限制。后来我在控制器里打了日志发现请求压根没进入控制器卡在中间件里。这才意识到是web中间件组里的Session middleware在等待session文件锁。导出接口持锁时间过长轮询请求只能排在后面。排查过程中还发现就算我手动调用Session::save()提前写盘文件锁依然要到请求结束才释放问题照旧。3.3 最终选择用数据库表记录进度而不是Session所以Session这条路我彻底放弃了。异步队列Worker运行在CLI环境本身没有HTTP session概念硬要操作Session还得手动启动纯属给自己找不自在。最终方案是建一张exports表任务是数据库里的一行记录Worker更新字段轮询接口查字段简单直接天然支持并发也没有锁问题。进度数据用数据库其实还有额外好处可以追溯历史任务可以做用户的导出记录列表甚至可以做失败重试。这些用Session完全做不到。如果你在同步导出流程里也想用轮询显示进度我的建议依然是建表或者用Cache千万别去动SessionSession不适合做任务状态存储。3.4 顺带说一句Laravel session安全版本聊到Session不得不提安全。前阵子社区里流传的 CVE-2024-29291 就是Laravel框架中与Session机制相关的安全更新如果你还在用受影响版本的Laravelcomposer update laravel/framework升到安全版本就行。这类问题不涉及功能设计纯粹是提醒大家别把旧项目扔在脑后顺手敲一条更新命令的成本远低于某天数据泄露的代价。4. 后端任务链路的完整落地任务表、队列Job、分批读取与压缩方案定型之后代码落地是重头戏。我按“表结构 → Job骨架 → 数据读取 → 文件压缩”的顺序一步步搭起来每一步都有细节要注意。4.1 设计一张能记录状态的导出任务表表就是整个异步导出状态的唯一事实来源设计上宁多勿缺。我最终的表结构长这样Schema::create(exports, function (Blueprint $table) { $table-id(); $table-foreignId(user_id)-constrained()-cascadeOnDelete(); $table-string(type)-default(export); $table-string(status)-index()-default(pending); $table-unsignedBigInteger(total)-default(0); $table-unsignedBigInteger(processed)-default(0); $table-unsignedInteger(progress)-default(0); $table-string(file_path)-nullable(); $table-json(filters)-nullable(); $table-string(error_message)-nullable(); $table-timestamps(); });字段解释一下status管理生命周期total和processed是进度计算的分子分母progress是直接算出来的百分比前端拿到就能用不用每次都做除法。filters存查询条件快照这很重要——用户发起导出时选的日期范围、状态、仓库之类的条件如果不快照下来等Worker执行的时候再从前端取就晚了别人一刷新页面参数就丢了。4.2 队列Job的基本骨架超时、重试、失败处理Job的核心逻辑不复杂但几个属性要提前设置好class GenerateExportJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $timeout 1200; public int $tries 1; public function __construct(public ExportTask $task) { } public function handle(): void { $this-task-update([status processing]); } }$timeout设建议至少1200秒因为千万行导出确实可能跑20分钟以上。$tries设为1不要自动重试。为什么导出类任务一旦跑到一半失败文件可能已经写了一部分直接重试会造成重复数据或文件冲突。宁可标记失败让用户手动重新发起也不要让队列系统盲跑重试。同时要在failed方法里把失败原因写回任务表public function failed(Throwable $e): void { $this-task-update([ status failed, error_message $e-getMessage(), ]); // 清理残留的临时文件 if ($this-task-file_path file_exists($this-task-file_path)) { unlink($this-task-file_path); } }这样前端轮询到failed状态就能直接给用户展示错误信息而不是一个光秃秃的“导出失败”。4.3 chunkById分批读取与CSV追加写入真正干活的部分在handle()里。读取数据我推荐用chunkById()而不是chunk()。chunk()的实现是LIMIT/OFFSET数据量一大OFFSET越翻越大查询越来越慢。chunkById()则是用WHERE id lastId的方式翻页走主键索引性能稳定得多。配合orderBy(id)使用更佳。写CSV就用PHP自带的fputcsv()完全够用性能比任何Excel库都快一个数量级。关键点是要边读边写不要攒在内存里public function handle(): void { $this-task-update([status processing]); $filters $this-task-filters ?? []; $csvPath storage_path(app/exports/ . date(Ymd) . /task_ . $this-task-id . .csv); if (! is_dir(dirname($csvPath))) { mkdir(dirname($csvPath), 0755, true); } $handle fopen($csvPath, w); fputcsv($handle, [id, order_no, amount, created_at]); $query Order::query() -select([id, order_no, amount, created_at]) -whereBetween(created_at, [$filters[from] ?? now()-subMonth(), $filters[to] ?? now()]); $total (clone $query)-count(); $this-task-update([total $total]); $processed 0; $query-chunkById(5000, function ($orders) use ($handle, $processed, $total) { foreach ($orders as $order) { fputcsv($handle, [ $order-id, $order-order_no, $order-amount, $order-created_at-toDateTimeString(), ]); } $processed $orders-count(); $this-task-update([ processed $processed, progress $total 0 ? (int) floor($processed / $total * 100) : 0, ]); if ($this-task-fresh()-status cancelled) { throw new CancelledExportException(); } }, id); fclose($handle); }注意这里total必须在写入前先查一次不然前端进度条到死都是0%。还有一个细节每处理完一批都调一次$this-task-fresh()检查状态是为了支持用户取消任务。如果发现任务被取消了直接抛异常终止生成避免继续浪费CPU和磁盘。4.4 压缩环节用系统zip代替ZipArchive内存立省几百兆在压缩那个环节我踩过一个大坑。最开始用PHP内置的ZipArchive把CSV压进zip小文件完全没问题但压缩1GB左右的CSV时内存直接飙到500MB以上Worker被OOM干掉任务失败。后来改成调用系统zip命令内存占用一下降到几十MB$zipPath preg_replace(/\.csv$/, .zip, $csvPath); exec( cd . escapeshellarg(dirname($csvPath)) . zip -r -q . escapeshellarg(basename($zipPath)) . . escapeshellarg(basename($csvPath)), $output, $exitCode ); if ($exitCode ! 0) { throw new \RuntimeException(zip command failed: . implode(PHP_EOL, $output)); } unlink($csvPath); $this-task-update([ status completed, file_path $zipPath, progress 100, ]);用exec之前一定要确认服务器上装了zip命令并且用escapeshellarg()转义路径防止路径里有空格或特殊字符把命令搞坏。系统zip是C语言实现的压缩内存效率和压缩速度都碾压PHP的ZipArchive。如果你不想依赖外部命令也可以用Spatie的Zip插件但内部底层依然是ZipArchive大文件场景依旧建议系统zip。压缩完把原始CSV删掉只保留zip避免临时文件堆满磁盘。5. 支持文件比对进度条的扩展写法同一套任务表换个业务逻辑开发完导出功能没多久产品就提了新需求要做一个文件比对工具两个大CSV文件每个几百MB逐行比对差异页面上必须有进度条不能干等。我一看这不就是同一套异步任务模型吗任务表、轮询接口、前端进度条组件全部复用只需要写一个CompareFileJob替换handle()里的业务逻辑就行。5.1 比对任务的进度模型total怎么定义比对两个文件进度条的“总进度”到底按谁算我的做法是取两个文件中行数较多的那个作为total因为处理过程要读完两个文件用较大的行数作为分母进度永远不会超过100%语义最自然。行数怎么快速拿到用wc -l命令几百MB的文件也就一秒钟的事$total (int) exec(wc -l . escapeshellarg($fileA));如果嫌exec不干净也可以自己遍历文件数行数但没必要系统命令就是干这个的。5.2 逐行哈希比对与差异输出比对逻辑很简单两个文件同时打开逐行读取比较内容不相等就写入差异文件。这里千万不要用file()一次性把整个文件读成数组几百MB直接内存爆掉。老老实实用fgets()一行一行读$handleA fopen($fileA, r); $handleB fopen($fileB, r); $diffHandle fopen($diffPath, w); $i 0; while (($lineA fgets($handleA)) ! false) { $lineB fgets($handleB); // 如果只关心内容是否一致直接比较字符串就够 // 如果文件有编码或换行差异可以hash后再比较 if ($lineA ! $lineB) { fwrite($diffHandle, $lineA); } $i; if ($i % 1000 0) { $this-task-update([ processed $i, progress (int) floor($i / max($total, 1) * 100), ]); } } fclose($handleA); fclose($handleB); fclose($diffHandle);注意这种逐行比对只适用于“两个文件顺序一致”的场景也就是同一份数据导出两次之后找差异。如果两个文件顺序是乱的就不能这么简单比对得先排序或导入临时表那是另一个话题但进度条模型还是一样的。5.3 同一套轮询接口无缝复用由于任务表里我加了type字段导出和比对只是type不同进度接口根本不用改。前端也只需要把“导出”按钮的文案改成“开始比对”轮询逻辑、进度条、下载逻辑全部复用。这让我意识到做一个通用的“耗时任务异步化基础设施”是值得的。任务表、队列Job基类、轮询接口、前端进度组件这些可以沉淀成项目内部的公共模块后续任何耗时操作报表生成、数据迁移、批量导入、视频转码都往这套框架里套就行。6. 前端轮询与进度提示的落地写法后台架构搭完前端也不能拖后腿。进度提示做得是否优雅直接影响用户对这个功能的评价。我前端没有用复杂框架原生JS就搞定了。6.1 轮询接口与前端计数器先写一个简单的进度接口注意做归属校验防止用户A查到用户B的导出进度public function progress(int $taskId) { $task ExportTask::query() -where(id, $taskId) -where(user_id, auth()-id()) -firstOrFail(); return response()-json([ status $task-status, progress $task-progress, processed $task-processed, total $task-total, file_url $task-status completed ? route(export.download, $task-id) : null, error $task-error_message, ]); }前端轮询频率我建议2秒一次。太快了纯属浪费服务器资源太慢了进度条看起来一顿一顿的。2秒刷新一次进度条走起来已经足够平滑async function pollExportProgress(taskId) { const bar document.getElementById(progress-bar); const text document.getElementById(progress-text); const timer setInterval(async () { const res await fetch(/api/export/progress/${taskId}); const data await res.json(); bar.style.width data.progress %; text.textContent data.progress %; if (data.status completed) { clearInterval(timer); document.getElementById(download-link).href data.file_url; document.getElementById(download-link).style.display block; } if (data.status failed) { clearInterval(timer); text.textContent 导出失败 data.error; } if (data.status cancelled) { clearInterval(timer); text.textContent 任务已取消; } }, 2000); }6.2 进度条展示与用户等待的心理体验设计除了进度条本身的百分比我还加了一个“已处理行数/总行数”的文本展示比如“已处理 350,000 / 1,000,000”。实测下来用户对进度条的信任感会强很多因为百分比可能长时间不变但行数在涨至少能确认后台没有死。另外我建议在最开始阶段就把话术准备好。当任务刚提交、还在pending状态时前端可以显示“任务已提交正在排队等待处理”不要一上来进度条就是0%用户会以为坏了。如果任务总量真的很大比如超过500万行我会在页面上加一句提示“预计需要5-15分钟您可以先去做其他事任务完成后会自动通知。”这比干巴巴的loading要有温度得多。6.3 取消、失败重试与临时文件清理用户手滑点错了导出条件需要能取消任务。取消的接口长这样public function cancel(int $taskId) { $task ExportTask::query() -where(id, $taskId) -where(user_id, auth()-id()) -firstOrFail(); if (in_array($task-status, [pending, processing])) { $task-update([status cancelled]); } return response()-json([ok true]); }取消之后队列Worker在下一个chunk边界会发现status变成cancelled抛出异常终止任务。前端轮询接口看到cancelled状态后把进度条区域变灰并提示“任务已取消”。临时文件的清理也得有。我的方案是导出目录按日期分层storage/app/exports/20260728/然后写一个计划任务每天凌晨清理三天前的目录0 2 * * * find /path/to/storage/app/exports -type d -mtime 3 -exec rm -rf {} 生产环境里这一步不能省否则跑几个月下来导出目录能占几十GB磁盘。7. 运行一个月之后参数推荐和那些得反复确认的配置方案上线跑了一个月过程里陆续优化了几个配置也踩了几个不在预期内的坑这里集中说下省得你再走一遍。7.1 Nginx和PHP-FPM的超时参数也要同步调虽然导出已经异步化但别忘了还有两个接口是同步请求一个是最开始创建任务的接口一个是轮询进度的接口。正常情况下它们都应该在几百毫秒内返回但万一数据库抖动或者查询里count太慢还是有超时的可能。我的建议是这三处超时参数统一拉高一点但别拉太高Nginx的fastcgi_read_timeout调到300秒PHPmax_execution_time调到120秒PHP-FPM的request_terminate_timeout调到300秒。这样正常请求不受影响偶发慢SQL也不会直接504。真正的重活都在队列Worker里HTTP层不需要为导出任务保留超长连接。7.2 Queue Worker的血泪参数timeout和内存队列Worker的--timeout参数和Job类里的$timeout是两个东西很容易搞混。--timeout是Supervisor杀掉Worker前的总执行时间上限Job类里的$timeout是队列系统认为任务超时的阈值。实际运行中只要有一个超时上限比任务实际耗时低任务就会被杀掉。我在Supervisor里配置worker时--timeout1200和Job的1200秒必须保持一致。另外一个血泪教训是PHP-FPM和CLI环境的内存限制是分开的很多人在CLI里没设memory_limit默认用-1即无限制结果真有同事写的Job把几GB数据load进内存直接把服务器搞到OOM重启。CLI的memory_limit最好也设个上限比如512M让Worker更早失败而不是拖垮整台机器。7.3 高并发导出的任务队列策略上线后很快遇到一个新问题十几个运营同时点导出任务表瞬间多出十几条processing状态的任务所有worker都在跑导出其他业务Job排不上队。我的解法是单独建一个exports队列并且限制这个队列的并发数php artisan queue:work redis --queueexports --tries1 --timeout1200 --sleep3Supervisor里只给这个队列开1到2个process。这样即使导出任务堆积也不会影响邮件、通知这些轻量任务。同时在后端创建任务时做了限制同一个用户最多只能有一个未完成的导出任务$activeTask ExportTask::query() -where(user_id, auth()-id()) -whereIn(status, [pending, processing]) -exists(); if ($activeTask) { return response()-json([message 您有正在进行的导出任务请等待完成后再试], 422); }效果很明显之前那种“导出任务堆积、核心业务Job被饿死”的情况再也没出现过。7.4 实测结果从504到稳定跑完上线一个月的真实数据导出任务最大单次跑了285万行订单数据CSV文件1.1GBzip压缩后280MB整体耗时约4分20秒。如果没有异步化这个任务在HTTP层必挂无疑。现在用户看到的是进度条从1%稳稳走到100%期间可以离开页面回来下载文件没有任何人再反馈导出超时。内存峰值在Worker里约180MB主要消耗在数据库查询的数据转换层比起之前同步方案动不动500MB的峰值已经算非常健康了。后端接口的响应时间全部回到几十毫秒级别服务器负载也稳定下来。我个人最大的体会是做这类功能第一步不是选队列还是选websocket而是先把“任务状态”这个东西抽象出来无论是存数据库还是存缓存只要任务状态有了后面加进度条、加大文件支持、加取消重试都是顺理成章的事。现在我再接新的耗时功能第一件事就是复用这套任务骨架建一条任务记录写一个Job插一个轮询接口前端拿现成的进度组件半天就能上线一个新功能。如果你也在做类似的事不妨按这个思路先把手里的导出逻辑拆一拆大概率会发现卡住你的不是Laravel本身而是没有把“请求”和“任务”分清楚。
返回列表