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

Типичная авария на боевом проекте: пользователь оформил заказ, платёжный шлюз списал деньги, а через пять минут в базе заказа нет. В логах — Deadlock found when trying to get lock и откат транзакции. Письмо «спасибо за покупку» при этом ушло. Причина не в платёжке и не в MySQL, а в том, как код на Laravel обращается с DB::transaction(): разработчик считал вызов метода атомарной операцией, а получил вложенные транзакции, события внутри блокировки и очередь, которая читает ещё не закоммиченную запись.
Разберём, что именно происходит внутри DB::transaction в Laravel 11.x, где границы транзакции влияют на архитектуру, а не только на пару строк кода, и какие решения приходится закладывать заранее, чтобы не переписывать сервисный слой после первого дедлока в проде.
Что делает DB::transaction на самом деле
Замыкание, переданное в DB::transaction(), выполняется между BEGIN и COMMIT. Если внутри вылетает исключение — вызывается ROLLBACK и исключение пробрасывается наружу. Второй аргумент — число повторов при дедлоке: Laravel ловит коды ошибок дедлока и сериализации и повторяет замыкание целиком. Это первый архитектурный подвох: всё содержимое замыкания может выполниться несколько раз.
Если внутри транзакции вы отправляете HTTP-запрос в платёжный шлюз или пишете в Redis, повтор при дедлоке отправит запрос дважды. Внешние побочные эффекты внутри блока с ретраями — почти всегда ошибка проектирования.
Транзакция защищает согласованность строк в одной базе. Она не защищает письма, HTTP-вызовы, файлы и записи в другую СУБД. Всё, что не откатывается вместе с ROLLBACK, нужно выносить за границу транзакции сознательно.Вложенные транзакции — это не транзакции
Когда DB::transaction() вызывается внутри уже открытой транзакции, Laravel не открывает вторую — драйвер PDO этого не умеет. Вместо этого создаётся точка сохранения (SAVEPOINT). Внутренний «откат» откатывает только до savepoint, а реальный COMMIT происходит на самом внешнем уровне.
Последствие: если сервисный метод createOrder() оборачивает всё в транзакцию и вызывается из другого метода, который тоже открыл транзакцию, ваш «атомарный» блок на деле лишь часть чужой транзакции. Уровень вложенности виден через DB::transactionLevel() — если он больше единицы, вы внутри чужой границы.
Где транзакция ломает предположения о порядке событий
Самая частая и дорогая проблема — события и очереди внутри транзакции. Классический сценарий:
// Laravel 11.x
DB::transaction(function () use ($cart) {
$order = Order::create([
'user_id' => $cart->user_id,
'total' => $cart->total,
]);
foreach ($cart->items as $item) {
$order->items()->create($item->toArray());
}
// Задача уходит в очередь ПРЯМО СЕЙЧАС,
// до COMMIT внешней транзакции
SendOrderConfirmation::dispatch($order);
});Если очередь работает на драйвере redis или sqs, задача попадёт в брокер до коммита. Воркер может подхватить её за миллисекунды и обратиться к базе за Order, которого там ещё нет — транзакция не завершилась. Итог — ModelNotFoundException в воркере на, казалось бы, только что созданной записи.
Решение: afterCommit
Laravel умеет откладывать диспетчеризацию до коммита. Способа два:
- глобально — параметр
after_commitв конфиге соответствующего соединения очереди (config/queue.php), проверьте наличие ключа в актуальной версии; - точечно — метод
afterCommit()на объекте задачи или свойствоpublic bool $afterCommit = true;в классе задачи и в mailable.
// Диспетчеризация произойдёт только после COMMIT
SendOrderConfirmation::dispatch($order)->afterCommit();Тот же механизм работает для событий модели через $afterCommit в слушателе. Но здесь кроется ловушка: afterCommit срабатывает на коммите самой внешней транзакции. Если код исполняется вне транзакции вообще, задача уйдёт немедленно — что как раз и нужно.
Границы транзакции как архитектурное решение
Вопрос «что положить внутрь транзакции» — не про удобство, а про то, где проходит граница согласованности. Три подхода, которые встречаются в реальных проектах:
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Транзакция в контроллере, обёрнута вся обработка запроса | Простота: всё либо есть, либо нет | Длинные блокировки, внешние вызовы попадают внутрь, дедлоки под нагрузкой |
| Транзакция в сервисном методе, максимально узкая | Короткие блокировки, внешние эффекты снаружи | Нужно вручную следить за границами при композиции методов |
| Без явной транзакции, идемпотентные шаги + компенсации | Работает через несколько сервисов и баз (saga) | Сложность: нужны компенсирующие действия и статусы |
Для монолита на одной базе рабочий выбор — узкая транзакция в сервисе, охватывающая только запись связанных строк. Всё, что обращается наружу, уходит через afterCommit или ставится в очередь после выхода из блока.
Блокировки и время удержания
Пока транзакция открыта, строки под SELECT ... FOR UPDATE (в Eloquent — lockForUpdate()) заблокированы. Чем дольше живёт транзакция, тем выше шанс, что другой запрос упрётся в блокировку или словит дедлок. HTTP-вызов на 400 мс внутри транзакции — это 400 мс, на которые строка недоступна конкурентам.
Правило простое: внутри транзакции только работа с базой. Никаких Http::get(), sleep(), чтения больших файлов и обращений к сторонним API.
Что ломается первым под нагрузкой
При росте параллельности проявляются проблемы, невидимые на одном запросе:
- Дедлоки на порядке блокировок. Два запроса блокируют строки A и B в разном порядке. MySQL выбирает жертву и откатывает её транзакцию. Если у вас настроены ретраи — замыкание выполнится заново со всеми побочными эффектами внутри.
- Lock wait timeout. Запрос ждёт освобождения блокировки дольше
innodb_lock_wait_timeoutи падает. Обычно признак того, что внутри транзакции есть что-то медленное. - Разбухание пула соединений. Долгие транзакции держат соединение занятым. При лимите соединений в базе воркеры и веб-процессы начинают конкурировать за пул.
- Двойные внешние эффекты. Ретрай дедлока повторяет весь блок — включая тот
Http::post(), который забыли вынести.
Как это мерить
Не гадайте — смотрите на цифры:
- в MySQL —
SHOW ENGINE INNODB STATUS, разделLATEST DETECTED DEADLOCK, и счётчикInnodb_deadlocksчерезSHOW GLOBAL STATUS; - в приложении — время удержания транзакции: залогируйте
microtimeдоDB::transactionи после, вынесите в метрику; - в очереди — процент задач, падающих с
ModelNotFoundException: прямой индикатор диспетчеризации до коммита.
Идемпотентность вместо надежды на атомарность
Как только процесс выходит за пределы одной базы — появляется платёж, вызов стороннего API, запись в другую СУБД — транзакция перестаёт быть достаточной гарантией. Здесь работает не «сделать всё атомарно», а «сделать каждый шаг повторяемым безопасно».
Практический приём — уникальный ключ идемпотентности на операцию. Перед списанием денег создаёте запись с уникальным idempotency_key в транзакции. Повторный вызов с тем же ключом упрётся в нарушение уникального индекса и не спишет дважды. Это дешевле и надёжнее, чем пытаться растянуть транзакцию на внешний сервис.
Что делать дальше
Пройдитесь по сервисному слою с тремя вопросами к каждому блоку DB::transaction: нет ли внутри внешних вызовов; помечены ли задачи и события как afterCommit; сколько строк и как долго блокируется. Настройте логирование времени транзакций и алерт на рост Innodb_deadlocks. Для операций, выходящих за пределы одной базы, заложите ключ идемпотентности до того, как проект встретит первый повторный платёж. Границы транзакции проще проектировать заранее, чем расшивать после инцидента.
Мотивационное письмо и эссе: Поступление, стипендии, гранты, обмен
Спросить за 25 ₽Источники: Laravel Database: Transactions (официальная документация), Laravel Queues: Jobs & Database Transactions
Частые вопросы
Почему письмо клиенту приходит, а заказа в базе нет?
У меня Laravel 10, а не 11 — механизм afterCommit работает так же?
Что будет, если внутри транзакции сделать HTTP-запрос к платёжке?
Вложенные DB::transaction действительно откатываются независимо?
Я вынес всё за транзакцию, но дедлоки всё равно есть — что дальше?
Нужна ли транзакция, если операция затрагивает только одну таблицу?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.