Задача в очереди Laravel выполнится больше одного раза. Не «может быть при редком сбое», а выполнится, если вы достаточно долго живёте в продакшене. Самый частый и самый обидный способ получить дубль - настройка, где retry_after меньше времени, которое задача реально работает. Воркер ничего не сломал, API не упал, а клиенту дважды ушло письмо, SMS или списание.

Ниже: как это воспроизвести за пять минут, почему это происходит, и как закрыть проблему так, чтобы она не зависела от правильности конфигов.

Что на самом деле делает retry_after

retry_after - это не таймаут задачи. Это время, через которое очередь считает, что воркер, взявший задачу, умер, и отдаёт её другому воркеру. Очередь не знает, жив ли первый воркер. Она знает только, что прошло N секунд, а подтверждения (удаления задачи) нет.

За таймаут отвечает другое: свойство $timeout у задачи или ключ --timeout у queue:work. Это ограничение, которое воркер накладывает на себя сам: по сигналу SIGALRM через расширение pcntl он убивает процесс, если задача работает дольше лимита.

Два числа никак не связаны друг с другом на уровне фреймворка. Laravel не упадёт при запуске, если retry_after = 10, а $timeout = 60. В документации лишь сказано, что --timeout должен быть на несколько секунд меньше retry_after. Это требование, а не проверка.

В итоге получаем ситуацию:

  1. Воркер A берёт задачу в момент t=0 и начинает долгий вызов.
  2. В момент t=retry_after очередь видит, что задача не подтверждена, и снова делает её доступной.
  3. Воркер B (их же несколько, иначе очередь бы не масштабировалась) берёт ту же задачу.
  4. Теперь два процесса одновременно выполняют один и тот же код с одними и теми же данными.

Стоит отметить, что воркер A при этом не прерывается. Он спокойно дойдёт до конца, и, возможно, оба вызова внешнего API успешно завершатся.

Воспроизведение на двух воркерах

Нужен чистый Laravel-проект с драйвером database (так проще всего, Redis не нужен). Создайте таблицы очереди:

php artisan queue:table
php artisan migrate

В .env:

QUEUE_CONNECTION=database

В config/queue.php у соединения database поставьте заведомо маленький retry_after:

'database' => [
    'driver' => 'database',
    'table' => 'jobs',
    'queue' => 'default',
    'retry_after' => 5, // секунд, меньше времени работы задачи
    'after_commit' => false,
],

Теперь задача, имитирующая медленный платный вызов:

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;

class SlowCall implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $timeout = 30;
    public int $tries = 3;

    public function handle(): void
    {
        Log::info('CALL start, attempt ' . $this->attempts());
        sleep(12); // здесь мог бы быть HTTP-запрос к платному API
        Log::info('CALL done, attempt ' . $this->attempts());
    }
}

Поставьте одну задачу и запустите два воркера в разных терминалах:

php artisan tinker --execute="App\Jobs\SlowCall::dispatch();"
php artisan queue:work --timeout=30   # терминал 1
php artisan queue:work --timeout=30   # терминал 2
 tail -f storage/logs/laravel.log

Наблюдаемый результат в логе примерно такой:

[00:00:00] CALL start, attempt 1
[00:00:06] CALL start, attempt 2
[00:00:12] CALL done, attempt 1
[00:00:18] CALL done, attempt 2

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

Если заменить sleep(12) на реальный Http::post() в платный сервис, вы получите два списания.

Это не единственный путь к дублю

Неверный retry_after - самый наглядный источник, но не единственный. Задача выполнится повторно и при полностью корректных настройках:

  • Падение после побочного эффекта. Вызов API прошёл, а процесс был убит (OOM, деплой, kill -9) до удаления задачи из очереди. Очередь отдаст её снова. Это модель доставки «как минимум один раз» (at-least-once), и в ней нет «ровно одного».
  • Исключение после вызова. Вы вызвали API, потом записали результат в БД, и запись упала. Задача уйдёт на retry и сходит в API ещё раз.
  • Таймаут по pcntl. Воркер убил процесс на 30-й секунде, но запрос уже ушёл провайдеру и был им обработан. Ответа вы не получили, поэтому для вас вызов «не состоялся».
  • Перезапуск воркеров. supervisorctl restart или queue:restart во время выполнения. Если stopwaitsecs в Supervisor меньше времени задачи, процесс получит SIGKILL.
  • Ручной повтор. php artisan queue:retry all для упавших задач, часть из которых успела выполнить внешний вызов.

Поэтому правильная постановка задачи звучит не как «как гарантировать единственный запуск», а как «как сделать так, чтобы второй запуск был безвредным».

Слой 1: развести числа в конфигурации

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

HTTP timeout клиента  <  $timeout задачи  <  retry_after  <  stopwaitsecs в Supervisor

Например: HTTP-клиент ждёт 20 секунд, задача ограничена 30, retry_after равен 90, а stopwaitsecs - 120. Запас нужен, потому что задача может вычитать данные из БД, подождать блокировку и только потом пойти в сеть.

Полезно также задавать таймаут в самом коде вызова, а не полагаться только на $timeout:

$response = Http::timeout(20)->connectTimeout(5)->post($url, $payload);

Тогда исключение ConnectionException приходит предсказуемо и вы сами решаете, что с ним делать, а не ждёте, пока pcntl убьёт процесс посреди запроса.

И ещё одна деталь: если на хосте нет pcntl (некоторые контейнерные образы, Windows), $timeout вообще не работает, и задача может висеть сколько угодно. Проверьте php -m | grep pcntl на том окружении, где реально крутятся воркеры.

Слой 2: ключ идемпотентности во внешнем API

Если провайдер поддерживает заголовок Idempotency-Key (Stripe, ЮKassa, многие платёжные и почтовые сервисы), используйте его. Повторный запрос с тем же ключом вернёт результат первого, а не выполнит операцию заново.

Главное правило: ключ создаётся один раз при постановке задачи, а не внутри handle(). Если сгенерировать Str::uuid() в handle(), каждая попытка получит новый ключ, и защита обнулится.

// в месте постановки задачи
ChargeOrder::dispatch($order->id, key: "charge-order-{$order->id}-v1");

Ключ либо детерминирован бизнес-сущностью (заказ + тип операции), либо сгенерирован до dispatch и лежит в свойстве задачи. Он сериализуется вместе с payload и переживает все повторные доставки.

У ключей идемпотентности есть срок хранения на стороне провайдера (часто 24 часа). Если задача может повториться через неделю после ручного queue:retry, одной этой защиты недостаточно.

Слой 3: собственная запись о вызове в PostgreSQL

Не у каждого API есть идемпотентность, а у некоторых она слабая. Поэтому второй слой строится у вас, в базе, с уникальным ограничением. Именно ограничение, а не проверка «if не существует» в коде, потому что две копии задачи работают параллельно и обе пройдут проверку.

Миграция:

Schema::create('outbound_calls', function (Blueprint $t) {
    $t->id();
    $t->string('idempotency_key')->unique();
    $t->string('status')->default('started'); // started | done
    $t->jsonb('response')->nullable();
    $t->timestamps();
});

Задача:

<?php

namespace App\Jobs;

use App\Models\OutboundCall;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\DB;

class ChargeOrder implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $timeout = 30;
    public int $tries = 3;

    public function __construct(
        public int $orderId,
        public string $key,
    ) {}

    public function handle(): void
    {
        // Атомарно: либо создаём запись, либо она уже есть.
        DB::table('outbound_calls')->insertOrIgnore([
            'idempotency_key' => $this->key,
            'status' => 'started',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        // Блокируем строку: вторая копия будет ждать первую.
        DB::transaction(function () {
            $call = OutboundCall::where('idempotency_key', $this->key)
                ->lockForUpdate()
                ->firstOrFail();

            if ($call->status === 'done') {
                return; // уже выполнено, второй запуск безвреден
            }

            $response = Http::timeout(20)
                ->withHeaders(['Idempotency-Key' => $this->key])
                ->post(config('services.billing.url') . '/charges', [
                    'order_id' => $this->orderId,
                ])
                ->throw();

            $call->update([
                'status' => 'done',
                'response' => $response->json(),
            ]);
        });
    }
}

insertOrIgnore в PostgreSQL превращается в INSERT ... ON CONFLICT DO NOTHING, поэтому гонка двух воркеров на создании записи безопасна. lockForUpdate() сериализует выполнение: пока первая копия держит блокировку и ходит в API, вторая стоит на SELECT ... FOR UPDATE. Когда первая коммитит со статусом done, вторая видит его и выходит, не делая вызова.

Проверка на тех же двух воркерах: поставьте ChargeOrder с одним и тем же ключом дважды и запустите воркеры с заниженным retry_after. В логе провайдера (или в заглушке, которую вы подставили вместо API) будет один запрос, а в таблице outbound_calls - одна строка со статусом done.

Что остаётся неохваченным

Есть узкое окно: вызов API прошёл, но процесс убит до записи done. Транзакция откатится, при повторе задача снова пойдёт в API. Здесь и работает слой 2: тот же Idempotency-Key заставит провайдера вернуть результат первого вызова. Два слоя закрывают разные дыры, и ни один из них не заменяет другой.

Если у API нет идемпотентности, окно остаётся, и тогда нужно либо запрашивать у провайдера статус операции по вашему идентификатору перед повтором, либо принять риск и мониторить такие случаи вручную. Честнее это признать, чем делать вид, что блокировка в вашей БД защищает чужую систему.

Чем не стоит закрывать проблему

ShouldBeUnique. Он предотвращает постановку дублей в очередь, но не защищает от повторной доставки уже стоящей задачи. Блокировка к тому же имеет срок жизни (uniqueFor) и может истечь раньше, чем задача закончится.

WithoutOverlapping. Хороший инструмент против параллельного запуска, но он тоже построен на кеш-блокировке с expireAfter. Если задача работает дольше, блокировка протухает и вторая копия заходит. Это снижает вероятность, а не исключает дубль. Он не помнит, что вызов уже был выполнен, и после окончания первой копии вторая спокойно сходит в API.

$tries = 1. Это убирает retry из-за исключений, но не убирает повторную доставку по retry_after или после падения воркера. А ещё вы теряете автоматическое восстановление после временных сбоев.

Увеличение retry_after до часа. Прячет симптом, но делает задачу, которую реально убил OOM, невидимой для очереди на целый час. И всё равно не спасает от ручного queue:retry.

Проверочный список перед релизом задачи с внешним вызовом

  1. Задан ли retry_after с запасом больше $timeout и больше клиентского таймаута HTTP?
  2. Больше ли stopwaitsecs в Supervisor времени самой долгой задачи?
  3. Создаётся ли ключ идемпотентности вне handle() и передаётся ли он провайдеру?
  4. Есть ли в вашей БД уникальное ограничение на операцию, а не только проверка в коде?
  5. Что произойдёт, если процесс убить сразу после ответа API? Ответ должен быть не «наверное, ничего».

Вторая копия задачи - не аварийная ситуация, а штатный режим работы очереди. Проектировать надо так, чтобы она ничего не стоила: тогда retry_after, деплой в разгар обработки и ручной queue:retry перестают быть поводом для ночного дежурства.