<? phpukraine СТАТТІ
Пошук по платформі
LARAVEL 3 вересня 2026 · 8 хв читання

Черги в Laravel без сюрпризів: ідемпотентність, повтори і afterCommit

Черга гарантує, що job виконається щонайменше один раз, а не рівно один. Розбираємо, звідки беруться дублі й втрачені задачі, як налаштувати tries, backoff і retryUntil, чому afterCommit обовʼязковий і як написати handle, який можна безпечно виконати двічі.

РP
Редакція phpukraine
Редакція платформи

Черги в 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 конекшена.

Чекліст

  1. --timeout воркера (і timeout супервізора Horizon) менший за retry_after конекшена.
  2. after_commit => true у конфізі черги; для подій — ShouldDispatchAfterCommit, для моделей — ShouldHandleEventsAfterCommit.
  3. Кожна задача має явні $tries, $maxExceptions, $timeout і backoff(); безлімітні спроби — лише разом із retryUntil().
  4. У конструктор передаються ідентифікатори й стабільний operationId, а не результати Str::uuid() з handle().
  5. Побічні ефекти захищені унікальним індексом та insertOrIgnore, а не перевіркою if (already_done).
  6. failed() не робить нічого, що потребує тих самих ресурсів, які щойно впали.
  7. Задачі в батчах — Batchable плюс перевірка cancelled() на першому рядку handle().
  8. queue:restart у деплой-скрипті, інакше воркери працюють зі старим кодом.
ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті