小Cの已经记不起来的博客

PHP Laravel 队列任务超时与重试次数配置建议

队列这东西吧,刚开始用的时候觉得挺爽,扔进去就不用管了。等你哪天真出问题了,去看日志,发现任务莫名其妙被重试了七八次,或者一个任务卡在那儿把 worker 全占满,才知道超时和重试这俩参数是真的得好好调,不然坑你没商量。

我前阵子就踩过一回:一个导数据的任务,跑个三五分钟很正常,结果队列配置里 retry_after 小于任务实际耗时,任务还没跑完,又被另一个 worker 重新捞起来再跑一遍,数据重复了一堆,排查了半天才定位到问题。所以这篇就把 Laravel 队列里超时和重试相关的东西捋一捋,顺便给点我实际在用的配置建议。

先把几个容易搞混的概念分清楚

Laravel 里跟"时间"和"重试"相关的配置其实有好几处,名字都差不多,特别容易看串:

尤其是最后这个 retry_after,很多人第一次配就是随手填个 90,觉得跟 --timeout 差不多就行。实际上官方文档里反复强调,retry_after 必须大于 --timeout,否则就会出现任务还在跑、又被重新投递的诡异情况。

timeout 到底该设多少

我的习惯是分队列设,不要所有任务都用一个值。比如:

// config/queue.php
'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 600,
    'block_for' => null,
],

然后在 supervisor 里按队列拆开跑:

[program:laravel-worker-default]
command=php /var/www/artisan queue:work redis --queue=default --timeout=60 --tries=3
numprocs=4

[program:laravel-worker-heavy]
command=php /var/www/artisan queue:work redis --queue=heavy --timeout=550 --tries=1
numprocs=2

轻任务 60 秒足够了,超了基本都是代码有 bug,kill 掉重试反而更好。重任务单独扔到 heavy 队列,超时给到 550 秒,retry_after 是 600,留了 50 秒的缓冲,这样任务跑得慢一点也不会被别的 worker 抢走重跑。

至于 tries 设多少,我一般的判断标准是:这个任务重试有没有副作用。发邮件这种幂等性差的,tries=1,失败了记录日志人工处理;导数据、生成报表这种可以重入的,tries=3 就够了,再多基本就是死循环。

在任务类里做更细的控制

队列级别的配置是兜底的,具体任务最好在类里再写清楚:

class ImportProducts implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $timeout = 120;
    public $backoff = [10, 60, 300];
    public $maxExceptions = 3;

    public function handle()
    {
        // ...
    }

    public function retryUntil()
    {
        return now()->addHours(2);
    }
}

$backoff 这个数组挺实用,第一次失败等 10 秒,第二次等 60 秒,第三次等 5 分钟,比固定间隔合理很多,尤其对接第三方 API 的时候,能避开对方短暂的抖动。maxExceptions 是 Laravel 8 之后加的,表示异常次数达到多少就彻底失败,比单纯看 $tries 更精确一点,因为有时候任务被手动 release 也会算一次尝试。

几个我认为值得写下来的坑

第一,retry_after 千万不要小于 --timeout。这个我在开头已经说过了,但真的值得再说一遍,因为它的报错不明显,任务看起来是"成功"的,数据却错了。

第二,timeout 超时后任务不会再重试。很多人以为 kill 掉之后会重新排队,其实不会——超时的任务直接标记失败。所以如果你的任务本身耗时波动大,宁可把 timeout 设大一点,靠 $tries 来控制重试,而不是指望超时后自动重来。

第三,用了 WithoutOverlapping 的话要注意锁的过期时间。默认 24 小时后才释放,如果任务失败没走到 release,下次同样的任务会被锁卡住。这种情况可以在中间件上加过期时间:

public function middleware()
{
    return [(new WithoutOverlapping($this->productId))->expireAfter(600)];
}

第四,Horizon 用户注意 retry_after 是写在 horizon.php 里的。如果你在用 Horizon,config/queue.php 里的 retry_after 会被覆盖,得去 config/horizon.php 的 waits 里配,这点也特别容易搞混。

一点小结

其实队列配置没有那种"我照抄一下就能用"的标准答案,得看你的任务实际跑多久、失败了能不能安全重来、下游扛不扛得住重复调用。我的建议是上线前先跑一批真实数据,把耗时打在日志里,然后按 P95 的耗时往上留 30% 到 50% 作为 timeout,retry_after 再在这个基础上加个几十秒的缓冲。至于重试次数,默认 3 次、幂等性差的任务 1 次,基本能覆盖绝大多数场景了。真出问题时至少不会一头雾水,知道该去看哪几个参数。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放