Laravel: Как работают транзакции базы данных

Источник: «Under the hood: How database transactions work in Laravel»
Продолжаем серию статей о происходящем под капотом, на этот раз о транзакциях базы данных в Laravel. Я не буду повторять всё о том, как вы можете использовать транзакции в Laravel. Если вы не знакомы с темой можете всё найти в официальной документации. Сейчас мы сосредоточимся на том, как эти реализации работают в фоновом режиме, что вызывало головную боль и как этого избежать. Итак, давайте разбираться.

Транзакции с замыканием

Вы можете использовать замыкания в методе транзакций фасада БД, например:

DB::transaction(function () {
DB::update('update users set votes = 1');
DB::delete('delete from posts');
});

Используя этот метод, не нужно беспокоиться о запуске транзакций, фиксации и откате изменений, если что-то пойдёт не так, он сделает всё это автоматически.

Методы, связанные с транзакциями расположены в трейте Illuminate/Database/Concerns/ManagesTransactions.php. Так же можно передать второй аргумент в метод DB::transaction указывающий количество попыток повтора транзакции при возникновении исключения или блокировки. Код повторяет заданное количество попыток запуская новую транзакцию.

public function transaction(Closure $callback, $attempts = 1)
{
for ($currentAttempt = 1; $currentAttempt <= $attempts; $currentAttempt++) {
$this->beginTransaction();

Он попытается выполнить функцию обратного вызова, и если в нём возникнет исключение — откатит транзакцию. Он будет пытаться повторить снова, пока не достигнет заданного количества попыток:

try {
$callbackResult = $callback($this);
}

// Если мы поймаем исключение, мы откатим эту транзакцию и повторим попытку, если
// количество попыток не исчерпано. Если у нас не осталось попыток, мы просто выбрасываем
// исключение и позволяем разработчику обрабатывать не перехваченные исключения.
catch (Throwable $e) {
$this->handleTransactionException(
$e, $currentAttempt, $attempts
);

continue;
}

Если всё прошло нормально, транзакция фиксируется,

try {
if ($this->transactions == 1) {
$this->getPdo()->commit();

optional($this->transactionsManager)->commit($this->getName());
}

$this->transactions = max(0, $this->transactions - 1);
} catch (Throwable $e) {
$this->handleCommitTransactionException(
$e, $currentAttempt, $attempts
);

continue;
}

возвращает результат обратного вызова и запускает событие committed:

$this->fireConnectionEvent('committed');

return $callbackResult;

Использование замыканий — самый простой и лёгкий способ использования транзакций в Laravel.

Ручная обработка транзакций

Laravel позволяет самостоятельно контролировать транзакцию. И это то, что доставляло проблемы в последнее время. Мы скоро подойдём к этому моменту, но сначала давайте рассмотрим, как это работает:

Это работает отлично, но что если вы захотите использовать вложенные транзакции. MySQL, например, не поддерживает вложенные транзакции, но механизм InnoDB поддерживает точки сохранения.

Laravel использует эту функцию (если поддерживается). Поэтому, когда мы вызываем DB::beginTransaction(), он подсчитывает уровень вложенности транзакций. В соответствии с этим уровнем вложенности он либо создаст новую транзакцию (в первый раз), либо создаст точку сохранений.

if ($this->transactions == 0) {
$this->reconnectIfMissingConnection();

try {
$this->getPdo()->beginTransaction();
} catch (Throwable $e) {
$this->handleBeginTransactionException($e);
}
} elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) {
$this->createSavepoint();
}

В ситуации, когда уровень вложенности больше одного, вызов DB::rollBack() уменьшит значение счётчика и откатиться к предыдущей точке сохранения.

protected function performRollBack($toLevel)
{
if ($toLevel == 0) {
$this->getPdo()->rollBack();
} elseif ($this->queryGrammar->supportsSavepoints()) {
$this->getPdo()->exec(
$this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1))
);
}
}

И здесь наступает каверзная часть: DB::commit() будет действительно фиксировать изменения в базе данных только тогда, когда счётчик равен 1. В противном случае он только уменьшает счётчик и запускает событие committed.

public function commit()
{
if ($this->transactions == 1) {
$this->getPdo()->commit();

optional($this->transactionsManager)->commit($this->getName());
}

$this->transactions = max(0, $this->transactions - 1);

$this->fireConnectionEvent('committed');
}

Очевидно, это нормально, но вам нужно убедиться, что для каждой запущенной транзакции вы вызываете либо фиксацию, либо откат. Особенно когда это делается в цикле.

Пример, когда что-то идёт не так

Итак, вот пример, вызывающий головную боль в нашем случае (это псевдокод, но суть та же):

$products = Products::all()
foreach($products as $product){
try {
DB::beginTransaction();

if ($product->isExpensiveEnough()) {
continue;
}

$product->price += 100;
$product->save();
DB::commit();
}
catch(Exception $e){
DB::rollback();
}
}

Итак, мы хотим перебрать продукты и изменить некоторые атрибуты в соответствии с некоторыми условиями. В этом псевдокоде мы хотим поднять цены на недостаточно дорогие продукты :).

Так в чём проблема с приведённым выше примером? В случае, когда мы обновляем продукт и всё идёт хорошо, мы начинаем транзакцию, обновляем модель, совершаем фиксацию изменения. Это даже хорошо работает, когда возникает исключение, и мы делаем откат.

Однако, когда условие не выполняется и мы продолжаем, на следующей итерации создаётся новая транзакция. Счётчик транзакций будет увеличен до 2. Следующая фиксация не будет выполнять фактическую фиксацию, а просто уменьшит значение счётчика. Следующая итерация начнёт новую транзакцию, увеличив счётчик до 2 и т.д. Как только количество методов beginTransaction() и commit()/rollback() выходит из синхронизации, последующие итерации не будут обновлять записи.

Это не смешно, когда команда, выполняющаяся пару часов, а в конце не обновляет ничего из того, что должна.

Итак, урок, который мы извлекли прост. Всегда помните, что каждая открытая транзакция должна завершаться фиксацией или откатом. Особенно в циклах.

Дополнительные материалы

Предыдущая Статья

PHP: Разница между self::, static:: и parent::

Следующая Статья

Laravel Pint: Настройка базовой конфигурации