Транзакции и блокировки в Eloquent: приёмы из практики
Двойное списание остатка пакета, гонки при бронировании, дедлоки под нагрузкой — разбираем, как Eloquent работает с транзакциями и блокировками строк и где очевидный код ломается.

Классический баг интернет-магазина: два запроса одновременно читают остаток товара — «в наличии 1 штука», — оба проходят проверку и оба создают заказ. В базе минус один товар. Виноват не Eloquent, а код, который читает и пишет в двух разных запросах без блокировки. Ниже — рабочие приёмы для Laravel 10.x и 11.x на MySQL 8 и PostgreSQL 16: как обернуть операции в транзакцию, когда нужны lockForUpdate и sharedLock, и почему DB::transaction() с замыканием почти всегда лучше ручного beginTransaction.
Транзакция замыканием против ручной
Eloquent даёт два способа. Ручной — вы сами открываете, коммитите и откатываете:
use Illuminate\Support\Facades\DB;
DB::beginTransaction();
try {
$order = Order::create(['user_id' => $userId, 'total' => $total]);
$order->items()->createMany($items);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
Проблема ручного подхода не в многословности, а в том, что легко забыть rollBack в одной из веток или словить исключение до commit и оставить соединение с открытой транзакцией. Замыкание закрывает эту дыру: если внутри вылетит исключение, транзакция откатится автоматически, при успехе — закоммитится.
DB::transaction(function () use ($userId, $total, $items) {
$order = Order::create(['user_id' => $userId, 'total' => $total]);
$order->items()->createMany($items);
});
У DB::transaction() есть второй аргумент — число попыток. При дедлоке или сериализационном конфликте Laravel повторит замыкание указанное число раз. Это не «магия retry на всё»: повторяются только ошибки, которые база помечает как повторяемые (например, код дедлока MySQL 1213).
// Три попытки при дедлоке
DB::transaction(function () {
// ...
}, 3);
Замыкание с числом попыток закрывает 90% гонок в обычном CRUD. Ручной beginTransaction оставляйте для случаев, когда логика ветвится между несколькими соединениями или транзакция живёт дольше одного метода.
Почему проверка-и-запись без блокировки ломается
Вернёмся к остатку товара. Наивный код внутри транзакции всё равно допускает гонку:
DB::transaction(function () use ($productId) {
$product = Product::find($productId); // читаем остаток
if ($product->stock < 1) {
throw new OutOfStockException();
}
$product->decrement('stock'); // списываем
});
Транзакция здесь не спасает. На уровне изоляции REPEATABLE READ (умолчание MySQL) два параллельных потока прочитают stock = 1 в один момент, оба пройдут проверку, оба сделают decrement — и остаток уйдёт в минус. Обычный SELECT строку не блокирует. Нужна пессимистичная блокировка.
lockForUpdate — блокировка на запись
lockForUpdate() добавляет к запросу FOR UPDATE: строка блокируется до конца транзакции, второй поток встаёт в очередь и читает уже обновлённое значение.
DB::transaction(function () use ($productId) {
$product = Product::where('id', $productId)
->lockForUpdate()
->first();
if ($product->stock < 1) {
throw new OutOfStockException();
}
$product->decrement('stock');
});
Ключевой момент: блокировка работает только внутри транзакции. Вызвать lockForUpdate вне DB::transaction() можно, но толку ноль — блокировка снимется сразу после запроса.
sharedLock — блокировка на чтение
sharedLock() ставит LOCK IN SHARE MODE (MySQL) / FOR SHARE (PostgreSQL): другие транзакции могут читать строку, но не могут её менять, пока ваша не завершится. Подходит, когда вы читаете связанные данные и хотите гарантировать, что их не перепишут до конца вашей логики, но сами менять их не собираетесь.
Сравнение приёмов
| Приём | SQL | Когда применять | Цена |
|---|---|---|---|
| Обычный SELECT в транзакции | — | Только читаете, гонок нет | Не защищает от параллельной записи |
| lockForUpdate | FOR UPDATE | Читаете, чтобы тут же изменить (остаток, баланс) | Сериализует потоки, риск дедлока |
| sharedLock | FOR SHARE | Читаете связанные данные, менять не будете | Блокирует запись другими, может дедлочить с FOR UPDATE |
| Атомарный decrement/increment | UPDATE ... SET x = x - 1 | Простое изменение счётчика без бизнес-проверок в PHP | Нельзя вставить условную логику между чтением и записью |
Часто забывают про четвёртый вариант. Если проверка тривиальна, атомарный UPDATE с условием обходится без явной блокировки строки:
$affected = Product::where('id', $productId)
->where('stock', '>', 0)
->decrement('stock');
if ($affected === 0) {
// остатка не было — заказ не создаём
throw new OutOfStockException();
}
Здесь одна атомарная операция: база сама проверяет stock > 0 и списывает. Метод decrement возвращает число затронутых строк. Ноль — значит условие не выполнилось. Это дешевле lockForUpdate, потому что не держит строку до конца транзакции. Минус — нельзя вклинить сложную бизнес-логику на PHP между проверкой и списанием.
Дедлоки: откуда берутся и как гасить
Дедлок возникает, когда две транзакции блокируют строки в разном порядке. Транзакция A заблокировала строку 1 и ждёт строку 2; транзакция B заблокировала строку 2 и ждёт строку 1. База убивает одну из них с ошибкой Deadlock found when trying to get lock (MySQL код 1213), в Laravel это прилетает как Illuminate\Database\QueryException.
Два практических правила против дедлоков:
- Блокируйте строки в стабильном порядке. Если списываете с нескольких счетов, сортируйте по id перед блокировкой — тогда все транзакции идут в одну сторону.
- Держите транзакцию короткой. Не делайте внутри транзакции HTTP-запросов к внешним API, отправки почты и других медленных операций. Чем дольше открыта транзакция, тем выше шанс пересечься.
Пример стабильного порядка при переводе между счетами:
DB::transaction(function () use ($fromId, $toId, $amount) {
// сортируем id, чтобы блокировать всегда в одном порядке
$ids = collect([$fromId, $toId])->sort()->values()->all();
$accounts = Account::whereIn('id', $ids)
->lockForUpdate()
->get()
->keyBy('id');
if ($accounts[$fromId]->balance < $amount) {
throw new InsufficientFundsException();
}
$accounts[$fromId]->decrement('balance', $amount);
$accounts[$toId]->increment('balance', $amount);
}, 3);
Число 3 вторым аргументом даёт три повтора при дедлоке. Для перевода денег это уместно: если одну транзакцию убили, повтор пройдёт чисто, потому что первая уже завершилась.
Вложенные транзакции и savepoints
Если вызвать DB::transaction() внутри другой транзакции, Laravel не откроет новую транзакцию — он создаст точку сохранения (SAVEPOINT). Откат внутреннего замыкания откатит только до savepoint, внешняя транзакция продолжится. Это выручает, когда часть операции допустимо потерять, а остальное должно сохраниться. Но помните: реальный COMMIT произойдёт только на самом внешнем уровне. Если полагаетесь на то, что данные уже в базе после внутренней транзакции, — их там ещё нет.
afterCommit: события после реального коммита
Частая ошибка — отправить письмо или задачу в очередь прямо внутри транзакции. Если транзакция откатится, письмо уже ушло, а заказа в базе нет. Для этого есть хук DB::afterCommit() и трейт-настройка для событий модели.
DB::transaction(function () use ($order) {
$order->save();
// выполнится только после успешного COMMIT
DB::afterCommit(function () use ($order) {
SendOrderConfirmation::dispatch($order);
});
});
Если транзакция откатится, замыкание afterCommit не выполнится. Для задач в очереди у джобов есть свойство $afterCommit — тогда диспатч отложится до коммита автоматически, без ручного хука.
Если не сработало
- Блокировка не держит, гонка сохраняется. Проверьте, что
lockForUpdateвызван внутриDB::transaction(). Вне транзакции блокировка снимается сразу. - QueryException с текстом Deadlock found (код 1213). Дедлок. Добавьте повторы вторым аргументом
DB::transaction()и приведите порядок блокировки строк к единому. - Lock wait timeout exceeded (MySQL код 1205). Транзакция слишком долго держит строку. Уберите из транзакции медленные внешние вызовы, сократите её объём.
- afterCommit срабатывает сразу, а не после коммита. Скорее всего код выполняется вне транзакции — тогда
afterCommitвызывает замыкание немедленно, это ожидаемое поведение. - SQLite в тестах ведёт себя иначе. SQLite не поддерживает построчные блокировки как MySQL/PostgreSQL. Тесты на гонки гоняйте на той же СУБД, что и в проде.
Что настроить дальше
Проверьте уровень изоляции транзакций вашей СУБД — от него зависит поведение чтений внутри транзакции. Посмотрите на джобы очереди: включите $afterCommit, чтобы задачи не запускались до фиксации данных. Под нагрузкой мерьте не среднее время запроса, а число дедлоков и таймаутов блокировки: в MySQL это видно через SHOW ENGINE INNODB STATUS, в PostgreSQL — в логах при включённом log_lock_waits. Если дедлоки массовые — пересматривайте порядок блокировок и длину транзакций, а не наращивайте число повторов.
Антиблок-аналитик 115-ФЗ: Анализ выписок, поиск рисков блокировки счёта, инструкции по безопасным транзакциям.
Спросить за 41 ₽Источники: Laravel Database: Database Transactions, Laravel Query Builder: Pessimistic Locking
Частые вопросы
А если у меня Laravel 8 или 9, эти приёмы сработают?
Нужна ли транзакция, если я делаю одну операцию UPDATE?
Чем lockForUpdate отличается от sharedLock простыми словами?
Дедлоки — это всегда баг в моём коде?
Почему письмо ушло, а заказа в базе нет?
Как это тестировать, чтобы поймать гонку?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.