Laravel Horizon job 尝试次数过多或运行超时
结论
retry_after 不是 job 的最大存活时间,也不限制最大尝试次数。它控制的是:worker 取走 job 后,如果 job 没有被正常删除,Redis 要等待多久才能让它重新进入可执行队列。
这个异常通常表示某条队列消息已经被 Horizon 保留,但对应的 worker 在确认完成前超时、崩溃或被终止。消息再次被取出时,attempts 会增加。如果达到 job 的 $tries 或 Horizon supervisor 的 tries,Laravel 就会抛出:
App\Jobs\TrackOrderPayment has been attempted too many times or run too long.
The job may have previously timed out.
排查时应重点检查 Horizon supervisor 的 tries、timeout,以及 job 自身的 $tries、$timeout 和 retryUntil()。把 retry_after 提高到 960 秒,解决不了尝试次数过少或 worker 超时的问题,还可能导致异常中断的 job 等待 16 分钟后才重新出现。
这里有两种“重试”
当前实现会在每次执行时删除当前 job,再重新 dispatch 一个新的 TrackOrderPayment:
$this->delete();
dispatch(new TrackOrderPayment($this->order))
->delay(self::RETRY_INTERVAL_SECONDS);
因此,15 次付款检查并不是同一条队列消息的 15 次尝试,而是 15 个相互独立的新 job。每个新 job 都有各自的 attempts 计数。
异常中的“attempted too many times”是指其中某个 job 被 Redis/Horizon 重复保留,例如:
- Horizon 取出 job,
attempts变为 1。 - worker 在确认 job 成功前超时、崩溃、被系统杀死或重启。
retry_after到期后,Redis 再次提供该 job。attempts变为 2。- 如果该 job 或 supervisor 只允许尝试一次,Laravel 会在再次执行
handle()前直接将它标记为失败。
所以,即使进入 handle() 后通常只运行 0.5 秒,也不能说明 job 从未超时或异常退出。耗时还可能发生在模型反序列化、数据库连接、DNS、HTTP 请求、队列中间件或 worker 进程本身。
检查 Horizon supervisor 配置
Horizon 的 worker 参数位于 config/horizon.php 的 supervisor 配置中,不一定和 Redis connection 配置放在一起。
Laravel 8 项目的具体结构可能会因 Horizon 小版本和已发布的配置文件而略有差异,但需要检查的参数大致如下:
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'processes' => 10,
'tries' => 3,
'timeout' => 30,
],
],
],
需要确认:
- 当前
APP_ENV实际匹配了哪个environments配置。 - 处理该 queue 的 supervisor 是否设置了
tries。 - supervisor 的
timeout是否过短。 - 是否有多个 supervisor 在消费同一个 queue。
- 部署环境是否仍在使用旧的配置缓存。
- 修改配置后,Horizon 进程是否真正重启。
修改配置后,应按照部署方式重新加载配置,并让 Horizon 平滑重启:
php artisan config:cache
php artisan horizon:terminate
horizon:terminate 会终止当前 Horizon 主进程。在生产环境中,通常需要由 Supervisor、systemd 或其他进程管理器自动将其重新启动。
在 job 中明确设置参数
比起完全依赖 supervisor 的默认值,更稳妥的做法是在 job 中明确声明尝试次数和超时时间:
class TrackOrderPayment implements ShouldQueue
{
public int $tries = 3;
public int $timeout = 30;
public bool $failOnTimeout = true;
public function handle(): void
{
// Payment tracking logic...
}
}
这里的 $tries = 3 控制单条队列消息的基础设施重试次数,并不是允许检查三次付款状态。按照当前重新 dispatch 新 job 的设计,每次付款检查都是一条新消息,每条消息最多可以因临时故障尝试三次。
如果所用的 PHP 或 Laravel 8 小版本不支持 typed property,可以改为:
public $tries = 3;
public $timeout = 30;
public $failOnTimeout = true;
还要检查 job 是否定义了:
public $maxExceptions;
或者:
public function retryUntil()
{
return now()->addMinutes(15);
}
retryUntil() 会设置时间限制。不过,如果系统每分钟都创建一个新 job,而且每个新实例都从“当前时间”重新计算 15 分钟,它就无法限制整条付款跟踪链的总时长。总截止时间应以订单创建时间、支付过期时间为准,或者由第一个 job 传入一个固定时间。
正确理解 timeout 和 retry_after
两者应满足:
Horizon timeout < Redis retry_after
例如:
// config/horizon.php
'timeout' => 30,
// config/queue.php
'retry_after' => 60,
Horizon 必须先终止真正超时的 worker,Redis 才能让尚未确认的 job 再次执行。否则,旧 worker 可能还在运行,而 Redis 已经将同一个 job 交给另一个 worker,造成并发重复执行。
延迟时间不计入 job 的运行超时。下面这个 job 在等待 60 秒期间不会占用 worker,也不会因为这 60 秒触发 $timeout:
dispatch(new TrackOrderPayment($order))->delay(60);
因此,不必为了覆盖完整的 15 分钟付款检查窗口,把 retry_after 设置为 960 秒。这个值应该覆盖单次实际执行可能需要的最长时间,并且比 worker timeout 稍长。设为 960 秒意味着 worker 异常退出后,Redis 最多可能等待 16 分钟才重新交付消息,甚至超过付款跟踪窗口。
简化当前的重新入队逻辑
handle() 正常返回后,Laravel 会自动从队列中删除当前 job,因此通常不需要先调用 $this->delete():
private function reQueueJob(): void
{
$this->order->refresh();
if ($this->order->status === Constants::ORDER_STATUS_AWAITING_PAYMENT) {
dispatch(new TrackOrderPayment($this->order))
->delay(self::RETRY_INTERVAL_SECONDS);
}
}
总周期还应由数据库中固定的过期时间控制:
private function reQueueJob(): void
{
$this->order->refresh();
if (
$this->order->status === Constants::ORDER_STATUS_AWAITING_PAYMENT
&& now()->lt($this->order->payment_expires_at)
) {
dispatch(new TrackOrderPayment($this->order))
->delay(self::RETRY_INTERVAL_SECONDS);
}
}
如果问题中的 this->order 只是粘贴时遗漏了符号,那么实际 PHP 代码必须写成:
$this->order
另一种方案:释放同一个 job
也可以不创建新 job,直接释放当前 job:
class TrackOrderPayment implements ShouldQueue
{
public $tries = 20;
public $timeout = 30;
public $failOnTimeout = true;
public function handle(): void
{
$this->order->refresh();
if ($this->order->status !== Constants::ORDER_STATUS_AWAITING_PAYMENT) {
return;
}
if (now()->gte($this->order->payment_expires_at)) {
return;
}
if ($this->paymentHasBeenDetected()) {
$this->markOrderAsPaid();
return;
}
$this->release(60);
}
}
在这种方案中,每次 release() 都算作同一个 job 的下一次尝试,因此 $tries 必须覆盖预期的轮询次数,并留出少量基础设施故障余量。如果预计轮询 15 次,可以根据业务容错要求设为 18 或 20,而不是默认的 1 或 3。
两种模式都可以使用,但含义不同:
delete()后重新dispatch():每轮都会创建一个新 job,尝试次数单独计算。$this->release(60):始终使用同一个 job,每轮都会增加attempts。
还需要排查哪些情况
如果设置了合理的 $tries 和 $timeout 后仍然失败,应检查失败记录中的完整异常栈,以及同一时间段的 Horizon、PHP 和系统日志,重点关注:
- PHP worker 是否因内存不足被操作系统终止。
- Horizon 是否在部署期间频繁重启。
- 支付接口是否偶尔发生连接或读取超时。
- 数据库、Redis 或 DNS 是否有短暂阻塞。
- job 是否使用了
WithoutOverlapping、RateLimited等中间件。部分中间件即使没有真正进入业务逻辑,释放 job 时也会消耗attempts。 - 队列进程是否仍在使用旧的配置缓存。
- job 自身是否定义了
$tries、$timeout、$maxExceptions或retryUntil()。 - 实际消费 queue 的 supervisor 是否就是当前检查的那个 supervisor。
配置时应分清三个概念:用固定的业务截止时间限制付款跟踪不超过 15 分钟;用 $tries 控制单条消息可以承受多少次异常执行;用 $timeout 和 retry_after 决定单次执行卡住或 worker 丢失后如何恢复。
备注:内容仅供参考。
