<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE

Як працювати з транзакціями в Laravel і коли потрібен lockForUpdate?

DB::transaction($callback, $attempts) обгортає замикання в транзакцію, відкочує її на будь-якому Throwable і повторює лише при deadlock чи lock wait timeout; lockForUpdate() потрібен там, де ви читаєте значення, щоб на його основі писати, і блокування тримається до COMMIT — тому має сенс тільки всередині транзакції.

У нас двічі списався товар зі складу, хоча код перевіряє залишок перед списанням — де помилка?
Чим DB::transaction відрізняється від beginTransaction/commit і навіщо другий аргумент?
Job усередині транзакції падає з ModelNotFoundException, хоча модель точно створена. Чому?
Коли sharedLock, а коли lockForUpdate?
транзакції lockForUpdate deadlock afterCommit конкурентність

DB::transaction(Closure $callback, int $attempts = 1) — це тонка обгортка: beginTransaction(), виклик замикання, commit(), а на будь-якому ThrowablerollBack() і проброс винятку далі. Звідси перша практична порада: ніколи не ковтайте виняток усередині замикання. Якщо ви обгорнули частину коду в try/catch і нічого не кинули, Laravel дійде до commit() і збереже половину роботи — база не знає про вашу логіку, вона бачить лише успішне завершення. Ручні DB::beginTransaction()/DB::commit() потрібні рідко: коли транзакція має пережити межу одного методу або коли ви керуєте нею з тесту. У всіх інших випадках замикання надійніше, бо забути rollBack() у ньому неможливо.

Другий аргумент — це кількість спроб, і ретрай спрацьовує вибірково. Laravel перевіряє помилку через causedByConcurrencyError(), який ловить характерні повідомлення драйверів: Deadlock found when trying to get lock і Lock wait timeout exceeded у MySQL, deadlock detected та serialization failure у PostgreSQL, database is locked у SQLite. Звичайний ValidationException чи порушення NOT NULL не повторюються — вони просто відкочують транзакцію. Є ще одне обмеження: у handleTransactionException Laravel дивиться на transactionLevel(), і якщо ви всередині вкладеної транзакції (тобто фактично всередині SAVEPOINT), повтор не робиться взагалі. Головна ж вимога до ретраю — ідемпотентність: замикання виконається з нуля вдруге і втретє, тому все, що не можна зробити двічі, всередині йому не місце.

Саме тут з'являється afterCommit. Транзакція, яка ще не закомітилась, невидима для інших з'єднань — а воркер черги працює на окремому з'єднанні. Тому SendOrderReceipt::dispatch($order) всередині транзакції — класична гонка: Redis отримує job миттєво, воркер підхоплює його за мілісекунди й падає з ModelNotFoundException, бо orders.id ще не існує. Лікується трьома способами: ->afterCommit() на конкретному диспатчі, public bool $afterCommit = true; у класі job'а або 'after_commit' => true у конфігурації з'єднання черги — тоді правило діє глобально, а виняток робиться через ->beforeCommit(). Для подій і слухачів у Laravel 10+ є контракти ShouldDispatchAfterCommit (на самій події) і ShouldHandleEventsAfterCommit (на слухачі), а для довільного коду — DB::afterCommit(fn () => ...), який поза транзакцією просто виконується негайно. Модельні події created/updated за замовчуванням спрацьовують усередині транзакції, тож обсервер, який щось надсилає назовні, треба позначати явно.

Транзакція гарантує атомарність, але не гарантує, що між вашим SELECT і вашим UPDATE ніхто не втрутився. Класична дірка — read-modify-write: прочитали stock, порівняли з $qty у PHP, зменшили, зберегли. Два паралельних запити прочитають однакове значення й обидва вважатимуть, що товару вистачає. lockForUpdate() додає FOR UPDATE і перетворює читання на ексклюзивне: другий процес зупиняється на самому SELECT і продовжить лише після вашого COMMIT, причому побачить уже нове значення (у MySQL на REPEATABLE READ звичайний SELECT читає знімок, а блокувальний — останню закомічену версію). sharedLock() дає слабше блокування — lock in share mode у MySQL, for share у PostgreSQL: кілька процесів можуть читати паралельно, і жоден не змінить рядок, доки ви не завершите. Він доречний, коли ви читаєте довідник, від якого залежить запис в іншу таблицю, і категорично недоречний як «легша версія» lockForUpdate: два процеси з S-lock, які потім спробують зробити UPDATE, чекатимуть одне одного і дадуть deadlock замість черги.

Межі й ціна. Блокування живе рівно стільки, скільки транзакція, тому поза DB::transaction lockForUpdate() не робить нічого корисного, а всередині — тримає рядок увесь час, доки ви робите будь-що інше; HTTP-виклик до платіжного шлюзу під блокуванням гарантує, що сусідні запити впруться в innodb_lock_wait_timeout (50 секунд за замовчуванням у MySQL) або в lock_timeout PostgreSQL. Блокувати можна лише те, що існує: у сценарії «створити, якщо немає» рятує унікальний індекс, а не FOR UPDATE. І окрема пастка тестування: SQLite-грамотка Laravel просто ігнорує блокування — compileLock() повертає порожній рядок, — тому feature-тест на in-memory SQLite не доведе, що ваш lockForUpdate() узагалі потрапляє в SQL. Нарешті, часто блокування взагалі не потрібне: там, де все зводиться до одного оператора, атомарний UPDATE ... WHERE stock >= ? із перевіркою кількості змінених рядків дешевший, коротший і не створює жодного шансу на deadlock.

use Illuminate\Support\Facades\DB;

// Другий аргумент — кількість СПРОБ, а не таймаут: Laravel повторить
// замикання цілком, якщо драйвер повернув deadlock або lock wait timeout
$order = DB::transaction(function () use ($user, $productId, $qty) {
    // FOR UPDATE: рядок заблоковано до COMMIT.
    // Паралельний запит зупиниться саме тут, а не прочитає старий stock.
    $product = Product::whereKey($productId)->lockForUpdate()->firstOrFail();

    if ($product->stock < $qty) {
        // Будь-який Throwable = автоматичний ROLLBACK і проброс далі
        throw new OutOfStockException($product->id);
    }

    $product->decrement('stock', $qty);

    $order = Order::create([
        'user_id' => $user->id,
        'product_id' => $product->id,
        'quantity' => $qty,
    ]);

    // Без afterCommit() воркер може взяти job раніше за COMMIT
    // і впасти з ModelNotFoundException на свіжому $order->id
    SendOrderReceipt::dispatch($order)->afterCommit();

    // Довільний побічний ефект після успішного COMMIT;
    // поза транзакцією замикання виконається негайно
    DB::afterCommit(fn () => Cache::forget("stock:{$product->id}"));

    return $order;
}, attempts: 3);

// Той самий сценарій без блокування взагалі: одна атомарна операція,
// 0 змінених рядків означає «залишку не вистачило»
$affected = Product::whereKey($productId)
    ->where('stock', '>=', $qty)
    ->decrement('stock', $qty);
Що `DB::transaction($cb, 3)` повторює замикання не на будь-якій помилці, а лише на конкурентних (deadlock, «Lock wait timeout exceeded», serialization failure), і лише на верхньому рівні вкладеності.
Що повтор означає вимогу ідемпотентності: замикання виконається вдруге цілком, тому листи, HTTP-виклики і платіжні запити всередині нього неприпустимі.
Що `lockForUpdate()` тримає рядок до `COMMIT`/`ROLLBACK`, отже поза транзакцією (в autocommit) блокування знімається одразу і не захищає нічого.
Що job, надісланий усередині транзакції, воркер може взяти раніше за COMMIT — тому `->afterCommit()`, `public $afterCommit = true` або `'after_commit' => true` у конфізі черги.
Що `sharedLock()` дозволяє паралельні читання, і саме тому два процеси, які потім роблять UPDATE, надійно ловлять deadlock: обидва тримають S-lock і чекають на X-lock.
Що альтернатива блокуванню — атомарний `UPDATE ... WHERE stock >= ?` з перевіркою кількості змінених рядків або унікальний індекс замість перевірки «чи існує».
Ловити виняток усередині замикання `DB::transaction` і не кидати його далі: Laravel вважає, що все добре, і комітить транзакцію з половиною змін.
Викликати `Product::lockForUpdate()->first()` поза транзакцією і вважати, що рядок заблоковано: в autocommit блокування знімається наступним же тиком.
Робити read-modify-write без блокування: прочитали `stock`, порахували в PHP, зберегли — два паралельних запити спокійно спишуть той самий залишок двічі.
Ставити `$attempts = 5` і залишати всередині `Mail::send()` або запит до платіжного шлюзу: після ретраю клієнт отримає два листи і два списання.
Перевіряти конкурентність тестами на SQLite: у SQLiteGrammar `compileLock()` повертає порожній рядок, тобто `lockForUpdate()` просто зникає з SQL і тест «зелений» на неробочому коді.
Тримати транзакцію відкритою навколо HTTP-виклику до зовнішнього API: рядки заблоковані на весь час мережевого таймауту, і `innodb_lock_wait_timeout` (50 с за замовчуванням) починає валити сусідні запити.
Розраховувати, що вкладений `DB::transaction` — це справжня транзакція: це SAVEPOINT, і повтор при deadlock на вкладеному рівні не працює.
ПОРАДА

Скажіть двома реченнями: «Транзакція гарантує атомарність, але не захищає від того, що хтось прочитав те саме значення, що і я — для цього потрібен lockForUpdate або атомарний UPDATE з умовою». І одразу додайте про повтори: «`DB::transaction($cb, 3)` виконає замикання вдруге цілком, тому все, що не можна зробити двічі, виноситься в `afterCommit`».

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Блокування живе рівно стільки, скільки транзакція: в autocommit воно знімається одразу після SELECT. sharedLock() дає S-lock (`lock in share mode` у MySQL, `for share` у PostgreSQL) — його можуть узяти кілька процесів одночасно, і коли кожен спробує підвищити його до X-lock для UPDATE, вони заблокують одне одного. DB::transaction ретраїть не будь-який виняток, а лише конкурентні помилки; решта відкочує транзакцію і летить далі.