Laravel Job Batches死锁问题:批量任务链异常的解决方案咨询
问题场景
我有两个批量任务链:一个负责准备数据的$validatorBatch,一个负责执行核心任务的$importerBatch,再加上一个收尾的ImportCleanupJob。切换到Redis作为任务存储后整体运行正常,但因为任务执行速度太快,偶尔会出现两个任务同时更新数据库job_batches表同一条记录的情况,触发死锁错误:
Deadlock found when trying to get lock; try restarting transaction
报错指向/Bus/Batch.php。当前代码逻辑是并行触发这些任务:
[ Bus::batch($validatorBatch)->dispatch(), Bus::batch($importerBatch)->dispatch(), ImportCleanupJob::dispatch($this->websiteImport), ];
现有重试机制完全不符合预期:要是$validatorBatch因为死锁失败,系统会先把$importerBatch和清理任务全跑完,再回头重试验证任务,完全没意义。我得保留Batch功能,这样验证任务失败时还能调用$batch->cancel()取消导入任务。
解决办法
1. 给批处理加依赖,串行执行验证与导入
既然验证失败就得取消导入,那根本不该让两个批处理并行跑。可以用Laravel Batch的then/catch/finally方法控制执行顺序,确保只有验证批处理成功完成后,才会启动导入批处理:
Bus::batch($validatorBatch) ->then(function (Batch $batch) { // 验证批处理成功走完,再启动导入批处理 Bus::batch($importerBatch)->dispatch(); }) ->catch(function (Batch $batch, Throwable $e) { // 验证失败时,直接取消任何已启动的导入任务(如果有的话) // 没启动的话自然不会执行,避免白跑无效任务 }) ->finally(function (Batch $batch) { // 不管验证成功还是失败,最后统一跑清理任务 ImportCleanupJob::dispatch($this->websiteImport); }) ->dispatch();
这样从根源上避免两个批处理同时更新job_batches表导致死锁,还能保证验证失败时不会触发无用的导入操作。
2. 优化job_batches表的锁策略
针对死锁本身,可以调整Laravel默认的批次更新逻辑。比如在更新批次状态(pending_jobs、failed_jobs这些字段)时,使用更合理的数据库锁机制:
- 可以自定义RedisBatchRepository,继承原类并重写更新方法,在查询批次记录时用
lockForUpdate(),或者根据数据库类型用NOWAIT/SKIP LOCKED来避免长时间持有锁,减少死锁概率。 - 举个例子,更新批次状态时改成:
DB::table('job_batches') ->where('id', $batchId) ->lockForUpdate() ->update([ 'pending_jobs' => $newPendingCount, // 其他字段 ]);
3. 用Redis分布式锁限制批处理并行触发
在启动两个批处理前,加个Redis锁确保同一时间只有一个批处理在操作job_batches表:
$lock = Cache::lock('batch_update_lock', 10); if ($lock->get()) { try { $validatorBatchInstance = Bus::batch($validatorBatch)->dispatch(); $importerBatchInstance = Bus::batch($importerBatch)->dispatch(); } finally { $lock->release(); } }
注意锁的过期时间要足够覆盖批次创建的耗时,别还没创建完锁就过期了。
4. 调整重试逻辑,避免无效重试
修改验证批处理的重试规则,让失败的重试任务先检查导入批处理的状态,如果导入任务已经跑完或者正在跑,直接终止重试:
Bus::batch($validatorBatch) ->catch(function (Batch $batch, Throwable $e) { // 先找到导入批处理实例 $importerBatch = Bus::findBatch($importerBatchId); // 如果导入正在跑,先取消它 if ($importerBatch && $importerBatch->running()) { $importerBatch->cancel(); } // 如果导入已经跑完,直接放弃重试,别做无用功 if ($importerBatch && $importerBatch->finished()) { return; } // 否则正常重试 }) ->dispatch();
内容的提问来源于stack exchange,提问作者OurBG

