---
title: 'Очереди Laravel в продакшене: таймауты и failed_jobs | DevSense'
description: 'Как настроить очереди Laravel для продакшена: retry_after и --timeout, повторы и backoff, failed_jobs, память долгоживущих воркеров, тяжёлые импорты через батчи, уникальные задачи, Supervisor и Horizon.'
faq:
    - { question: 'Чем retry_after отличается от --timeout в очередях Laravel?', answer: 'retry_after задаётся в config/queue.php и говорит соединению, через сколько секунд считать взятую задачу зависшей и выдать её снова. --timeout (или атрибут #[Timeout] у задачи) ограничивает, сколько секунд воркер даёт задаче выполняться, прежде чем завершиться с ошибкой. Таймаут должен быть на несколько секунд меньше retry_after, иначе задача может выполниться дважды.' }
    - { question: 'Почему задача в очереди выполнилась два раза?', answer: 'Чаще всего из-за того, что задача работала дольше retry_after и соединение выдало её второму воркеру, или воркер убили посреди выполнения (деплой, OOM, SIGKILL), и задача вернулась в очередь. Поэтому задачи должны быть идемпотентными, а таймауты — согласованы: HTTP-клиент < таймаут задачи < retry_after.' }
    - { question: 'Почему растёт память воркера очереди?', answer: 'Воркер queue:work — долгоживущий процесс, который не перезагружает фреймворк между задачами. Память копят статические массивы и синглтоны с состоянием, лог запросов, Telescope, изображения GD и большие коллекции. Ограничьте жизнь воркера флагами --max-jobs, --max-time и --memory и запускайте его под Supervisor, который поднимет процесс заново.' }
    - { question: 'Что делать с записями в таблице failed_jobs?', answer: 'Не перезапускать вслепую. Сначала посмотреть исключение (queue:failed, Horizon), исправить причину, затем повторить задачи командой queue:retry. Для уведомлений использовать метод failed() у задачи или событие Queue::failing, а старые записи регулярно удалять командой queue:prune-failed по расписанию.' }
published: '2026-10-03'
---
# Очереди Laravel в продакшене: таймауты, failed_jobs, память и тяжёлые задачи

Локально очереди «просто работают»: `queue:work` в соседнем терминале, задачи выполняются за секунду, ошибок нет. В продакшене начинаются истории другого жанра. Клиенту дважды ушло одно и то же письмо со счётом. Импорт прайс-листа на 400 тысяч строк крутится третий час и съел два гигабайта памяти. В `failed_jobs` скопилось двадцать тысяч записей, и никто не знает, какие из них важны. Воркеры после деплоя неделю выполняют задачи старым кодом.

Эта статья — про настройку очередей Laravel так, чтобы они переживали сбои, деплои и большие объёмы: как связаны `retry_after` и `--timeout`, как устроены повторы, что делать с `failed_jobs`, почему растёт память воркеров и как дробить тяжёлые импорты. Локальный запуск очередей в Docker разобран в статье [Sail: очереди и воркеры](../tools/sail-queues), а выбор брокера — в [сравнении очередей сообщений](message-queues-compared).

**Связанные материалы:** [Отказоустойчивые интеграции](resilient-external-integrations) · [Zero-downtime deployment](zero-downtime-deployment-laravel) · [Eloquent и большие данные](eloquent-large-datasets) · [Наблюдаемость и мониторинг](observability-monitoring-laravel)

## Содержание

* [Жизненный цикл задачи: попытки, возврат, провал](#lifecycle)
* [retry_after и --timeout: лестница таймаутов](#timeouts)
* [Повторы: tries, backoff, retryUntil](#retries)
* [failed_jobs: разбор, повтор, очистка](#failed-jobs)
* [Память долгоживущих воркеров](#memory)
* [Тяжёлые импорты и экспорты](#heavy-jobs)
* [Дубликаты и гонки: уникальные задачи и after_commit](#uniqueness)
* [Раздельные очереди и приоритеты](#priorities)
* [Supervisor и Horizon](#supervisor)
* [Мониторинг очередей](#monitoring)
* [Частые ошибки](#common-mistakes)
* [Чеклист](#checklist)
* [Квиз для самопроверки](#self-test-quiz)

---

<a id="lifecycle"></a>
## Жизненный цикл задачи: попытки, возврат, провал

Когда воркер берёт задачу, начинается **попытка** (attempt). Попытка расходуется, даже если метод `handle()` не выполнился до конца. По документации Laravel попытку «съедают»:

* необработанное исключение в задаче;
* ручной возврат в очередь через `$this->release()`;
* middleware вроде `WithoutOverlapping` или `RateLimited`, которые не получили блокировку и вернули задачу;
* превышение таймаута;
* успешное выполнение `handle()`.

**По умолчанию Laravel делает одну попытку.** Если задача упала — она сразу считается проваленной и попадает в `failed_jobs`. Если вы используете `RateLimited` или `WithoutOverlapping`, одной попытки почти наверняка мало: задача «сгорит» на первом же возврате, так и не начав работу.

```
dispatch → [queue] → worker reserves → handle()
                          │                 │
                          │         ┌───────┴────────┐
                          │      success      exception / timeout / release
                          │         │                │
                          │      delete      attempts < tries? ──yes──→ back to queue (after backoff)
                          │                          │
                          │                          no
                          │                          ▼
                          └── worker died ──→   failed() + failed_jobs
                              (job reappears after retry_after)
```

---

<a id="timeouts"></a>
## retry_after и --timeout: лестница таймаутов

Это два разных механизма, которые легко перепутать.

* **`retry_after`** — параметр соединения в `config/queue.php`. Он отвечает на вопрос «через сколько секунд считать взятую задачу брошенной и выдать её другому воркеру». Брокер не знает, жив ли воркер, поэтому ориентируется только на время. У Amazon SQS вместо него — Visibility Timeout в настройках очереди.
* **`--timeout`** у `queue:work` (или атрибут `#[Timeout]` у задачи) — сколько секунд воркер даёт задаче выполняться. По умолчанию — 60 секунд. Если время вышло, процесс воркера завершается с ошибкой, а Supervisor поднимает новый. Для таймаутов нужно расширение **pcntl**.

Если `--timeout` больше `retry_after`, задача, которая работает дольше `retry_after`, будет **выдана второму воркеру, пока первый ещё её выполняет**. Отсюда дубли писем, двойные списания и гонки. Документация формулирует правило прямо: `--timeout` должен быть как минимум на несколько секунд короче `retry_after`.

На практике согласовывать нужно целую лестницу значений — каждое следующее больше предыдущего:

| Уровень | Где задаётся | Пример |
|---------|--------------|--------|
| Таймауты HTTP-клиента и SQL | `Http::timeout()`, `connect_timeout`, `statement_timeout` | 10–30 с |
| Таймаут задачи | `#[Timeout(120)]` или `--timeout=120` | 120 с |
| `retry_after` соединения | `config/queue.php` | 150 с |
| Ожидание остановки воркера | `stopwaitsecs` в Supervisor, `stop_grace_period` в Docker | 180 с |

```php
// config/queue.php
'redis' => [
    'driver' => 'redis',
    'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 150),
    'block_for' => 5,
    'after_commit' => true,
],
```

```php
// app/Jobs/SyncSupplierPrices.php
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(120)]
final class SyncSupplierPrices implements ShouldQueue
{
    use Queueable;

    public function handle(SupplierClient $client): void
    {
        // The HTTP client has its own, shorter timeout: blocking IO
        // (sockets, HTTP) may not be interrupted by the job timeout.
        $client->withTimeout(seconds: 20)->syncPrices();
    }
}
```

> [!WARNING]
> **Таймаут задачи не прерывает зависший сокет.** Документация отдельно предупреждает: блокирующий ввод-вывод (сокеты, исходящие HTTP-соединения) может не реагировать на таймаут воркера. Всегда задавайте собственные таймауты HTTP-клиенту и запросам к базе.

`block_for` у Redis говорит драйверу, сколько секунд ждать новую задачу в блокирующем режиме. Значение `0` блокирует воркер бесконечно — и он перестаёт обрабатывать сигналы вроде `SIGTERM` до прихода следующей задачи, что ломает мягкую остановку при деплое.

Если задачу нельзя повторять после таймаута (например, она уже могла отправить данные во внешнюю систему), пометьте её атрибутом `#[FailOnTimeout]`: тогда таймаут сразу переводит задачу в проваленные, без повторов.

---

<a id="retries"></a>
## Повторы: tries, backoff, retryUntil

В Laravel 13 параметры повторов удобно задавать PHP-атрибутами прямо на классе задачи (в более старых версиях — свойствами `$tries`, `$timeout`, `$backoff`):

```php
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;

#[Tries(10)]
#[MaxExceptions(3)]
#[Backoff([10, 60, 300])]
final class PushOrderToCrm implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function middleware(): array
    {
        // Releases caused by rate limiting consume attempts, hence Tries(10),
        // while MaxExceptions(3) fails the job after three real errors.
        return [new RateLimited('crm')];
    }

    public function handle(CrmClient $crm, OrderSummaryQuery $orders): void
    {
        $crm->upsertOrder($orders->summary($this->orderId));
    }
}
```

* **`Tries`** — максимум попыток. Значение на классе важнее флага `--tries` воркера.
* **`MaxExceptions`** — после скольких настоящих исключений задача проваливается, даже если попытки ещё есть. Это позволяет разрешить много возвратов из-за rate limit, но не долбить сломанный API десять раз.
* **`Backoff`** — пауза перед повтором после исключения. Массив `[10, 60, 300]` задаёт нарастающие паузы: 10 секунд, минута, пять минут для третьего и последующих повторов.
* **`retryUntil()`** — альтернатива числу попыток: повторять сколько угодно раз, но не дольше указанного момента. Если заданы и `tries`, и `retryUntil()`, приоритет у `retryUntil()`.

```php
public function retryUntil(): DateTime
{
    return now()->addMinutes(30);
}
```

Для нестабильных внешних API полезен middleware `ThrottlesExceptions`: после серии ошибок он откладывает задачи на заданное время, вместо того чтобы сжигать попытки на заведомо лежащем сервисе. Это реализация паттерна Circuit Breaker на уровне очереди (подробно — в статье об [отказоустойчивых интеграциях](resilient-external-integrations)).

```php
public function middleware(): array
{
    return [(new ThrottlesExceptions(10, 5 * 60))->backoff(5)];
}
```

> [!NOTE]
> **Повтор безопасен, только если задача идемпотентна.** Задача может выполниться повторно после таймаута, падения воркера или вашего `queue:retry`. Используйте `upsert` вместо `insert`, ключи идемпотентности у платёжных API и проверку «уже сделано?» в начале `handle()`.

---

<a id="failed-jobs"></a>
## failed_jobs: разбор, повтор, очистка

Когда попытки кончились, Laravel вызывает метод `failed()` задачи и записывает её в таблицу `failed_jobs` вместе с payload и текстом исключения.

```php
public function failed(?Throwable $exception): void
{
    // Runs in the worker after the last attempt: notify, compensate, mark state.
    Order::whereKey($this->orderId)->update(['crm_sync_status' => 'failed']);

    report($exception);
}
```

Рабочий цикл с проваленными задачами:

```bash
php artisan queue:failed                 # list failed jobs with exception summaries
php artisan queue:retry <uuid>           # retry one job after fixing the cause
php artisan queue:retry --queue=crm      # retry every failed job from one queue
php artisan queue:retry all              # retry everything (use with care)
php artisan queue:forget <uuid>          # delete one record
php artisan queue:prune-failed --hours=168
```

Правила, которые экономят нервы:

* **Не делайте `queue:retry all` вслепую.** Если причина не исправлена, задачи упадут снова, а неидемпотентные — успеют что-то сделать повторно.
* **Чистите таблицу по расписанию.** По умолчанию `queue:prune-failed` удаляет записи старше 24 часов; укажите `--hours` под свой процесс разбора.
* **Удаляйте задачи с удалёнными моделями.** Если модель удалили, пока задача ждала очереди, атрибут `#[DeleteWhenMissingModels]` тихо удалит задачу вместо падения с `ModelNotFoundException`.
* **Алертите не на каждую ошибку, а на тренд.** Одна проваленная задача в час — шум, сто в минуту — инцидент. Событие `Queue::failing()` удобно для счётчика метрик.

```php
// routes/console.php
Schedule::command('queue:prune-failed --hours=168')->daily();
Schedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();
```

---

<a id="memory"></a>
## Память долгоживущих воркеров

`queue:work` — это демон: он загружает приложение один раз и выполняет задачи в цикле, не перезапуская фреймворк. Это быстро, но всё, что задача оставила в памяти, остаётся там до конца жизни процесса. Типичные источники роста:

* статические массивы и синглтоны, которые накапливают состояние («кэш» в свойстве сервиса);
* лог запросов (`DB::enableQueryLog()`), Telescope и Debugbar в продакшене;
* ресурсы GD/Imagick без `imagedestroy()` или `clear()`;
* большие коллекции, загруженные целиком через `get()` вместо `lazyById()`.

Защита — ограничивать жизнь воркера и позволить менеджеру процессов перезапускать его:

```bash
php artisan queue:work redis --queue=default \
    --tries=3 --timeout=120 \
    --max-jobs=1000 --max-time=3600 --memory=256
```

* `--max-jobs` — завершиться после N задач;
* `--max-time` — завершиться через N секунд работы;
* `--memory` — завершиться, если после задачи процесс занимает больше N мегабайт (по умолчанию 128).

Важно понимать порядок: `--memory` проверяется **между** задачами. Если одна задача сама по себе превышает `memory_limit` PHP, процесс упадёт с фатальной ошибкой посреди выполнения, задача вернётся в очередь только после `retry_after` — и снова упадёт. Такие задачи нужно не «лечить» лимитами, а переписывать: потоковая обработка и дробление на части.

---

<a id="heavy-jobs"></a>
## Тяжёлые импорты и экспорты

Одна задача «импортировать файл на 400 000 строк» — это всё сразу: долгое выполнение, риск таймаута, рост памяти и нулевой прогресс при падении на 399 999-й строке. Работающая схема — разбить работу на много маленьких идемпотентных задач и объединить их в **батч**.

```php
// app/Domain/Catalog/Actions/StartPriceImport.php
public function handle(string $path, int $userId): Batch
{
    $jobs = [];
    foreach ($this->reader->chunkOffsets($path, rowsPerChunk: 2000) as [$from, $to]) {
        $jobs[] = new ImportPriceChunk($path, $from, $to);
    }

    return Bus::batch($jobs)
        ->name("price-import:{$userId}")
        ->onQueue('imports')
        ->allowFailures()
        ->then(fn (Batch $batch) => PriceImportFinished::dispatch($userId, $batch->id))
        ->catch(fn (Batch $batch, Throwable $e) => report($e))
        ->finally(fn (Batch $batch) => Storage::delete($path))
        ->dispatch();
}
```

```php
// app/Jobs/ImportPriceChunk.php
use Illuminate\Bus\Batchable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(90)]
final class ImportPriceChunk implements ShouldQueue
{
    use Batchable, Queueable;

    public function __construct(
        public string $path,
        public int $fromRow,
        public int $toRow,
    ) {}

    public function handle(PriceFileReader $reader): void
    {
        if ($this->batch()?->cancelled()) {
            return;
        }

        $rows = $reader->rows($this->path, $this->fromRow, $this->toRow);

        // Idempotent: re-running the same chunk overwrites the same SKUs.
        DB::table('product_prices')->upsert($rows, uniqueBy: ['sku'], update: ['price_cents', 'updated_at']);
    }
}
```

Что здесь важно:

* **В задачу передаются координаты работы, а не данные.** Путь к файлу и диапазон строк весят байты. Массив из двух тысяч строк в конструкторе раздует payload, память Redis и таблицу `failed_jobs`.
* **Каждая часть идемпотентна.** `upsert` по `sku` даёт тот же результат при повторе.
* **Прогресс виден.** `$batch->progress()`, `processedJobs()` и `failedJobs` можно показать пользователю. Для батчей нужна таблица `job_batches` (`php artisan make:queue-batches-table`), и её тоже нужно чистить командой `queue:prune-batches`.
* **`allowFailures()`** не отменяет весь импорт из-за одной битой части; без него первая ошибка отменяет батч.
* **Отдельная очередь `imports`** со своими воркерами не даёт импорту задержать письма и уведомления.

Та же логика для экспорта: задача читает данные потоком через `lazyById()` (см. [Eloquent и большие данные](eloquent-large-datasets#export)), пишет файл в хранилище и отправляет пользователю ссылку.

Если задача принимает модель, Laravel сериализует её вместе с загруженными связями. Атрибут `#[WithoutRelations]` (или `$model->withoutRelations()`) оставляет в payload только идентификатор — модель будет заново загружена из базы при выполнении.

---

<a id="uniqueness"></a>
## Дубликаты и гонки: уникальные задачи и after_commit

**Задача стартует раньше, чем данные закоммичены.** Классический баг: задача отправляется внутри транзакции, воркер берёт её мгновенно, ищет заказ по ID — а транзакция ещё не закоммичена. `ModelNotFoundException`, или хуже — задача работает со старыми данными. Решения:

* `'after_commit' => true` в настройках соединения — все задачи, события в очереди, письма и уведомления ждут коммита;
* интерфейс `ShouldQueueAfterCommit` на конкретной задаче;
* `->afterCommit()` при отправке.

**Одна и та же задача в очереди несколько раз.** Пользователь трижды нажал «Пересчитать», и в очереди три одинаковые тяжёлые задачи. Интерфейс `ShouldBeUnique` не даст поставить вторую, пока первая не выполнена:

```php
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Attributes\UniqueFor;

#[UniqueFor(3600)]
final class RecalculateCustomerStats implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(public int $customerId) {}

    public function uniqueId(): string
    {
        return (string) $this->customerId;
    }
}
```

Уникальность держится на атомарной блокировке в кэше (Redis, база, memcached). Блокировка снимается после выполнения или окончательного провала задачи; `ShouldBeUniqueUntilProcessing` снимает её перед началом выполнения, позволяя поставить следующую задачу, пока текущая работает. Внутри батчей уникальность не применяется.

**Две разные задачи меняют одни данные одновременно.** Здесь помогает middleware `WithoutOverlapping` с ключом по сущности: задачи с одинаковым ключом выполняются по очереди.

```php
public function middleware(): array
{
    return [(new WithoutOverlapping($this->customerId))->releaseAfter(30)->expireAfter(180)];
}
```

`expireAfter` обязателен на практике: если воркер умрёт посреди задачи, блокировка без срока жизни останется навсегда.

---

<a id="priorities"></a>
## Раздельные очереди и приоритеты

Одна очередь `default` для всего — частая причина жалоб «письмо для сброса пароля пришло через двадцать минут»: впереди стояли десять тысяч задач импорта. Разделяйте задачи по характеру нагрузки:

* `high` — то, что ждёт пользователь: письма сброса пароля, уведомления, вебхуки оплаты;
* `default` — обычные фоновые задачи;
* `imports` / `reports` — тяжёлые и долгие, со своими таймаутами и лимитами памяти.

Воркер обрабатывает очереди по приоритету слева направо: `--queue=high,default`. Для тяжёлых очередей запускайте отдельные воркеры с другими `--timeout` и `--memory`, а значит, и отдельное соединение с подходящим `retry_after`.

В Laravel 13 маршрутизацию задач по очередям можно собрать в одном месте вместо `->onQueue()` в каждом вызове:

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::route([
        SendPasswordResetLink::class => 'high',
        ImportPriceChunk::class => ['redis-long', 'imports'],
    ]);
}
```

---

<a id="supervisor"></a>
## Supervisor и Horizon

Воркеры должны работать постоянно и подниматься после падения, `--max-time` или `queue:restart`. Без менеджера процессов этого не будет.

```ini
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis --queue=high,default --tries=3 --timeout=120 --max-time=3600 --memory=256
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/worker.log
stopwaitsecs=180

[program:laravel-imports]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis-long --queue=imports --timeout=600 --memory=512
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/imports.log
stopwaitsecs=660
```

`stopwaitsecs` должен быть больше самой долгой задачи этой группы: при остановке Supervisor отправляет `SIGTERM`, воркер дорабатывает текущую задачу, и только по истечении `stopwaitsecs` процесс убивается `SIGKILL`. В Docker ту же роль играет `stop_grace_period`.

**Horizon** — для очередей на Redis удобнее: конфигурация воркеров в коде, автобалансировка процессов между очередями, дашборд с метриками, проваленными задачами и тегами.

```php
// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['high', 'default'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 2,
            'maxProcesses' => 10,
            'tries' => 3,
            'timeout' => 120,
            'memory' => 256,
            'maxTime' => 3600,
            'maxJobs' => 1000,
        ],
        'supervisor-imports' => [
            'connection' => 'redis-long',
            'queue' => ['imports'],
            'balance' => false,
            'maxProcesses' => 2,
            'timeout' => 600,
            'memory' => 512,
        ],
    ],
],
```

Правило про таймауты действует и здесь: `timeout` супервизора Horizon должен быть на несколько секунд меньше `retry_after` соединения и при этом больше таймаута любой отдельной задачи. Метрики Horizon строятся по снимкам — добавьте `Schedule::command('horizon:snapshot')->everyFiveMinutes()`.

**Деплой.** Воркеры держат код в памяти и не видят изменений без перезапуска. После переключения релиза выполните `php artisan queue:restart` (или `php artisan horizon:terminate`): воркеры доработают текущие задачи и завершатся, а Supervisor поднимет их с новым кодом. Сигнал перезапуска хранится в кэше, поэтому кэш должен быть общим для всех серверов. Совместимость payload старых задач с новым кодом — отдельная тема, она разобрана в статье про [деплой без простоя](zero-downtime-deployment-laravel#queues).

---

<a id="monitoring"></a>
## Мониторинг очередей

Очереди ломаются тихо: веб работает, ошибок 500 нет, а письма не уходят третий час. Минимальный набор сигналов:

* **Длина очереди и время ожидания** — растущая очередь означает, что воркеров мало или они стоят.
* **Скорость провалов** — число записей в `failed_jobs` за интервал.
* **Живость воркеров** — Horizon показывает статус супервизоров; без Horizon следите за процессами через Supervisor.

Встроенная команда `queue:monitor` проверяет размер очередей и при превышении порога отправляет событие `QueueBusy`, на которое можно повесить уведомление:

```php
// routes/console.php
Schedule::command('queue:monitor redis:high,redis:default --max=500')->everyMinute();
```

Подробнее о метриках, логах и алертах — в статье о [наблюдаемости](observability-monitoring-laravel).

---

<a id="common-mistakes"></a>
## Частые ошибки

**1. `--timeout` больше `retry_after`.**
Долгая задача выдаётся второму воркеру, пока первый её выполняет. Результат — дубли и гонки.

**2. Нет таймаута у HTTP-клиента.**
Зависший сокет не всегда прерывается таймаутом задачи. Воркер стоит, очередь растёт.

**3. Одна попытка по умолчанию вместе с `RateLimited` или `WithoutOverlapping`.**
Первый же возврат в очередь проваливает задачу. Увеличьте `Tries` и ограничьте ошибки через `MaxExceptions`.

**4. Неидемпотентные задачи.**
Повтор после таймаута, падения воркера или `queue:retry` отправляет второе письмо или второе списание.

**5. Данные в payload вместо идентификаторов.**
Большие массивы и модели со связями раздувают Redis и `failed_jobs`. Передавайте ID и пути к файлам.

**6. Задача из транзакции без `after_commit`.**
Воркер не находит ещё не закоммиченную запись или работает со старыми данными.

**7. Воркеры без ограничения жизни.**
Память растёт неделями, пока процесс не убьёт OOM killer посреди задачи. Используйте `--max-jobs`, `--max-time`, `--memory`.

**8. Забыть `queue:restart` после деплоя.**
Воркеры выполняют задачи старым кодом против новой схемы базы.

**9. `queue:retry all` без разбора причин.**
Задачи падают снова, а неидемпотентные успевают повторить побочные эффекты.

---

<a id="checklist"></a>
## Чеклист

1. Лестница таймаутов согласована: HTTP/SQL < таймаут задачи < `retry_after` < `stopwaitsecs` / `stop_grace_period`.
2. Установлено расширение pcntl; у Redis `block_for` не равен `0`.
3. У каждой задачи осознанно заданы `Tries`, `Backoff` и при необходимости `MaxExceptions` или `retryUntil()`.
4. Задачи идемпотентны; внешние вызовы используют ключи идемпотентности.
5. Задачи отправляются после коммита (`after_commit` или `ShouldQueueAfterCommit`).
6. В payload — идентификаторы и пути, а не большие данные; модели без связей.
7. Тяжёлые импорты разбиты на батчи с отдельной очередью и своими воркерами.
8. Воркеры перезапускаются по `--max-jobs`, `--max-time`, `--memory` под Supervisor или Horizon.
9. `queue:restart` / `horizon:terminate` — обязательный шаг деплоя.
10. `queue:prune-failed` и `queue:prune-batches` стоят в расписании, на размер очередей и скорость провалов настроены алерты.

---

## Итог

Очереди в продакшене надёжны настолько, насколько согласованы их числа и идемпотентны их задачи. Таймауты выстраиваются лестницей, повторы настраиваются под характер ошибки, тяжёлая работа дробится на маленькие повторяемые части, а воркеры живут ограниченное время и перезапускаются при каждом деплое. Тогда `failed_jobs` превращается из кладбища в рабочий список, который можно разобрать и повторить.

---

<a id="self-test-quiz"></a>
## Квиз для самопроверки

### Вопрос 1: `retry_after` соединения равен 90 секундам, воркер запущен с `--timeout=300`. Задача выполняется 200 секунд. Что произойдёт?
- А) Задача спокойно завершится — у воркера запас по таймауту.
- Б) Через 90 секунд соединение выдаст задачу другому воркеру, и она начнёт выполняться второй раз параллельно с первой.
- В) Воркер прервёт задачу через 90 секунд.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
`retry_after` отсчитывается брокером, который не знает, жив ли воркер. Через 90 секунд задача считается брошенной и выдаётся снова. Поэтому `--timeout` должен быть на несколько секунд меньше `retry_after`.
</details>

### Вопрос 2: Задача использует middleware `RateLimited` и падает в `failed_jobs`, так ни разу и не выполнив `handle()`. Почему?
- А) `RateLimited` не работает с драйвером Redis.
- Б) Возврат задачи в очередь из-за лимита расходует попытку, а по умолчанию у задачи всего одна попытка.
- В) Middleware выполняется после `handle()`.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Каждый `release()` — это израсходованная попытка. Для задач с rate limiting увеличивают `Tries` (или используют `retryUntil()`), а число реальных ошибок ограничивают через `MaxExceptions`.
</details>

### Вопрос 3: Как правильно импортировать файл на 400 000 строк через очередь?
- А) Одна задача с `#[Timeout(7200)]` и `--memory=4096` у воркера.
- Б) Батч из небольших идемпотентных задач, каждая обрабатывает свой диапазон строк, в отдельной очереди со своими воркерами.
- В) Передать все строки файла в конструктор задачи, чтобы воркеру не нужно было читать файл.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Маленькие части укладываются в таймауты и лимиты памяти, повторяются независимо и показывают прогресс. Вариант А теряет всю работу при падении на последней строке, а вариант В раздувает payload, память Redis и `failed_jobs`.
</details>