Черги в Laravel виглядають простими: dispatch(new SendInvoice($id)) і задача колись виконається. Проблеми починаються, коли задача виконується двічі, або коли вона падає з ModelNotFoundException на щойно створеній моделі, або коли ретрай списує гроші вдруге. Усе це наслідки однієї властивості: черга гарантує доставку щонайменше один раз, а не рівно один раз. Ця стаття про те, що з цього випливає для коду.
Чому доставка щонайменше один раз
Воркер бере задачу з черги, позначає її зарезервованою, виконує handle() і лише потім видаляє з черги. Між «виконав» і «видалив» є вікно, у якому процес може померти: SIGKILL від OOM-killer, перезавантаження контейнера, обрив мережі до Redis. Задача залишається в черзі й буде виконана ще раз — уже після того, як побічні ефекти першої спроби відбулись.
Друге джерело дублів — таймаути видимості. У драйверах database і redis за це відповідає retry_after у config/queue.php:
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90, // через 90 с зарезервована задача повертається в чергу
'block_for' => null,
],
Якщо задача виконується довше за retry_after, черга вважає її загубленою й віддає іншому воркеру — при тому що перший досі працює. Два handle() одночасно на тих самих даних. Тому правило жорстке: --timeout воркера має бути меншим за retry_after, і з запасом. Воркер із --timeout=60 при retry_after=90 безпечний; --timeout=120 при retry_after=90 гарантує дублі на довгих задачах. Для SQS роль retry_after виконує Visibility Timeout, налаштований на боці AWS.
Третє джерело — самі ретраї. $tries = 3 означає, що при винятку після часткового виконання handle() запуститься з нуля. Якщо перша половина методу вже надіслала лист і списала кошти, а впала друга — повтор зробить це вдруге.
Висновок практичний: handle() треба писати так, щоб повторний виклик із тими самими аргументами був безпечним. Не «щоб не було ретраїв», а щоб ретраї нічого не ламали.
tries, backoff, retryUntil і failed()
Стандартний queue:work за замовчуванням має --tries=1: один виняток — і задача одразу у failed_jobs. Налаштування на класі задачі перекриває аргументи команди, і саме там йому місце, бо політика повторів залежить від задачі, а не від воркера.
final class SyncContactToCrm implements ShouldQueue
{
use Queueable;
/** Скільки разів пробувати. 0 — без обмеження, тоді обовʼязково retryUntil(). */
public int $tries = 5;
/** Скільки «справжніх» винятків дозволено — рятує від 5 спроб на один і той самий 422. */
public int $maxExceptions = 2;
/** Задача не переживе 30 с — падаємо явно, а не мовчки продовжуємо. */
public int $timeout = 30;
/** Таймаут одразу вважати провалом, а не поводитись як зі звичайним винятком. */
public bool $failOnTimeout = true;
/** Модель видалили, поки задача чекала — тихо викидаємо задачу. */
public bool $deleteWhenMissingModels = true;
/** @return array<int, int> пауза перед 2-ю, 3-ю, 4-ю+ спробами, у секундах */
public function backoff(): array
{
return [10, 60, 300];
}
public function retryUntil(): \DateTimeInterface
{
return now()->addHour();
}
public function failed(?\Throwable $e): void
{
// Викликається один раз після останньої спроби. Тут — компенсація і сигнал.
}
}
Кілька неочевидних деталей. backoff() як масив дає прогресивні паузи; останнє значення застосовується до всіх наступних спроб. retryUntil() перевіряється перед кожною спробою і має пріоритет над $tries, тож комбінація $tries = 0 плюс retryUntil() — це «пробуй, поки має сенс», а не «пробуй вічно». failed() — не місце для важкої логіки: якщо задача провалилась через недоступну базу, failed() теж не зможе в неї писати. Логуйте й ставте компенсацію окремою задачею.
Для зовнішніх API краще не крутити $tries вручну, а взяти готові middleware:
/** @return array<int, object> */
public function middleware(): array
{
return [
new ThrottlesExceptions(5, 10 * 60), // 5 помилок → пауза 10 хв на всю чергу задачі
(new WithoutOverlapping($this->contactId)) // не більше одного handle на контакт
->releaseAfter(30)
->expireAfter(120),
];
}
WithoutOverlapping тримає блокування в кеші й знімає паралельність, але не ідемпотентність: після відпускання блокування задача все одно виконається. Так само ShouldBeUnique захищає від дублювання dispatch, а не від повторного виконання тієї самої задачі.
afterCommit і події в транзакціях
Класична помилка: dispatch усередині транзакції.
DB::transaction(function () use ($data) {
$order = Order::create($data);
ProcessOrder::dispatch($order); // ← воркер може стартувати до COMMIT
});
Redis приймає задачу негайно, воркер підхоплює її за мілісекунди й робить Order::findOrFail() — рядка ще немає, бо транзакція не закомічена. Отримуєте ModelNotFoundException, який неможливо відтворити локально, бо локально воркер повільніший за транзакцію. А якщо транзакція відкотиться, задача залишиться в черзі й працюватиме з даними, яких ніколи не існувало.
Лікується на трьох рівнях. Глобально — у конфізі конекшена:
'redis' => [
'driver' => 'redis',
// ...
'after_commit' => true,
],
Точково — на класі задачі (public bool $afterCommit = true;) або на місці виклику: ProcessOrder::dispatch($order)->afterCommit(). Є і зворотна операція ->beforeCommit(), коли глобально ввімкнено, а конкретну задачу треба відправити негайно.
Те саме стосується подій і обсерверів, які запускають задачі. Івент, що реалізує Illuminate\Contracts\Events\ShouldDispatchAfterCommit, не буде розісланий до коміту; модель, що реалізує Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit, відкладає свої created/updated-події. Для довільного коду є DB::afterCommit(fn () => ...). Поза транзакцією всі три варіанти виконуються одразу, тож ставити їх безпечно.
Ідемпотентний handle на прикладі списання
Ідемпотентність — це не «перевірити, чи вже робили» через if, бо між перевіркою й записом влізає другий воркер. Це унікальний ключ операції в базі, який робить повторний запис неможливим.
Ключ генерується при dispatch, а не в handle(), і передається в конструктор. Таблиця wallet_entries має унікальний індекс на (wallet_id, operation_id).
final class DebitWallet implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public function __construct(
public int $walletId,
public int $amountMinor,
public string $operationId, // напр. UUID платежу, стабільний між спробами
) {}
public function handle(): void
{
DB::transaction(function (): void {
$inserted = DB::table('wallet_entries')->insertOrIgnore([
'wallet_id' => $this->walletId,
'operation_id' => $this->operationId,
'amount_minor' => -$this->amountMinor,
'created_at' => now(),
]);
if ($inserted === 0) {
return; // операцію вже застосовано попередньою спробою
}
$wallet = Wallet::query()->lockForUpdate()->findOrFail($this->walletId);
$wallet->decrement('balance_minor', $this->amountMinor);
});
}
}
insertOrIgnore перетворюється на INSERT IGNORE в MySQL і ON CONFLICT DO NOTHING у PostgreSQL — рішення «нове чи повтор» приймає база на унікальному індексі, а не PHP. lockForUpdate потрібен, щоб два різні списання з одного гаманця не перезаписали баланс.
Той самий принцип для зовнішніх викликів: більшість платіжних і мейл-API приймають ключ ідемпотентності в заголовку, і його теж треба брати з конструктора задачі, а не генерувати в handle().
Http::withHeaders(['Idempotency-Key' => $this->operationId])
->post($this->endpoint, $this->payload)
->throw();
Якщо API ключів не приймає — записуйте факт виклику в свою таблицю з унікальним індексом до запиту, а результат оновлюйте після.
Батчі й ланцюжки
Батч потрібен, коли є N однотипних задач і треба знати, коли вони всі завершились. Таблиця створюється через php artisan make:queue-batches-table.
$batch = Bus::batch(
$invoiceIds->map(fn (int $id) => new SendInvoice($id))->all()
)
->name('invoices-monthly')
->allowFailures() // одна помилка не скасовує решту
->onQueue('mail')
->then(fn (Batch $batch) => ReportBatchDone::dispatch($batch->id))
->catch(fn (Batch $batch, Throwable $e) => Log::error('batch', ['id' => $batch->id]))
->finally(fn (Batch $batch) => Log::info('batch finished', ['failed' => $batch->failedJobs]))
->dispatch();
Задачі в батчі мають використовувати трейт Batchable і на початку handle() перевіряти скасування, інакше $batch->cancel() нічого не зупинить:
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
// ...
}
Ланцюжок — про послідовність, а не про групу. Bus::chain([new ReserveStock($id), new ChargeCard($id), new ShipOrder($id)])->catch(...)->dispatch() виконує задачі одну за одною і зупиняє ланцюжок на першій, що остаточно провалилась. Важливо: retry такої задачі через queue:retry продовжує ланцюжок із того ж місця. Тобто кожна ланка може бути виконана повторно через години після решти — усі аргументи мають бути серіалізовними ідентифікаторами, а не «свіжими» обʼєктами. Ланцюжок можна доповнювати зсередини через $this->prependToChain() і $this->appendToChain().
Horizon поверх цього дає видимість: метрики очікування по чергах, теги задач і сповіщення про довгі waits. Пам'ятайте, що timeout супервізора в config/horizon.php підпадає під те саме правило, що й --timeout воркера: він має бути меншим за retry_after конекшена.
Чекліст
--timeoutворкера (іtimeoutсупервізора Horizon) менший заretry_afterконекшена.after_commit => trueу конфізі черги; для подій —ShouldDispatchAfterCommit, для моделей —ShouldHandleEventsAfterCommit.- Кожна задача має явні
$tries,$maxExceptions,$timeoutіbackoff(); безлімітні спроби — лише разом ізretryUntil(). - У конструктор передаються ідентифікатори й стабільний
operationId, а не результатиStr::uuid()зhandle(). - Побічні ефекти захищені унікальним індексом та
insertOrIgnore, а не перевіркоюif (already_done). failed()не робить нічого, що потребує тих самих ресурсів, які щойно впали.- Задачі в батчах —
Batchableплюс перевіркаcancelled()на першому рядкуhandle(). queue:restartу деплой-скрипті, інакше воркери працюють зі старим кодом.