PAYATHON 2026

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 重复保留,例如:

  1. Horizon 取出 job,attempts 变为 1。
  2. worker 在确认 job 成功前超时、崩溃、被系统杀死或重启。
  3. retry_after 到期后,Redis 再次提供该 job。
  4. attempts 变为 2。
  5. 如果该 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 丢失后如何恢复。

备注:内容仅供参考。