Задача в очереди 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. Это требование, а не проверка.
В итоге получаем ситуацию:
- Воркер A берёт задачу в момент t=0 и начинает долгий вызов.
- В момент t=retry_after очередь видит, что задача не подтверждена, и снова делает её доступной.
- Воркер B (их же несколько, иначе очередь бы не масштабировалась) берёт ту же задачу.
- Теперь два процесса одновременно выполняют один и тот же код с одними и теми же данными.
Стоит отметить, что воркер 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.
Проверочный список перед релизом задачи с внешним вызовом
- Задан ли
retry_afterс запасом больше$timeoutи больше клиентского таймаута HTTP? - Больше ли
stopwaitsecsв Supervisor времени самой долгой задачи? - Создаётся ли ключ идемпотентности вне
handle()и передаётся ли он провайдеру? - Есть ли в вашей БД уникальное ограничение на операцию, а не только проверка в коде?
- Что произойдёт, если процесс убить сразу после ответа API? Ответ должен быть не «наверное, ничего».
Вторая копия задачи - не аварийная ситуация, а штатный режим работы очереди. Проектировать надо так, чтобы она ничего не стоила: тогда retry_after, деплой в разгар обработки и ручной queue:retry перестают быть поводом для ночного дежурства.
