Транзакции и блокировки в Eloquent: сравниваем варианты
Два клиента одновременно списывают деньги с одного счёта — и остаток пакета уходит в минус, хотя проверка стояла. Разбираем, чем DB::transaction отличается от lockForUpdate и когда нужен ни тот, ни другой.

Классический баг: пользователь дважды нажимает «Оплатить», два HTTP-запроса читают баланс 100 рублей, оба видят, что денег хватает на покупку за 80, оба списывают. Итог — минус 60. Проверка if ($account->balance >= $amount) стоит, транзакция вроде бы есть, а гонка всё равно проходит. Причина в том, что транзакция сама по себе не блокирует чтение, а lockForUpdate без транзакции бесполезен. Разберём, какие инструменты даёт Eloquent (Laravel 10.x/11.x, драйвер MySQL/InnoDB и PostgreSQL), чем каждый платит и когда какой брать.
Три уровня защиты, которые часто путают
В обсуждениях смешивают три разные вещи. Транзакция гарантирует атомарность — либо все запросы применились, либо ни один. Пессимистическая блокировка не даёт другим сессиям читать строку до конца транзакции. Оптимистическая блокировка ничего не блокирует, а ловит конфликт на моменте записи. Это не взаимозаменяемые опции — они решают разные задачи и часто работают вместе.
Транзакция без блокировки защищает от частичной записи, но не от гонки чтения. Если два процесса читают одно и то же значение и решают на его основе — одной транзакции мало.
Вариант 1. Просто транзакция
DB::transaction оборачивает набор запросов в BEGIN/COMMIT и делает автоматический откат при исключении. Начиная с Laravel это ещё и повтор при deadlock: второй аргумент — число попыток.
use Illuminate\Support\Facades\DB;
// Laravel 11.x
DB::transaction(function () use ($order) {
// оба запроса применятся или оба откатятся
$order->update(['status' => 'paid']);
Payment::create([
'order_id' => $order->id,
'amount' => $order->total,
]);
}, 3); // до 3 попыток при deadlock
Что защищает: от того, что заказ пометится оплаченным, а запись о платеже не создастся. Чего не защищает: если между чтением баланса и записью в него влезет другой процесс, транзакция это не заметит — уровень изоляции по умолчанию (REPEATABLE READ в MySQL, READ COMMITTED в PostgreSQL) не блокирует конкурентов при обычном SELECT.
Вариант 2. Пессимистическая блокировка
lockForUpdate() добавляет FOR UPDATE к SELECT: строка блокируется до конца транзакции, вторая сессия на таком же запросе ждёт. sharedLock() ставит разделяемую блокировку (FOR SHARE) — читать другим можно, писать нельзя.
DB::transaction(function () use ($accountId, $amount) {
// блокируем строку счёта до COMMIT
$account = Account::where('id', $accountId)
->lockForUpdate()
->firstOrFail();
if ($account->balance < $amount) {
throw new \RuntimeException('Недостаточно средств');
}
$account->balance -= $amount;
$account->save();
});
Здесь второй запрос на тот же id не прочитает строку, пока первый не завершится. Гонка баланса закрыта. Цена — сериализация доступа: если по одной строке идёт очередь из десятков запросов, они выстроятся в линию, и latency вырастет. Плюс риск deadlock, если разные транзакции берут блокировки на несколько строк в разном порядке.
Важная деталь: lockForUpdate вне транзакции не имеет смысла — блокировка снимается сразу после запроса, потому что каждый запрос вне явной транзакции — это свой автокоммит.
Вариант 3. Оптимистическая блокировка
Ничего не блокируем. Держим в таблице поле версии (version или updated_at) и в UPDATE проверяем, что оно не изменилось с момента чтения. Если изменилось — кто-то нас опередил, откатываем и повторяем.
DB::transaction(function () use ($accountId, $amount) {
$account = Account::findOrFail($accountId);
$affected = Account::where('id', $accountId)
->where('version', $account->version) // условие на версию
->update([
'balance' => $account->balance - $amount,
'version' => $account->version + 1,
]);
if ($affected === 0) {
// версия изменилась между чтением и записью
throw new \RuntimeException('Конфликт версий, повторите');
}
});
Строки не блокируются, конкуренты не ждут — это выигрыш при высоком чтении и редких конфликтах. Цена: при частых конфликтах вы получаете поток повторов, и под нагрузкой это может оказаться дороже, чем честная блокировка. Плюс обработку повтора и лимит попыток пишете руками.
Сравнение
| Критерий | Только транзакция | lockForUpdate | Оптимистическая |
|---|---|---|---|
| Защита от гонки чтения-записи | Нет | Да | Да (через конфликт версий) |
| Конкуренты ждут | Нет | Да, выстраиваются в очередь | Нет |
| Риск deadlock | Низкий | Есть при блокировке нескольких строк | Практически нет |
| Поведение при частых конфликтах | Молча портит данные | Рост latency | Поток повторов |
| Нужно поле версии | Нет | Нет | Да |
| Кто пишет повтор | DB::transaction (при deadlock) | DB::transaction (при deadlock) | Вы сами |
Когда что выбирать
- Только транзакция — когда важна атомарность нескольких записей, но нет решения на основе прочитанного значения. Пример: создать заказ и позиции к нему одним махом.
- lockForUpdate — когда есть «прочитал, проверил, записал» на одной-двух горячих строках и конфликты частые. Списание баланса, резерв последнего товара на складе, инкремент счётчика при высокой конкуренции по одной строке.
- Оптимистическая — когда чтений много, а реальные пересечения редки: редактирование карточки товара в админке, где два менеджера одновременно правят одну запись раз в месяц. Блокировать всех ради редкого конфликта дороже, чем изредка попросить повторить.
Критерий простой: часто ли две транзакции реально спорят за одну строку. Часто — пессимистическая. Редко — оптимистическая. Спора нет вообще — хватит транзакции.
Что ломается на практике
- lockForUpdate без транзакции. Самая частая ошибка. Блокировка живёт ровно один автокоммитный запрос и сразу снимается — защиты ноль. Всегда внутри
DB::transaction. - Разный порядок блокировки строк. Транзакция A блокирует строку 1, потом 2; транзакция B — 2, потом 1. Классический deadlock. InnoDB одну из них убьёт с ошибкой. Лечится единым порядком блокировки (например, всегда по возрастанию
id) и повтором. - Тяжёлая работа внутри транзакции. HTTP-запрос к платёжному шлюзу или отправка письма внутри
DB::transactionдержит блокировку всё время сетевого вызова. Внешние вызовы выносите за пределы транзакции. - Оптимистическая блокировка без лимита повторов. Под шквалом конкурентных записей повторы могут зациклиться. Ставьте потолок попыток и отдавайте пользователю понятную ошибку.
- SQLite в тестах, MySQL в проде. Поведение блокировок различается: SQLite блокирует базу целиком, тонкости
FOR UPDATEтам не воспроизводятся. Тестируйте конкурентные сценарии на том же движке, что в проде.
Что делать дальше
Определите горячие строки — те, за которые реально спорят запросы (счета, остатки, счётчики), и решите по каждой отдельно, а не выбирайте одну стратегию на всё приложение. Проверьте, что все lockForUpdate обёрнуты в транзакцию — это грепается по коду за минуту. Настройте логирование deadlock на стороне БД (в MySQL — SHOW ENGINE INNODB STATUS) и посмотрите, есть ли они вообще под текущей нагрузкой. Уровень изоляции транзакций и его влияние на блокировки — отдельная тема, здесь мы разбирали только выбор стратегии.
Антиблок-аналитик 115-ФЗ: Анализ выписок, поиск рисков блокировки счёта, инструкции по безопасным транзакциям.
Спросить за 41 ₽Источники: Laravel Database: Query Builder — Pessimistic Locking, Laravel Database: Database Transactions
Частые вопросы
Работает ли lockForUpdate в PostgreSQL так же, как в MySQL?
А если у меня Laravel 9 или старше?
Что делать, если получаю Deadlock found when trying to get lock?
Можно ли обойтись вообще без транзакций, если операция одна?
Оптимистическая блокировка не сработала — версия не проверилась. Почему?
Насколько lockForUpdate замедлит приложение под нагрузкой?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.