Coolify 队列作业最佳实践:retry_after、指数退避、唯一锁与 Horizon 生产配置解析
本文基于 Coolify 仓库内的 Laravel 队列作业规则文档 .agents/skills/laravel-best-practices/rules/queue-jobs.md 展开,系统讲解队列作业在重试时序(retry_after 与 timeout 的关系)、指数退避、唯一性锁、失败处理、限流、批量作业以及 Horizon 生产配置上的九条实战规则,并结合 Coolify 源码中真实的作业实现与配置文件逐条印证,帮助你在大型 Laravel PaaS 类项目中写出可预测、不重复、可观测的队列系统。
一、retry_after 必须大于作业 timeout
队列 worker 在派发一个作业后会为其记录"认领时间"。如果作业长时间未完成,且队列连接上的 retry_after(单位:秒)已过期,队列就会认为这个作业已经"卡死",把它重新放回队列再次派发——即使原来的 worker 仍在执行它,从而导致同一作业被重复执行。因此规则是:retry_after 必须严格大于作业自身的 timeout(作业执行超时时间)。
错误示范(retry_after ≤ timeout):
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 拉大到远超所有作业超时的上界,才能杜绝"正在执行却被重新派发"的重复执行问题。同一文件中 database 与 beanstalkd 连接则使用默认的 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,这是规则二的直接落地:
- app/Jobs/UpdateStripeCustomerEmailJob.php:
public $tries = 3;+public $backoff = [10, 30, 60];—— 调用 Stripe 外部 API 的作业,前两次分别等 10 秒、30 秒,最后一次等 60 秒; - app/Jobs/ProcessGithubPullRequestWebhook.php:
public array $backoff = [30, 60, 120];—— 处理 GitHub Pull Request Webhook 的异步任务,退避节奏更平缓; - app/Notifications/CustomEmailNotification.php:
public $backoff = [10, 20, 30, 40, 50];—— 自定义邮件通知使用线性递增的五次退避; - 各通知渠道作业(如 app/Jobs/SendMessageToSlackJob.php、app/Jobs/SendWebhookJob.php 等)统一使用
public $backoff = 10;的固定 10 秒退避。
可以看出 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.php 与 app/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/ 下可搜索到 ApplicationDeploymentJob、DatabaseBackupJob、ScheduledTaskJob、ServerCheckJob、RemoveContainerJob 等)。其中 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.php 的 failed() 则记录"重试耗尽后 Stripe 客户邮箱更新最终失败"的详细上下文(团队 ID、新旧邮箱、异常信息),供人工介入。两种写法对应同一个原则:失败路径必须有明确的业务收尾动作和可检索的日志上下文。
五、在作业中限流外部 API 调用
对调用第三方 API 的作业,使用 RateLimited 队列中间件按指定名称进行节流,超过速率阈值的作业会被推迟而不是直接失败:
public function middleware(): array
{
return [new RateLimited('external-api')];
}
Coolify 在邮件发送场景中有现成实例:app/Notifications/Test.php 对 EmailChannel 注册了 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 弹性配置
],
其中几处与前述规则直接呼应的设计:
- worker 的
timeout动态兜底:worker 超时取HORIZON_TIMEOUT(默认 39600 秒)与ScheduledVolumeBackup::DEFAULT_TIMEOUT + 600(体积备份作业的默认超时加 10 分钟缓冲)两者的较大值,再封顶 85800 秒。这保证了"任何作业的timeout都小于 worker 超时、小于retry_after"这条不变量——正是第一节规则在 Horizon 侧的落地方式。 - 作业侧的队列分级:Coolify 的作业普遍通过构造器中的
$this->onQueue(...)指定队列,如 app/Jobs/ApplicationPullRequestUpdateJob.php、app/Jobs/DeleteResourceJob.php、app/Jobs/DockerCleanupJob.php 等使用onQueue('high'),而 Horizon 的queue选项默认消费high,default两条队列并按书写顺序优先消费high,实现优先级分离。 - 云端/自托管的队列隔离: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. trim 与 metrics 保留策略:同文件中近 60 分钟裁剪运行中/完成作业指标、recent_failed/failed 保留 10080 分钟(7 天)的裁剪配置,控制 Redis 中 Horizon 元数据的长期占用。
九、规则速查表
| 规则 | 核心参数 | Coolify 中的对应证据 |
|---|---|---|
retry_after > timeout |
config/queue.php 的 retry_after;作业的 $timeout |
config/queue.php Redis 连接 retry_after: 86400、after_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.php 的 RateLimited('email') |
| 批量作业 | Bus::batch()->then()->catch()->dispatch() |
多容器部署拆作业的场景(app/Jobs/ApplicationDeploymentJob.php) |
| 时间型重试上限 | retryUntil() + $tries = 0 |
规则文档示例 |
| Horizon 多队列与扩缩容 | config/horizon.php 的 defaults/environments |
config/horizon.php、bootstrap/helpers/shared.php 的 deployment_queue()/crons_queue() |
小结:Coolify 的队列体系体现了一套完整的生产约束——连接层用大 retry_after 防重复派发,作业层用 $backoff 与 $tries 控制重试节奏,用 ShouldBeUnique/WithoutOverlapping 防并发重复,用 failed() 保证失败可观测,用 onQueue() 与队列路由助手函数做优先级与部署/定时任务的资源隔离,最后由 Horizon 按队列负载弹性伸缩 worker 并用 timeout 计算兜底保证所有作业超时都在安全窗口内。把这套约束作为新作业的上手清单,可以显著降低队列系统的重复执行、重试风暴与静默失败三类典型事故。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00