Laravel 批量处理 PDF:失败的重试,成功的别再跑一遍
批量上传 30 份 PDF,第 27 份处理失败。如果把整个批次都放在一个队列任务里,重试时前面成功的 26 份也可能再调用一次接口。
更合适的起点是:一份文件一个 Job,批次只汇总各文件的状态。重试某个 Job,不重新遍历整批文件;Job 再次执行时,先查自己是否已有可用产物。
下面以 Laravel 12 调用 ComPDF Cloud API 将 PDF 转为 DOCX 为例。它讲的是应用如何组织任务,不能把 Laravel 的队列与防重能力说成 ComPDF 的内置功能。
接口能用,不代表队列应该直接全量重跑
先用一份获准使用的 PDF,按PDF 转 Word 接口文档确认转换结果是否适合后续编辑。检查正文、表格、分页和扫描页,不要只看请求成功。
再把成功的这一次调用放进单文件 Job。批次记录可以有 total、completed、failed 和 processing 这些数量,但保存完成状态的单位应当是文件任务,不是一个“整批成功”的布尔值。

图:已完成的 A 不随 B 的失败重跑,B 单独退避重试,C 独立完成。这是应用任务组织示意,不是后台实测截图。
三种标识不要混在一起
- 批次 ID:方便用户查找这一批上传记录。
- 文件任务 ID:对应一个输入文件、转换操作和参数版本。
- 源文件摘要:确认实际处理的还是登记任务时那份文件。
本例按文件内容摘要复用产物,操作固定为 PDF 转 DOCX,参数版本写入键名。若 OCR 开关、语言或转换参数发生变化,就应改变任务身份。多租户系统还要加入租户和权限边界,不能因为文件摘要相同就跨客户共享结果。
Laravel Job:先看有效产物,再发起处理
需要一个正常的 Laravel 12 项目、PHP 8.2+、ZIP 扩展、可运行的队列,以及支持原子锁的共享缓存。下面使用私有 local 磁盘存储小文件,不提供公开下载路径;生产下载入口要先校验权限。
在 config/services.php 中加入:
'compdf' => ['key' => env('COMPDF_API_KEY')],
只在服务端环境配置自己的密钥。配置缓存后按项目部署方式刷新配置,不在 Job 内直接读 env。
将下面类保存为 app/Jobs/ConvertPdf.php:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use ZipArchive;
class ConvertPdf implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 120;
public function __construct(public string $sourcePath, public string $sourceHash) {}
public function backoff(): array { return [15, 60]; }
public function middleware(): array
{
return [(new WithoutOverlapping('pdf-docx-v1:'.$this->sourceHash))
->releaseAfter(15)->expireAfter(150)];
}
public function handle(): void
{
$disk = Storage::disk('local');
$base = 'converted/'.$this->sourceHash.'-docx-v1';
if ($disk->exists($base.'.json') && $disk->exists($base.'.docx')) {
$done = json_decode($disk->get($base.'.json'), true, 512, JSON_THROW_ON_ERROR);
if (($done['output_hash'] ?? '') === hash('sha256', $disk->get($base.'.docx'))) {
return;
}
}
$source = $disk->get($this->sourcePath);
if (hash('sha256', $source) !== $this->sourceHash) {
$this->fail(new RuntimeException('源文件已改变,请登记新任务'));
return;
}
$key = config('services.compdf.key');
if (!is_string($key) || $key === '') {
throw new RuntimeException('未配置服务端 API key');
}
$response = Http::connectTimeout(10)->timeout(60)
->withHeaders(['x-api-key' => $key])
->attach('file', $source, 'input.pdf')
->post('https://api-server.compdf.com/server/v2/process/pdf/docx', ['enableOcr' => '1'])
->throw()->json();
$data = $response['data'] ?? [];
if (array_is_list($data)) $data = $data[0] ?? [];
if ((string) ($response['code'] ?? '') !== '200' || ($data['status'] ?? '') !== 'success') {
throw new RuntimeException('任务未成功,不登记为完成');
}
$url = $data['downloadUrl'] ?? '';
if (!is_string($url) || !str_starts_with($url, 'https://')) {
throw new RuntimeException('未取得有效下载地址');
}
$bytes = Http::connectTimeout(10)->timeout(30)->get($url)->throw()->body();
if (!str_starts_with($bytes, "PK\x03\x04")) {
throw new RuntimeException('下载结果不是预期 ZIP 格式');
}
$tmp = tempnam(sys_get_temp_dir(), 'docx-');
try {
if (file_put_contents($tmp, $bytes) === false) throw new RuntimeException('写入校验文件失败');
$zip = new ZipArchive();
if ($zip->open($tmp) !== true) throw new RuntimeException('无效 ZIP');
$isDocx = $zip->locateName('word/document.xml') !== false;
$zip->close();
if (!$isDocx) throw new RuntimeException('缺少 DOCX 文档结构');
} finally {
if ($tmp !== false && is_file($tmp)) unlink($tmp);
}
if (!$disk->put($base.'.docx', $bytes)) throw new RuntimeException('保存产物失败');
if (!$disk->put($base.'.json', json_encode([
'source_hash' => $this->sourceHash,
'output_hash' => hash('sha256', $bytes),
], JSON_THROW_ON_ERROR))) throw new RuntimeException('登记完成状态失败');
}
}
这段处理有几个刻意保留的检查:输入文件变化时终止旧任务;接口业务状态没有成功时不登记完成;下载结果除了检查 ZIP 文件头,还检查 word/document.xml;完成登记保存产物摘要,下次执行时核对实际文件。
下载返回 DOCX,不代表在 Word 或 WPS 中已验收编辑效果。正式导入业务系统前,仍应检查目标文件的实际用途。
每次只派发一份文件
已把文件放入私有磁盘并完成类型、大小和权限检查后,再派发对应 Job。sourcePath 必须来自服务端记录,不能直接使用用户提交的任意路径:
use App\Jobs\ConvertPdf;
use Illuminate\Support\Facades\Storage;
$path = $record->source_path; // 已授权的服务端上传记录
$bytes = Storage::disk('local')->get($path);
ConvertPdf::dispatch($path, hash('sha256', $bytes));
示例把小文件读入内存。大文件应改用流式上传和下载,结合节点的内存及文件大小限制设计,不能直接扩大上传限制。
超时配置要互相匹配
本例单次处理请求超时为 60 秒、下载为 30 秒,Job 超时为 120 秒。使用数据库队列时,将连接的 retry_after 设得比任务超时更长,例如 180 秒;再启动指定连接的 worker:
php artisan queue:work database --timeout=120 --tries=3
这些是示例参数,需要根据实际请求耗时调整。务必让 Job timeout 小于 retry_after,避免旧 worker 仍在处理时,同一个任务又被交给另一个 worker。WithoutOverlapping 的锁也应有足够长的有效期;本例为 150 秒,并要求各 worker 使用同一个支持锁的缓存。
锁竞争后释放的任务也会增加尝试次数。高并发时应结合 retryUntil 或更合适的 tries 处理,不能假定“设置三次”就一定有三次实际接口调用机会。Laravel 队列文档有对应的超时、重试和重叠执行说明。
用你自己的文件,做一次可观察的重试
- 选两三份获准文件,其中至少有一份带表格或扫描页。先在PDF 转换方案确认支持的方向与接入方式,再检查单文件转换效果。
- 为每份文件分别派发 Job,保留 task ID、尝试次数和结果记录。日志不记录 API key 或带签名的下载地址。
- 在本地或测试环境让其中一个任务在请求前抛出临时异常,观察其他成功文件是否保持完成,只有失败任务进入重试。不要在生产环境停网做实验。
- 重新派发已完成的同一任务,确认走到有效产物检查后返回。再用修改后的文件创建新任务,检查两者不会混用结果。
- 达到重试上限后,在项目的失败任务记录中定位单个失败任务;只重试该任务,不重新提交整个批次。
本例的源文件检查和产物登记不是一次原子操作。远端处理成功后本地崩溃,或者保存文件后完成记录写入失败,都可能在重试时再次调用接口。它不能保证远端任务只执行一次。正式系统还要加入持久化任务状态、恢复策略和异常核对,区分请求失败、结果下载失败与产物保存失败。
把一批文件拆成单文件任务,是为了让错误有明确的归属和重试范围。ComPDF 负责转换,Laravel 负责调度,应用负责确认结果能否交付;这三项各自检查,才不会把“队列跑完了”当成“用户拿到了可用文件”。
本作品采用《CC 协议》,转载必须注明作者和本文链接
关于 LearnKu
推荐文章: