必须拆分数据获取、文件生成、响应传输三环节:TaskWorker异步生成临时文件并更新Redis状态,前端轮询就绪后由HTTP Worker用SwooleStream流式读取下发,避免内存爆满与协程阻塞。

直接用 PhpSpreadsheet 一次性生成百万行 Excel 文件,必然内存爆掉、协程卡死、接口超时——这不是配置问题,是设计错误。必须拆开「数据获取」「文件生成」「响应传输」三个环节,用 TaskWorker 跑生成,用流式 HTTP 响应传文件。
为什么不能在协程里直接生成并返回大 Excel
因为 PhpSpreadsheet 默认构建完整内存对象模型:每行每单元格都实例化 PHP 对象。10 万行 × 10 列 ≈ 百万个 Cell 实例,PHP 内存轻松破 500MB;Swoole 协程一卡就是几十秒,用户看到的是空白页或 504。更麻烦的是,ob_start() + php://output 在 TaskWorker 里根本不可用——它没响应上下文。
-
IOFactory::createWriter($spreadsheet, 'Xlsx')的save()方法若写到php://output,只在 HTTP Worker 有效 - TaskWorker 没有
$response对象,无法设置 header 或 body - 即使强行
file_put_contents到临时路径,前端轮询下载时仍可能遇到文件未写完就被读取的问题
正确流程:TaskWorker 生成 + 临时文件 + 流式响应
核心是让耗时操作彻底脱离请求生命周期。导出任务提交后,立即返回 taskId;TaskWorker 异步写文件到 storage/exports/;前端轮询 /export/status?task_id=xxx,状态就绪后发起真实下载请求,服务端用 SwooleStream 边读边吐,不缓存整文件。
- 导出前先查总数,预估耗时,写入 Redis:
export:task:{taskId},含status、total_rows、done_rows、file_path - TaskWorker 中用
ChunkedQuery分批拉数据库(比如每次limit 5000 offset xxx),避免单次查询超时或内存溢出 - 生成文件不用
Spreadsheet全量对象,改用PhpOffice\PhpSpreadsheet\Writer\Xlsx的底层流写能力 —— 但注意:它本身不支持真正流式写入,所以实际采用「分批写入临时 sheet → 合并」或更推荐:用league/csv导出 CSV,或换xlsxwriter扩展(C 实现,无内存膨胀) - 文件写完后,Redis 状态切为
ready,并设 TTL(如 3600 秒),避免磁盘被垃圾文件占满
流式下载接口必须绕过 Swoole 的 output buffer
直接 return $response->withBody(new SwooleStream($content)) 是错的——$content 是整个文件内容,等于又把文件全读进内存。必须用文件句柄流式读取,靠 Swoole 底层的 fd 直接转发。
- 正确做法是:用
fopen($filePath, 'rb')打开只读流,再包装成SwooleStream,Swoole 会自动按 chunk 推送(默认 64KB) - 务必设置
Content-Type为application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,否则浏览器不会识别为 Excel - 加
Content-Disposition: attachment; filename="xxx.xlsx",不然可能在浏览器里直接打开而非下载 - 下载完立刻调用
unlink($filePath),但要确保流已发送完毕——建议用defer或在 response 发送完成回调中删(Hyperf 3.2+ 支持onResponse事件)
容易被忽略的坑:并发写同名文件 & 临时目录权限
多个导出任务如果用固定文件名(如 export.xlsx),会相互覆盖;而用 uniqid() 又可能导致 Redis 和文件系统状态不一致。还有,storage/exports 目录必须对 task worker 进程用户可写,且 Swoole 主进程和 task 进程的 umask 可能导致生成文件权限为 600,HTTP Worker 读不到。
- 文件名必须带 taskId,例如:
exports/{$taskId}_{$timestamp}.xlsx - 写完文件后,用
chmod($filePath, 0644)显式放开读权限 - Redis key 和文件路径必须原子写入:先写文件,成功后再写 Redis;失败则清理残留文件
- 别依赖
BASE_PATH.'/storage',确认该路径在所有 worker 进程中挂载一致(Docker 环境尤其注意 volume 映射)
最复杂的不是代码怎么写,而是怎么让「生成」和「传输」两个阶段严丝合缝地交接——文件写到一半就被读、Redis 状态更新延迟、worker 进程意外退出导致孤儿文件,这些边界情况比功能本身更消耗调试时间。


















