老项目里最怕改什么?不是登录逻辑,也不是支付回调,而是那些看起来不起眼、一上线就出幺蛾子的导入导出功能。我上个月刚在 ThinkPHP 8 的项目里把一个 Excel 导入接口从"单次 2000 行就内存爆炸"改到"10 万行稳定跑完",中间把整个导入导出的生命周期从头到尾捋了一遍。今天这篇就把这条链路摊开来讲:一个导入导出接口从请求进来到响应返回,中间到底经历了哪些环节、每个环节的职责边界在哪、哪些地方最容易埋坑。
标题里用了"庖丁解牛"这个词,是因为我发现绝大多数人对导入导出的理解停留在"控制器里写几行循环"的层面,遇到问题就靠试,试不出来就百度,从来不关心数据在请求生命周期里到底是怎么流动的。实际上,导入导出涉及请求路由、中间件、控制器、服务层、模型、文件系统、响应输出、临时资源回收等多个环节,任何一个节点处理不当,都会以各种各样的 Bug 形式还回来。这篇文章我会用一套实操过的完整方案,把 ThinkPHP 8 里导入导出的生命周期按阶段拆解,该给代码的地方给代码,该讲原理的地方讲原理。
1. 为什么说导入导出的坑都藏在生命周期里
1.1 先理解"生命周期"在这个场景下到底指什么
很多同学一看到"生命周期"四个字,脑子里蹦出来的是 Vue 的 created、mounted,或者 Spring Bean 的 init、destroy。其实放到 ThinkPHP 8 的导入导出场景里,生命周期要拆开看两层意思。
第一层是框架层面的请求生命周期。一个 HTTP 请求打进来,ThinkPHP 8 会依次完成入口文件加载、Container 容器初始化、全局中间件执行、路由匹配、控制器实例化、控制器方法执行、Response 对象生成、输出内容、收尾清理。导入导出只是控制器方法里做的一件事,但它会被整个请求生命周期包裹着。
第二层是业务数据层面的生命周期。拿导入来说,一份 Excel 文件从用户电脑上传到服务器,再被 PHP 读取成数组,再经过逐行校验、逐行归档、事务提交,最后返回结果给前端,这中间每一步都是数据的"生存阶段"。导出也一样,数据库里的记录被查询出来、被格式化、被写入文件流、被下载到用户电脑,每一站都有资源消耗和出错可能。
两层叠加之后,你会发现问题往往不出在某个具体函数写错了,而是出在"某个环节没有在合适的时机做该做的事"。比如文件上传后没有及时移动或删除临时文件,比如大数据量查询没有在循环里分批释放内存,比如事务还没提交就把响应返回给了前端。这些都属于生命周期管理不到位。
1.2 不理解完整链路,你会踩到哪些典型的坑
先说几个我真实经历过的、几乎都能归因到"生命周期认知缺失"的报错场景。
第一个是内存暴涨。用 PhpSpreadsheet 加载一个 5 万行的 Excel,默认模式下所有单元格数据都会被加载到内存里,一个单元格按 1KB 算,5 万行乘以 20 列就是 100 万格,接近 1GB。如果不设置 ReadDataOnly 或使用自定义筛选器,PHP 进程直接 OOM,这就是不了解文件读取阶段该做什么导致的。
第二个是事务锁表。导入数据时把数据校验和数据库写入混在一起,每插入一行就提交一次事务,或者反过来——明明校验失败了,却因为已经执行了部分写入语句,导致数据库里残留半批脏数据。这属于没有给"数据校验"和"数据落库"划清阶段边界。
第三个是导出乱码或者文件损坏。用 fputcsv 时没处理 UTF-8 BOM,用 PhpSpreadsheet 时没清空输出缓冲区,或者文件写完后没有用 response()->download() 而是直接 echo 文件内容,这些都会让前端拿到一个打不开的文件。这是响应输出阶段出了问题。
第四个是临时文件堆积。导入上传的临时文件、导出生成的临时 CSV、Excel 预览文件,做完没清理,时间长了服务器硬盘被塞满。这是收尾阶段没做资源释放。
你会发现这些坑没有任何一个是"语法不会写",全都是"不知道某个环节什么时候该干什么"。所以这篇的核心思路就是:把导入导出的完整生命周期画出一条时间线,然后把每个阶段该做的事、该避开的事,对应到代码上。
2. ThinkPHP 8 导入导出的完整请求链路
2.1 从请求进来到响应返回,一条主线串起来
我在实际项目里喜欢把导入导出的完整链路画成一条流水线,这样排错的时候可以按图索骥。下面这条线是 ThinkPHP 8 下一个典型的导入接口会经过的节点,导出接口只是后半段方向不同。
先看导入:
- 入口文件 public/index.php 接收到请求,加载 Composer 自动加载和框架启动文件。
- 容器初始化,注册全局中间件,比如 SessionInit、参数绑定。在这个阶段,上传的文件被 PHP 解析到 $_FILES 里,但还没有被移动。
- 路由分发到 UploadController@import,控制器方法被调用。
- 控制器里通过 Request 对象获取上传文件,此时文件还在 PHP 的临时目录里,生命周期只在当前请求内有效。
- 调用服务层(比如 ImportService)来读取文件、解析数据、校验数据、写入数据库。
- 整个流程包在一个事务里,成功后提交,失败后回滚。
- 返回 JSON 响应,前端拿到成功或失败的结果。
- 框架发送响应,紧接着执行请求终止逻辑。此时必须及时清理临时文件。
再看导出:
- 请求进入,路由到 ExportController@export。
- 控制器调服务层查询数据。这里的关键是内存管理,不能一次把几十万行全捞出来。
- 数据经过格式化,写入临时文件,可以是 CSV、XLSX 等。
- 通过 Response 对象把文件以附件形式返回,设置正确的 Content-Type 和 Content-Disposition。
- 浏览器下载完成后,请求结束,临时文件需要被清理或纳入定时清理计划。
这条主线里最容易被忽略的,其实是请求开始和请求结束这两个边缘节点。很多人只关心控制器里那几行代码,不关心文件从哪来、最后到哪去。但恰恰是这两个边缘节点决定了一个导入导出功能是"能用"还是"可靠"。
2.2 不同环节的职责边界划分
我把整条链路划分成六个阶段,每个阶段都有明确的任务边界。
第一阶段,请求接收与鉴权。导入导出接口都不应该允许匿名访问,一定要在中间件或者控制器构造函数里做权限校验,防止有人上传恶意文件或者批量拉走全量数据。这个阶段的产出是"通过验证的请求"。
第二阶段,文件获取与预处理。拿到上传文件后,先检查扩展名、MIME 类型、文件大小,然后移动到可写目录。特别注意,$_FILES 里的文件如果在请求结束前没有被 move_uploaded_file,会被自动删除,所以这个动作必须尽早做。
第三阶段,数据解析。把文件内容读取成 PHP 数组。这一阶段要关注读取模式(只读单元格数据还是读取公式、样式)、内存占用、编码转换。
第四阶段,数据处理与校验。对每一行数据做格式校验、唯一性检查、业务规则校验,把错误收集起来。这个阶段不应该写数据库,保证校验失败时数据零写入。
第五阶段,数据落库与事务控制。校验通过的数据批量写入,事务包裹,确保要么全部成功,要么全部回滚。写入过程中如果有意外错误,捕获异常并回滚。
第六阶段,响应输出与资源清理。导入返回统计结果,导出则输出文件流。无论成功失败,都要清理临时文件,释放内存占用。
这六个阶段里,第三、第四、第五阶段的代码量最大,也最容易写成一锅粥。我见过不少人把文件读取、校验、入库写在一个 Controller 方法里,两百行代码,牵一发动全身。正确的做法是每个阶段拆成独立的方法或者类,用服务层把它们串起来,这样生命周期中的每一步都可以单独测试和排查。
3. 导入功能的生命周期实战拆解
3.1 环境准备与组件选型
在 ThinkPHP 8 里做 Excel 导入导出,最主流的方案是 PhpSpreadsheet,也就是 PHPExcel 的官方继承者。ThinkPHP 官方没有内置 Excel 操作库,但可以通过 Composer 直接引入,兼容性很好。
安装命令很简单:
composer require phpoffice/phpspreadsheet这里有一个选型问题要说明白。如果你是老项目从 ThinkPHP 3.2 或者 ThinkPHP 5 升级过来的,可能之前用的是 PHPExcel,这个库已经停止维护多年,而且对 PHP 8.x 支持不好,运行时会抛出一堆弃用警告,个别方法还会直接报致命错误。PhpSpreadsheet 在命名空间、类名、方法名上都有变化,但整体用法一脉相承,迁移成本并不高。
如果只需要导出 CSV,其实可以不引 PhpSpreadsheet,用 PHP 自带的 fputcsv 就够了,性能和内存都表现极佳。但如果需要导出多 Sheet、带样式、带公式的 xlsx 文件,或者导入 xlsx、xls 等格式,建议直接用 PhpSpreadsheet。
我常用的引入方式是在服务层里引入:
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; use PhpOffice\PhpSpreadsheet\Writer\Xlsx as XlsxWriter;再补充一个环境要求:PHP 需要开启 fileinfo 扩展和 zip 扩展,PhpSpreadsheet 解析 xlsx 时依赖它们。在 ThinkPHP 8 的 .env 里把 upload_max_filesize、post_max_size 配置好,默认的 2M 上传限制对导入功能来说太小了,我一般会调到 32M 或更高,具体看业务文件大小。
3.2 上传、读取、校验、落库的完整流程
先写一个控制器入口,对应的路由是 upload/import。这里的逻辑只负责从请求里拿文件、调服务层、返回结果,不掺杂业务细节。
<?php declare(strict_types=1); namespace app\controller; use think\Request; use think\Response; use app\service\ImportService; use think\exception\ValidateException; class ImportController { public function import(Request $request): Response { $file = $request->file('excel_file'); if (!$file) { return json(['code' => 1, 'msg' => '未接收到文件']); } try { $service = new ImportService(); $result = $service->handleImport($file); return json([ 'code' => 0, 'msg' => '导入完成', 'data' => $result ]); } catch (ValidateException $e) { return json(['code' => 1, 'msg' => $e->getMessage()]); } catch (\Throwable $e) { return json(['code' => 1, 'msg' => '导入失败:' . $e->getMessage()]); } } }注意到我这里用了catch (\Throwable $e),不是只 catch Exception。PHP 里 Error 和 Exception 是不同的层级,文件操作、类型错误可能抛出 Error,如果不捕获,ThinkPHP 8 会返回一个 500 错误页,对前端来说就是一个笼统的服务器错误,排查起来很麻烦。
服务层的核心方法 handleImport 需要负责整条业务链路。我把它的逻辑写清楚,分步骤来:
第一步,验证文件类型和大小。trust 一下扩展名和 MIME,不要只认 MIME,因为有些环境拿到的 MIME 是 application/octet-stream,不够准确。
public function handleImport(UploadedFile $file): array { // 1. 校验扩展名 $extension = strtolower($file->getOriginalExtension()); if (!in_array($extension, ['xlsx', 'xls', 'csv'])) { throw new ValidateException('仅支持 xlsx、xls、csv 格式'); } // 2. 限制大小,这里以 32M 为例 if ($file->getSize() > 32 * 1024 * 1024) { throw new ValidateException('文件大小不能超过 32M'); } // 3. 移动到本地上传目录,返回文件路径 $savePath = app()->getRootPath() . 'runtime/import/'; if (!is_dir($savePath)) { mkdir($savePath, 0755, true); } $filePath = $savePath . uniqid('imp_', true) . '.' . $extension; $file->move($savePath, basename($filePath)); }这个阶段有个经验:上传文件移动完成后,一定要拿到完整的绝对路径,后续操作都基于这个路径来读文件,不要再依赖 UploadedFile 对象。因为请求生命周期结束之前如果文件没有被移走,临时文件会被回收,后面再操作就晚了。移动后,原临时文件已经不存在,代价是磁盘上留下了新文件,所以导入逻辑跑完必须记得清理。
第二步,读取文件内容。这里的关键是使用 ReadDataOnly 和自定义 ReadFilter,避免加载样式和公式到内存。PhpSpreadsheet 在默认情况下会读取大量冗余数据,比如颜色、边框、公式计算,对只需要导入数据的场景来说完全是浪费。
use PhpOffice\PhpSpreadsheet\Reader\IReadFilter; class ChunkReadFilter implements IReadFilter { private int $startRow = 1; private int $endRow = 1; public function setRows(int $startRow, int $endRow): void { $this->startRow = $startRow; $this->endRow = $endRow; } public function readCell($columnAddress, $row, $worksheetName = ''): bool { if ($row >= $this->startRow && $row <= $this->endRow) { return true; } return false; } }然后按行读取。
$reader = new XlsxReader(); $reader->setReadDataOnly(true); $reader->setReadEmptyCells(false); $filter = new ChunkReadFilter(); $reader->setReadFilter($filter); $spreadsheet = $reader->load($filePath); $sheet = $spreadsheet->getActiveSheet(); $rows = $sheet->toArray();这里还有一个更大的优化空间,就是分块读取。一个 10 万行的 Excel,一次性 toArray 仍然会把 10 万行都塞到内存里,只是比默认模式省了样式数据。真正要控制内存,必须设置行范围,分批 load。PhpSpreadsheet 支持同一个 Reader 多次 load,每次只读一个范围。
比较简单的做法是先读取总行数,然后按每批 5000 行的区间循环加载:
$highestRow = $sheet->getHighestRow(); $batchSize = 5000; for ($start = 1; $start <= $highestRow; $start += $batchSize) { $end = min($start + $batchSize - 1, $highestRow); $filter->setRows($start, $end); $spreadsheet = $reader->load($filePath); $sheet = $spreadsheet->getActiveSheet(); $batchData = $sheet->toArray(); // 处理 $batchData $spreadsheet->disconnectWorksheets(); unset($spreadsheet, $sheet, $batchData); }每次处理完一定要调用 disconnectWorksheets 和 unset,这一步是真正的内存释放关键。实测下来,同样的 10 万行 xlsx,不分块大约需要 900M 内存,分块加 ReadDataOnly 后能压到 60M 左右。
第三步,数据校验与格式化。这一阶段我建议把每一行的错误收集到一个数组里,而不是遇到错误立刻抛出异常。给用户返回一个"第几行有问题、原因是什么"的清单,比返回一个"导入失败"有价值得多。
$errors = []; $successCount = 0; $rowNumber = 1; // 表头占一行,从第二行开始数据 // 这里假设数据列固定为 ['name', 'phone', 'amount'] foreach ($rows as $row) { $rowNumber++; $data = array_combine(['name', 'phone', 'amount'], array_pad($row, 3, '')); if (mb_strlen($data['name']) === 0) { $errors[] = "第{$rowNumber}行:姓名不能为空"; continue; } if (!preg_match('/^1\d{10}$/', $data['phone'])) { $errors[] = "第{$rowNumber}行:手机号格式不正确"; continue; } if (!is_numeric($data['amount']) || $data['amount'] <= 0) { $errors[] = "第{$rowNumber}行:金额必须为正数"; continue; } // 校验通过的数据攒起来,后面统一写入 $validData[] = [ 'name' => $data['name'], 'phone' => $data['phone'], 'amount' => $data['amount'], 'create_time' => time(), ]; $successCount++; }第四步,事务落库。校验通过的数据用一个事务包起来,批量插入。批量插入比逐条 insert 性能高出非常多,10 万行数据如果逐条插入可能要几分钟,批量插入几十秒内就能完成。
Db::startTrans(); try { foreach (array_chunk($validData, 1000) as $chunk) { Db::name('user_recharge')->insertAll($chunk); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; }这里可以解释一个关键点:为什么批量插入要分块?因为单次 insertAll 的 SQL 语句大小是有限制的,如果一次插入 10 万条记录,拼出来的 SQL 可能有几十 MB,会超过 MySQL 的 max_allowed_packet 限制,导致插入失败。按每批 1000 条拆分,既不会触发大小限制,也能保持较高性能。
最后,在返回结果之前清理临时文件。
@unlink($filePath); return [ 'total' => $rowNumber - 1, 'success' => $successCount, 'errors' => $errors, ];3.3 导入过程中的关键节点处理
这里有几个节点,处理不好就是大坑。
第一个节点是文件读取阶段的公式问题。Excel 里如果某个单元格是公式,默认模式下 PhpSpreadsheet 会返回公式字符串而不是计算结果。对于导入来说,大多数业务场景需要的是"值",不是"公式"。虽然设置了 setReadDataOnly(true) 之后,读到的就是缓存的计算结果,但如果 Excel 是由某些工具生成的、没有计算公式缓存,你只能拿到 null。稳妥的做法是在解析前对每一列做一次空值兜底,不要假设 Excel 里的数据一定结构完整。
第二个节点是日期时间格式。Excel 的日期本质上是序列号,比如 45000 表示某个日期。用 toArray 读出来后,很可能直接拿到一个 float 类型的 45000,而不是"2023-03-20"。处理方式是用 PhpSpreadsheet 的 Date::excelToDateTimeObject 转换,或者干脆在模板里约定好日期列必须是字符串格式。我踩过几次坑之后,在项目里强制要求所有导入模板的日期列都设置成文本格式,省去一堆格式判断。
第三个节点是数据唯一性检查。如果导入的数据需要做去重,一定要先查数据库里已存在的数据,组合成一个 Set,在校验阶段直接查 Set,不要在循环里逐条 SELECT,否则 1 万条数据就是 1 万次查询,数据库直接被打爆。先查全部再内存比对,是一次查询搞定的事情。
$exists = Db::name('user_recharge') ->whereIn('phone', array_column($validData, 'phone')) ->column('phone'); $existsSet = array_flip($exists);还有一个点是文件编码。CSV 文件可能是 GBK 编码,用 PhpSpreadsheet 读不出来,必须先转 UTF-8。我用过最可靠的方式是用 mb_convert_encoding 检测并转换,这里不要依赖 Excel 自动判断,csv 导入前明确做一次:
if ($extension === 'csv') { $content = file_get_contents($filePath); $content = mb_convert_encoding($content, 'UTF-8', 'GBK'); file_put_contents($filePath, $content); }如果文件是 UTF-8 的话,mb_convert_encoding 不会破坏内容,但旧版本 PHP 的 mb_detect_encoding 对中文识别准确率不高,所以最稳妥的是让用户上传前固定好编码格式,或者提供一个测试用例跑一遍。
4. 导出功能的生命周期实战拆解
4.1 数据查询、内存控制、文件输出的管线设计
导出的业务流程比导入稍微简单一点,但生命周期同样要理清。核心思路是:永远不要在内存里一次性组装全量数据,要边查边写、边写边释放。
先看一个最简单的 CSV 导出,用 PHP 自带的 fputcsv 实现。CSV 的好处是不需要引入额外的库,内存占用极低,缺点是格式简单,不支持样式和多 Sheet。
public function exportCsv(): Response { $fileName = 'recharge_' . date('YmdHis') . '.csv'; $filePath = app()->getRootPath() . 'runtime/export/' . $fileName; // 确保导出目录存在 if (!is_dir(dirname($filePath))) { mkdir(dirname($filePath), 0755, true); } $handle = fopen($filePath, 'w'); // 添加 BOM,防止中文乱码 fwrite($handle, "\xEF\xBB\xBF"); // 表头 fputcsv($handle, ['ID', '姓名', '手机号', '金额', '创建时间']); // 这里用 chunk 分批查询,避免一次性加载所有数据 Db::name('user_recharge') ->field('id, name, phone, amount, create_time') ->chunk(1000, function ($users) use ($handle) { foreach ($users as $user) { fputcsv($handle, [ $user['id'], $user['name'], $user['phone'], $user['amount'], date('Y-m-d H:i:s', $user['create_time']), ]); } }); fclose($handle); // 返回下载响应 return download($filePath, $fileName); }这里用到了 ThinkPHP 8 的chunk方法,它会分批从数据库取数据并执行回调,每次只取 1000 条,查询完一批释放一批。配合 fputcsv 的写入方式,整个导出的内存占用稳定在一个很低的值。实测 20 万行数据,内存始终在 64M 以内。
关于download()方法,ThinkPHP 8 提供了一个很方便的响应辅助函数,它内部会做文件发送并设置响应头。这里有个隐藏坑:如果你的业务里使用 PHP-FPM 而非内置服务器,download 方法同样好使。但要注意,一旦调用了 download,方法里后续的代码就不会继续执行了,因为它会返回一个响应对象并中断当前方法后续逻辑分支,所以临时文件的清理要放在别名清理工具里,或者在生成文件时就规划好文件存储目录并做定时任务清理。
4.2 大数据量导出的分页与队列方案
CSV 拆分只能解决部分场景。当数据量到了百万级别,HTTP 请求导出基本不再适用,因为整个导出过程可能持续几分钟甚至更久,请求早超时了。这时候要把导出改成异步任务:接收导出请求后,把任务推到队列,由后台任务生成文件,生成完成后通知用户下载。
在 ThinkPHP 8 里可以用自带的 Queue 队列组件,也可以引入 Laravel 风格的队列包。思路是:
- 用户点击导出,前端发请求到 ExportController@queueExport。
- 控制器把导出条件、文件名、导出类型写入队列任务,立即返回"导出任务已创建,请在任务中心下载"。
- 后台队列消费任务,逐批查询数据并写入文件,生成完成后把文件地址写入任务表,状态置为已完成。
- 用户前端轮询或等着下载。
队列里执行的核心代码和同步导出差不多,只不过是包在队列类的 handle 方法里。需要额外注意的有两点:一是队列进程的内存也要保护,不能因为切了队列就一次性查全量;二是生成文件后的临时文件名要和任务 ID 关联,方便后续清理。
如果不想用队列,也可以退而求其次,在同步导出时通过set_time_limit(0)取消时间限制,ini_set('memory_limit', '512M')提高内存上限。这是最粗暴的方式,适合数据量不大、并发不高的场景。但我在生产环境里不建议这么干,因为 PHP-FPM 进程会被一个导出请求占死,并发一起来就全部卡住。
另一个贴合生命周期的方案是流式下载。用php://output直接输出,而不是先写临时文件再 download。流式的好处是省去临时文件管理,但如果网络中断,用户只能拿到半个文件,而且服务器端没有重试机会。我一般只在数据量小、对可靠性要求不高的场景里使用。
4.3 导出模板与字段映射的处理
导出功能除了原始数据导出,最常见的变种是"按模板导出"和"自定义字段导出"。模板导出的生命周期里多了一个环节:先加载一张预设的 Excel 表头模板,再把数据填进去。这里的核心是保证模板文件的路径和格式可控,不要让用户上传模板,防止模板注入。
字段映射问题其实是字段名和表头的映射关系管理。我习惯建一个映射数组,字段名作为键,表头名作为值,这样新增导出字段时只改数组不需要改代码。
protected array $exportFields = [ 'id' => 'ID', 'name' => '姓名', 'phone' => '手机号', 'amount' => '金额', 'status_text' => '状态', 'create_time' => '创建时间', ];这个映射数组可以在控制器里做校验,只允许导出映射里存在的字段,防止通过 URL 参数注入非法字段名,造成 SQL 错误或者泄露敏感数据。这个点其实很容易被人忽略,但真实项目里被扫过漏洞的不少。
另外,导出的生命周期收尾时,一定要记录日志。我用 ThinkPHP 8 的日志通道记录导出的操作人、导出条件、导出行数、耗时,这样问题出现时可以回溯到具体哪个人哪次操作导出了什么数据。
5. 生命周期中的异常与事务处理
5.1 事务边界应该放在哪里
我见过很多人在导入功能里把事务开在控制器层,其实这是不对的。事务的边界应该紧贴着数据写入的最小必要范围:校验已经全部通过,要写库时才开启,写库完成立即提交。如果把事务开在校验之前,那么十几万行的校验过程会长时间持有一个数据库连接,把连接池占满,其他人就没法用了。
具体来说,事务只包裹下面这一段:
Db::startTrans(); try { foreach (array_chunk($validData, 1000) as $chunk) { Db::name('user_recharge')->insertAll($chunk); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; }有同学会问,校验阶段读数据不需要事务吗?实际上不需要。校验阶段只做 SELECT 和内存比对,SELECT 有 MVCC 机制保证一致性,不需要显式开事务。如果校验阶段还写了别的表,比如做状态变更,那就另说,但这种操作应该从导入流程里拆出去,单独成为一步。
导出功能默认不需要显式开启事务,因为只是读数据。如果你导出时还附带一些统计字段,比如"每个用户的总消费金额",用 group by 聚合查询就行,也不要开事务。只有在导出完成后需要把"导出记录"写入数据库、更新导出次数时,才用事务包住这个小写操作。
5.2 错误处理与失败回滚的常见姿势
导入功能失败有两种情况:数据校验失败和数据库写入失败。校验失败的场景,不应该回滚任何数据,因为压根没写入;数据库写入失败的场景,要整体回滚,不能留下半个批次的数据。
完整方案里,我把校验失败和写入失败分开处理。校验失败时返回一个错误清单,前端解析清单展示“第几行失败、原因”。写入失败时,把它们看作两种异常返回。
这里比较适合用自定义异常类。比如 ValidationFailedException 和 ImportWriteException,一个在业务逻辑中抛出,一个在数据库写入异常时抛出,控制器里分开捕获,返回不同的提示。
public function handleImport(UploadedFile $file): array { // ...前面代码省略 if (!empty($errors)) { throw new ValidateException(json_encode([ 'total' => $rowNumber - 1, 'success' => $successCount, 'errors' => $errors, ], JSON_UNESCAPED_UNICODE)); } try { Db::startTrans(); foreach (array_chunk($validData, 1000) as $chunk) { Db::name('user_recharge')->insertAll($chunk); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw new ImportWriteException($e->getMessage()); } }另一个容易忽略的是导入文件如果太大了,服务端在读取前就应该检查文件大小并提前拒绝,不能在读取之后才发现内存爆了。这个检查放在控制器进来自变量获取文件之后、文件移动之前,越早越好。
最后要强调一个点:导入功能应该支持幂等。什么意思?同一个文件如果因为超时或者网络问题上传了两次,服务器端不应该重复插入两遍数据。实现方式是在导入前先检查文件内容里的某个业务主键是否已经存在,如果存在则跳过,或者以文件哈希为维度做一次去重。我习惯的做法是为导入任务建一张表,记录文件的 MD5、状态、导入时间、操作人,重复文件直接返回"该文件已导入过"。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
整理一些我用 ThinkPHP 8 做导入导出时遇到的高频问题,直接给排查方向和参考方案。
| 问题现象 | 常见原因 | 排查与解决 |
|---|---|---|
| 导入 xlsx 报 Class not found | PhpSpreadsheet 未安装 | composer require phpoffice/phpspreadsheet |
| 导入大文件内存溢出 | 未开启 ReadDataOnly、未分块读取 | 设置 setReadDataOnly(true),用 ReadFilter 分块 |
| 导出的 CSV 中文乱码 | 缺少 BOM 头,或原数据非 UTF-8 | 写入文件前加 \xEF\xBB\xBF,或先转码 |
| 下载文件提示文件损坏 | 输出内容被调试信息污染 | 关闭调试模式,检查代码中不能有 dump/print/echo |
| 导入时数据库锁表 | 事务开着没提交,或单批插入量太大 | 缩小 array_chunk 批大小,确保事务及时提交 |
| 上传文件过大被拦截 | PHP 上传限制太小 | 调整 upload_max_filesize、post_max_size |
| 导出文件名中文乱码 | Content-Disposition 未做编码处理 | 对文件名做 rawurlencode 或者用 RFC 5987 格式 |
| 队列导出迟迟未完成 | 队列里数据查询卡住或内存耗尽 | 查看队列日志,检查是否死循环,调大内存限制 |
每条问题我在实际项目里都至少碰到过一次,最经典的是调试内容污染下载文件。ThinkPHP 开发的 debug 模式下,页面底部会输出一段调试面板的 HTML 或者日志条数信息,导出文件时如果 echo 了这些内容,下载下来文件解析时就报错。解决方式是导出接口的响应里不要输出任何非文件内容,并且关闭 debug 再发布。
6.2 几个值得留意的实践细节
实战中还有一个很容易踩的坑是 ThinkPHP 8 在download()下载文件之前会先发送一些响应头,如果你的控制器方法里已经输出过内容,下载就会失败。常见于框架的 trace 调试、或自己不小心在代码里写了 dump。建议导出方法里干净利落,只处理数据并返回 download 响应,不要加其他任何输出。
另一个实践细节是导出大文件时设置合理的响应头。用 ThinkPHP 8 的download()方法时,可以主动添加Cache-Control、Expires等头,防止浏览器或者代理缓存旧文件。如果你用自定义 Response,注意设置 Content-Type 为正确的 MIME 类型。CSV 是text/csv; charset=UTF-8,xlsx 是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet。
文件命名建议统一用业务前缀加日期时间,比如recharge_20260001_102030.xlsx,不要只用默认文件名。这样可以避免同一浏览器下载前后两次文件时因为文件名相同导致覆盖或者缓存分不清。
关于临时文件清理,我当前项目里的做法是:导入目录和导出目录都放到 runtime 下,每天凌晨用一个计划任务删除超过 24 小时的文件。这样即使某个异常流程导致临时文件没被删除,也不会堆积太多。
在创建目录时给权限上做一点手脚,我这里不再展开。有一点要注意的是,PhpSpreadsheet 的 writer 在保存文件时有些环境需要 Write 权限,存储目录尽量使用项目 runtime 或 storage 目录,而不要放在 public 目录下,防止用户直接通过 URL 下载到别人导出的文件,那相当于数据泄漏。
我最终落地到生产环境的方案里,导入导出的代码全部收敛到 Service 层,Controller 只做参数接收和响应返回,中间件负责鉴权,数据库事务只在写库阶段打开,PhpSpreadsheet 读取时一定开 ReadDataOnly 并分块,CSV 导出使用 chunk 分批查询加 BOM 处理,异步大导出走队列并记录任务状态。这套链路跑下来,十万级数据的导入导出已经非常稳定,内存和耗时都在可控范围内。遇到类似需求时,可以参考这套思路先画出自己项目的生命周期阶段,再一个阶段一个阶段地补细节,坑自然就少很多。