根本原因是timeout与retry_after配置冲突导致任务重复执行,需确保retry_after≥timeout、避免handle末尾阻塞操作、启用Horizon事件监听并采用timeout×1.5设retry_after的稳定组合。

这种情况本质是“任务逻辑已跑完,但 Horizon 或 Laravel 认为它超时失败了”,属于超时机制与实际执行时间错配的典型问题。根本原因不是代码没执行,而是系统在任务还没来得及标记完成时就强制终止了进程。
确认 timeout 和 retry_after 是否冲突
Horizon(以及底层 Redis 队列)依赖两个关键时间参数协同工作:
- timeout:单个任务允许运行的最长时间(秒),超时则 Worker 强制杀掉该进程
- retry_after:任务被取出后,若未在该时间内完成并确认(ack),就会被重新入队——这个值必须 ≥ timeout,否则会出现“任务刚执行完,系统却因未及时 ack 而重发”的现象
检查 config/queue.php 中 Redis 驱动的配置:
‘retry_after’ => 150,再确认你任务类或启动命令中设置的 timeout 没有超过它。例如设了 $timeout = 180,但 retry_after 只有 150,任务执行到第 151 秒就会被重复投递——而此时你的业务逻辑可能已在第 160 秒完成,导致数据写入两次、状态混乱。
任务完成但未及时通知队列系统
Laravel 队列在 Redis 模式下,Worker 执行完 handle() 后需向 Redis 发送 ACK 确认。如果 handle() 末尾有耗时操作(如日志刷盘、HTTP 回调、大文件 flush)、或 PHP 进程被 timeout 中断在 ACK 前,就会造成“人干完了活,但没签收单”。
- 避免在 handle() 结尾加阻塞操作;如有必要,改用异步方式(如 dispatch 后续任务)
- 启用 Horizon 的
trim配置中recent和failed的保留时长,方便比对任务完成时间戳与 Horizon 标记失败的时间点 - 在 handle() 开头打日志,在结尾再打一条带唯一 ID 的完成日志,对照 Horizon 面板中的“最近任务”时间线验证是否真有延迟
检查 Horizon 是否覆盖了原生失败处理逻辑
Horizon 默认接管失败任务的捕获和存储(存进 Redis 的 horizon:failed 列表),它不依赖 Laravel 原生的 failed_jobs 表,也不自动调用任务类里的 failed() 方法——除非你在 config/horizon.php 中显式启用了事件监听:
- 确认
'events' => ['job.failed' => true]已开启 - 确保任务类中
failed()方法存在且未被 try/catch 吞掉异常 - 若你依赖
failed()做补偿(如回滚事务),但 Horizon 没触发它,就会误以为“失败没发生”,其实只是没走这条路
推荐的稳定组合配置
针对 Laravel 9 + Redis + Horizon 场景,建议采用以下实践:
- 将
retry_after设为 timeout 的 1.5 倍(如 timeout=120 → retry_after=180) - 任务类中用
public $timeout = 120;显式声明,而非仅靠命令行参数 - 启动 Horizon 时禁用 --timeout 参数,让其完全按任务类定义执行
- 在 handle() 最后一行加
Log::debug('Job completed', ['id' => $this->id]);,并在 Horizon 面板里搜索该日志关键词,确认完成时间是否晚于 timeout 值


















