一、目标与可靠性边界
本章构建 Redis 队列与 Horizon 运维闭环:任务只在事务提交后入队、重复投递不产生重复业务效果、失败任务可观测且能安全重放。队列通常按至少一次处理设计,成功 push 不等于业务已经完成。
可靠性不靠无限重试,而靠事务边界、业务幂等、有限退避、明确超时、失败分类和可控恢复共同完成。
HTTP → MySQL 事务 → after_commit → Redis Queue
↓
Horizon Supervisor
↓
幂等 Job → 外部服务 / 数据库
├─ 成功:业务状态落库
└─ 失败:退避 → failed_jobs → 审核重放二、安装 Horizon 与失败任务表
Horizon 只管理 Redis 队列。多个环境共用 Redis 时必须用不同前缀,避免测试任务被生产 worker 消费。安装后先核对连接与 ACL,再启动守护进程。
composer require laravel/horizon
php artisan horizon:install
php artisan queue:failed-table
php artisan migrate
php artisan optimize:clearQUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_QUEUE=default三、配置事务提交、重试和超时
worker timeout 必须比 retry_after 短数秒,否则任务重新可见时旧进程可能仍在执行,造成并发重复。下例任务最多运行 90 秒,Redis 在 120 秒后才允许重投。
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 120,
'block_for' => 5,
'after_commit' => true,
],// config/horizon.php
'supervisor-orders' => [
'connection' => 'redis', 'queue' => ['orders'],
'balance' => 'auto', 'minProcesses' => 2, 'maxProcesses' => 12,
'tries' => 5, 'timeout' => 90, 'memory' => 256,
],四、设计业务幂等任务
任务参数只携带稳定主键,执行时重新查询并锁定状态。数据库状态检查和外部服务幂等键缺一不可。ShouldBeUnique 只能抑制一段时间内的重复入队,不能替代业务幂等。
final class CaptureOrderPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5; public int $timeout = 90;
public array $backoff = [10, 30, 120, 300];
public function __construct(public readonly int $orderId) {}
public function handle(PaymentGateway $gateway): void {
DB::transaction(function () use ($gateway) {
$order = Order::query()->lockForUpdate()->findOrFail($this->orderId);
if ($order->payment_status === 'captured') return;
$result = $gateway->capture("order-payment:{$order->id}", $order->total_amount);
$order->update(['payment_status'=>'captured','payment_reference'=>$result->reference]);
});
}
}五、只在数据库提交后派发
afterCommit 保证事务回滚时任务不入队,worker 也不会抢先读取未提交数据。它只覆盖当前数据库事务与派发时机,跨数据库或跨服务一致性仍需要 Outbox 等模式。
DB::transaction(function () use ($request) {
$order = Order::create([
'user_id' => $request->user()->id,
'total_amount' => $request->validated('total_amount'),
'payment_status' => 'pending',
]);
CaptureOrderPayment::dispatch($order->id)->onQueue('orders')->afterCommit();
});六、区分可重试和不可重试错误
参数错误与权限错误通常重试无意义,应直接 fail;限流和短暂网络错误按明确延迟 release。不要捕获所有异常后返回成功,否则 Horizon 会把失败误记为完成。
try {
$this->capture($gateway);
} catch (InvalidPaymentRequest $e) {
$this->fail($e);
} catch (GatewayRateLimited $e) {
$this->release(60);
}
public function failed(?Throwable $e): void {
Log::error('order-payment failed', ['order_id'=>$this->orderId,'type'=>$e?->getMessage()]);
}七、运行、部署与面板授权
生产使用 systemd、Supervisor 或编排平台守护 Horizon。发布先执行兼容迁移,再优雅终止旧 worker;旧负载与新代码不兼容时增加版本字段,不能直接清空队列。Horizon 面板必须由授权策略保护。
php artisan horizon
php artisan horizon:status
php artisan horizon:supervisors
# 发布新代码后由守护进程拉起新版本
php artisan horizon:terminate八、测试重复投递与故障恢复
测试必须主动把同一任务执行两次并断言外部调用只发生一次。重放失败任务前确认代码已修复、下游恢复且幂等仍有效;批量 retry all 会放大故障,应按异常和时间窗口小批处理。
$job = new CaptureOrderPayment($order->id);
$job->handle($gateway);
$job->handle($gateway);
$this->assertSame('captured', $order->fresh()->payment_status);
$gateway->shouldHaveReceived('capture')->once();php artisan test --filter=PaymentJobTest
php artisan queue:failed
php artisan queue:retry <failed-job-uuid>
php artisan queue:forget <failed-job-uuid>九、监控与容量计划
至少监控队列等待时间、执行时长 P95/P99、失败率、重试率、Redis 内存和 evicted_keys。队列增长时先区分流量增加、单任务变慢和毒消息,再决定扩 worker、拆分队列或限流。
- 高低优先级分队列,避免批处理阻塞用户任务。
- 重试要加入退避和抖动,防止下游恢复瞬间雪崩。
- 日志包含业务主键、job UUID 和尝试次数,但不记录令牌与个人信息。
十、上线与回滚验收
在测试环境验证事务回滚不入队、重复投递仅产生一次业务效果、timeout 小于 retry_after、Horizon 守护与面板授权正确。回滚代码时保持负载向后兼容,必要时暂停消费者而不是删除队列。
- 部署前保存队列深度和失败任务基线。
- 部署后观察至少一个业务峰值周期。
- 失败重放有审批、批次上限和停止阈值。
- Redis 持久化和恢复策略经过演练。
总结
可靠队列由事务边界、业务幂等、正确超时、有限重试、可观测失败和可控重放共同组成。Horizon 解决 Redis 队列运行管理,但一致性仍要靠状态机、唯一约束和外部幂等键保证。