首页
/ Coolify 队列作业最佳实践:retry_after、指数退避、唯一锁与 Horizon 生产配置解析

Coolify 队列作业最佳实践:retry_after、指数退避、唯一锁与 Horizon 生产配置解析

2026-09-04 12:35:20作者:申梦珏Efrain

本文基于 Coolify 仓库内的 Laravel 队列作业规则文档 .agents/skills/laravel-best-practices/rules/queue-jobs.md 展开,系统讲解队列作业在重试时序(retry_aftertimeout 的关系)、指数退避、唯一性锁、失败处理、限流、批量作业以及 Horizon 生产配置上的九条实战规则,并结合 Coolify 源码中真实的作业实现与配置文件逐条印证,帮助你在大型 Laravel PaaS 类项目中写出可预测、不重复、可观测的队列系统。

一、retry_after 必须大于作业 timeout

队列 worker 在派发一个作业后会为其记录"认领时间"。如果作业长时间未完成,且队列连接上的 retry_after(单位:秒)已过期,队列就会认为这个作业已经"卡死",把它重新放回队列再次派发——即使原来的 worker 仍在执行它,从而导致同一作业被重复执行。因此规则是:retry_after 必须严格大于作业自身的 timeout(作业执行超时时间)。

错误示范(retry_aftertimeout):

class ProcessReport implements ShouldQueue
{
    public $timeout = 120;
}

// config/queue.php — retry_after: 90 ← 作业还在跑就被重试!

正确示范(retry_after > timeout):

class ProcessReport implements ShouldQueue
{
    public $timeout = 120;
}

// config/queue.php — retry_after: 180 ← 安全地长于任何作业的 timeout

Coolify 自身就遵循了这条规则。查看 config/queue.php 可以看到,Coolify 的默认队列连接是 Redis('default' => env('QUEUE_CONNECTION', 'redis')),其 Redis 连接配置为:

'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 86400,   // 24 小时,远大于任何单个作业的 timeout
    'block_for' => null,
    'after_commit' => true,   // 事务提交后才入队,避免读到未提交数据
],

retry_after 设为 86400(一天)是有意为之:Coolify 中有不少作业需要 SSH 到多台服务器执行远程命令(例如 app/Jobs/ServerCheckJob.php$timeout = 60,而批量备份类作业的超时上限可达数万秒),把 retry_after 拉大到远超所有作业超时的上界,才能杜绝"正在执行却被重新派发"的重复执行问题。同一文件中 databasebeanstalkd 连接则使用默认的 retry_after: 90,仅适用于轻量作业场景。

二、使用指数退避(Exponential Backoff)而不是立即重试

作业失败后立即无间隔地重试,会在下游服务(API、SMTP、远程服务器)故障时形成"重试风暴",反而延长故障恢复时间。Laravel 提供了 $backoff 属性支持逐步拉长的重试间隔,既可以是固定秒数的整数,也可以是逐次取值的数组。

错误示范(固定/立即重试):

class SyncWithStripe implements ShouldQueue
{
    public $tries = 3;
    // 默认:立即重试,压垮 API
}

正确示范(逐次加长的退避):

class SyncWithStripe implements ShouldQueue
{
    public $tries = 3;
    public $backoff = [1, 5, 10];
}

Coolify 源码中大量作业都显式声明了 $backoff,这是规则二的直接落地:

可以看出 Coolify 的做法是按下游服务的容忍度定制退避曲线:对 Stripe、GitHub 这类第三方 API 用数组型多段退避,对消息通道用统一的小幅退避,而不是全项目一刀切。

三、用 ShouldBeUnique 防止重复作业,必要时换用 ShouldBeUniqueUntilProcessing

当同一个逻辑任务可能被并发触发多次(定时任务与手动触发、Webhook 重放等),实现 ShouldBeUnique 可让队列以 uniqueId() 返回值作为锁键,在 uniqueFor 秒内拒绝入队重复实例,从而避免重复处理。

class GenerateInvoice implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(): string
    {
        return $this->order->id;
    }

    public $uniqueFor = 3600;
}

Coolify 的三类清理作业正是这一模式的真实用例。例如 app/Jobs/CleanupOrphanedPreviewContainersJob.php

class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $timeout = 600; // 10 minutes max

    public function middleware(): array
    {
        return [(new WithoutOverlapping('cleanup-orphaned-preview-containers'))->expireAfter(600)->dontRelease()];
    }
    // ...
}

这个作业作为安全网扫描所有服务器的孤立 PR 预览容器,遍历远程服务器耗时可达 10 分钟($timeout = 600)。它同时使用了三层防护:ShouldBeEncrypted(载荷加密)、ShouldBeUnique(防止同一清理任务并发入队),以及 WithoutOverlapping 队列中间件(同名作业重叠时丢弃新实例并 expireAfter(600) 释放锁)。app/Jobs/CleanupInstanceStuffsJob.phpapp/Jobs/CleanupHelperContainersJob.php 也是同样的 ShouldBeEncrypted + ShouldBeUnique + ShouldQueue 组合。

两者的锁生命周期不同,选型要注意:

  • ShouldBeUnique:锁一直持有到作业完成。适合"整个处理周期内都不允许第二个实例"的场景,如上面的清理作业——如果第二实例提前入队,清理逻辑可能互相干扰;
  • ShouldBeUniqueUntilProcessing:锁在作业开始处理时就释放,允许后续新实例继续排队。适合"同一时刻只有一个在处理、但处理完之后新请求应能排队等待"的场景,例如重建搜索索引:
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
    // 锁在处理开始时释放,而不是结束时释放
}

四、始终实现 failed() 方法:让失败显式可观测

作业在耗尽重试次数后进入失败队列时,Laravel 会调用作业类上的 failed(?Throwable $exception) 方法(若实现了该签名)。没有它,失败只静默落入 failed_jobs 表,业务侧状态永远不会被更新。

规则文档给出的示范:

public function failed(?Throwable $exception): void
{
    $this->podcast->update(['status' => 'failed']);
    Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
}

Coolify 中十余个关键作业都实现了 failed()app/Jobs/ 下可搜索到 ApplicationDeploymentJobDatabaseBackupJobScheduledTaskJobServerCheckJobRemoveContainerJob 等)。其中 app/Jobs/ServerCheckJob.php 展示了 failed() 的进阶用法——区分"超时"这一特定失败类型并做针对性善后:

class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
{
    public $tries = 1;
    public $timeout = 60;

    public function failed(?\Throwable $exception): void
    {
        if ($exception instanceof TimeoutExceededException) {
            Log::warning('ServerCheckJob timed out', [
                'server_id' => $this->server->id,
                'server_name' => $this->server->name,
            ]);
            $this->server->increment('unreachable_count');

            // Delete the queue job so it doesn't appear in Horizon's failed list.
            $this->job?->delete();
        }
    }
}

ServerCheckJob 是高频轮询任务:探测超时说明服务器暂时不可达,这属于业务语义内的正常波动,因此 failed() 只做三件事——记日志、给 Server 模型的 unreachable_count 计数、调用 $this->job?->delete() 主动删掉失败记录,避免它污染 Horizon 的失败列表。而像 app/Jobs/UpdateStripeCustomerEmailJob.phpfailed() 则记录"重试耗尽后 Stripe 客户邮箱更新最终失败"的详细上下文(团队 ID、新旧邮箱、异常信息),供人工介入。两种写法对应同一个原则:失败路径必须有明确的业务收尾动作和可检索的日志上下文

五、在作业中限流外部 API 调用

对调用第三方 API 的作业,使用 RateLimited 队列中间件按指定名称进行节流,超过速率阈值的作业会被推迟而不是直接失败:

public function middleware(): array
{
    return [new RateLimited('external-api')];
}

Coolify 在邮件发送场景中有现成实例:app/Notifications/Test.phpEmailChannel 注册了 new RateLimited('email') 中间件,将测试邮件的发送纳入统一的速率限制命名空间,防止用户高频触发测试邮件时打爆 SMTP 服务。

六、用 Bus::batch() 把相关作业打包处理

当一组作业应当"同生共死"(全部成功才算完成、任一失败即整体回滚语义)时,用 Bus::batch() 派发,并可通过 ->then() 在全部成功后触发后续动作、->catch() 捕获任一失败:

Bus::batch([
    new ImportCsvChunk($chunk1),
    new ImportCsvChunk($chunk2),
])
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
->dispatch();

Coolify 的部署流程中,一个应用的环境级部署会拆分为多个容器级作业(如 app/Jobs/ApplicationDeploymentJob.php 协调多容器部署),这类"同一逻辑单元拆成多作业"的场景正是批处理 API 的典型适用面。批量派发还能让 Horizon 以批次为粒度展示进度与失败聚合,比单独监听每个作业更易于实现"全部完成才通知用户"的语义。

七、retryUntil() 必须搭配 $tries = 0

Laravel 中 $tries(次数上限)与 retryUntil()(时间上限)默认是"取更严格者"的语义。如果你希望作业在 4 小时内无论失败多少次都持续重试,就必须显式把 $tries 设为 0(不限制次数),否则作业会先触发次数上限而过早进入失败队列:

public $tries = 0;

public function retryUntil(): \DateTimeInterface
{
    return now()->addHours(4);
}

适用场景是"依赖外部条件恢复"的长周期作业:等待对端恢复、等待人工解锁等。使用这条规则时要同时保证 retry_after 足够大(见第一节),否则长等待期间的作业也可能被误判卡死。

八、复杂队列场景使用 Horizon:多队列分级与弹性伸缩

当系统需要监控、自动扩缩容、失败追踪或按优先级区分多条队列时,应引入 Laravel Horizon。规则文档给出的配置骨架:

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['high', 'default', 'low'],
            'balance' => 'auto',
            'minProcesses' => 1,
            'maxProcesses' => 10,
            'tries' => 3,
        ],
    ],
],

Coolify 的 config/horizon.php 是一个生产级完整样本,值得逐段对照:

'defaults' => [
    's6' => [
        'connection' => 'redis',
        'balance' => env('HORIZON_BALANCE', 'false'),
        'queue' => env('HORIZON_QUEUES', 'high,default'),
        'maxTime' => env('HORIZON_MAX_TIME', 0),
        'maxJobs' => 400,
        'memory' => 128,
        'tries' => 1,
        'nice' => 0,
        'sleep' => 3,
        'timeout' => min(
            max((int) env('HORIZON_TIMEOUT', 39600), ScheduledVolumeBackup::DEFAULT_TIMEOUT + 600),
            85800,
        ),
    ],
],

'environments' => [
    'production' => [
        's6' => [
            'autoScalingStrategy' => 'size',
            'minProcesses' => env('HORIZON_MIN_PROCESSES', 1),
            'maxProcesses' => env('HORIZON_MAX_PROCESSES', 4),
            'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1),
            'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1),
        ],
    ],
    // 'local' 环境使用相同的 s6 弹性配置
],

其中几处与前述规则直接呼应的设计:

  1. worker 的 timeout 动态兜底:worker 超时取 HORIZON_TIMEOUT(默认 39600 秒)与 ScheduledVolumeBackup::DEFAULT_TIMEOUT + 600(体积备份作业的默认超时加 10 分钟缓冲)两者的较大值,再封顶 85800 秒。这保证了"任何作业的 timeout 都小于 worker 超时、小于 retry_after"这条不变量——正是第一节规则在 Horizon 侧的落地方式。
  2. 作业侧的队列分级:Coolify 的作业普遍通过构造器中的 $this->onQueue(...) 指定队列,如 app/Jobs/ApplicationPullRequestUpdateJob.phpapp/Jobs/DeleteResourceJob.phpapp/Jobs/DockerCleanupJob.php 等使用 onQueue('high'),而 Horizon 的 queue 选项默认消费 high,default 两条队列并按书写顺序优先消费 high,实现优先级分离。
  3. 云端/自托管的队列隔离bootstrap/helpers/shared.php 定义了两个路由助手函数——
function deployment_queue(): string
{
    return isCloud() ? 'deployments' : 'high';
}

function crons_queue(): string
{
    return isCloud() ? 'crons' : 'high';
}

云端部署时,app/Jobs/ApplicationDeploymentJob.php$this->onQueue(deployment_queue())app/Jobs/DatabaseBackupJob.php$this->onQueue(crons_queue()) 会分别路由到专用队列,由相互隔离的 Horizon worker 池消费;自托管实例则统一落到共享的 high 队列。注释中还明确提示:云端环境下 worker 的 HORIZON_QUEUES 必须包含 deployments/crons,否则这些作业永远不会被处理——这是多队列架构里最典型的运维陷阱。 4. 弹性的 tries = 1:Horizon 层默认只跑一次('tries' => 1),把重试策略完全下放给作业类自己的 $tries + $backoff 声明(第二节),避免 worker 层与作业层两套重试策略叠加导致行为不可预测。 5. trimmetrics 保留策略:同文件中近 60 分钟裁剪运行中/完成作业指标、recent_failed/failed 保留 10080 分钟(7 天)的裁剪配置,控制 Redis 中 Horizon 元数据的长期占用。

九、规则速查表

规则 核心参数 Coolify 中的对应证据
retry_after > timeout config/queue.phpretry_after;作业的 $timeout config/queue.php Redis 连接 retry_after: 86400after_commit: true
指数退避 $tries + $backoff(数组/整数) app/Jobs/UpdateStripeCustomerEmailJob.php[10, 30, 60]
唯一作业 ShouldBeUnique + uniqueId() + $uniqueFor app/Jobs/CleanupOrphanedPreviewContainersJob.php 等三个清理作业
处理期即释放锁 ShouldBeUniqueUntilProcessing 规则文档示例(搜索索引类作业)
显式失败处理 failed(?Throwable $exception) app/Jobs/ServerCheckJob.php 处理 TimeoutExceededException
外部 API 限流 middleware() 返回 new RateLimited('name') app/Notifications/Test.phpRateLimited('email')
批量作业 Bus::batch()->then()->catch()->dispatch() 多容器部署拆作业的场景(app/Jobs/ApplicationDeploymentJob.php
时间型重试上限 retryUntil() + $tries = 0 规则文档示例
Horizon 多队列与扩缩容 config/horizon.phpdefaults/environments config/horizon.phpbootstrap/helpers/shared.phpdeployment_queue()/crons_queue()

小结:Coolify 的队列体系体现了一套完整的生产约束——连接层用大 retry_after 防重复派发,作业层用 $backoff$tries 控制重试节奏,用 ShouldBeUnique/WithoutOverlapping 防并发重复,用 failed() 保证失败可观测,用 onQueue() 与队列路由助手函数做优先级与部署/定时任务的资源隔离,最后由 Horizon 按队列负载弹性伸缩 worker 并用 timeout 计算兜底保证所有作业超时都在安全窗口内。把这套约束作为新作业的上手清单,可以显著降低队列系统的重复执行、重试风暴与静默失败三类典型事故。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
980
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384