В этой статье мы не будем касаться темы что такое транзакции и для чего они нужны. Вы можете ознакомиться с этой темой здесь. В статье мне хотелось бы сосредоточиться на том, как это реализовано под капотом, как оно работает в фоне, и какие головные боли это может вызвать при использовании. Так что давайте начнем.
Транзакции с замыканиями
Поскольку вы, вероятно, уже знакомы с транзакциями, вы можете использовать замыкания в методе транзакции фасада БД, например:
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);
}
// If we catch an exception we'll rollback this transaction and try again if we
// are not out of attempts. If we are out of attempts we will just throw the
// exception back out and let the developer handle an uncaught exceptions.
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;
}
возвращает результат колбека и запускает событие commited:
$this->fireConnectionEvent('committed');
return $callbackResult;
Использование замыканий-это самый простой и легкий способ использования транзакций в Laravel.
Ручная обработка транзакций
Laravel позволяет вам самостоятельно контролировать транзакцию.
- Запускаем транзакцию с помощью DB::beginTransaction();
- Если все прошло хорошо , то фиксируем с помощью DB:: commit();
- Или откат в противном случае с DB::rollBack();
Это работает просто отлично, но что делать, если вы хотите использовать вложенные транзакции? Например , 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');
}
Очевидно, что это нормально, но вам нужно убедиться, что для каждой запущенной транзакции вы вызываете либо commit, либо rollback. Особенно, когда это делается в цикле.
Пример, когда все идет не так
Итак, вот пример того, что вызвало головную боль в нашем случае (это псевдокод, но суть та же):
$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() get “выйдет из синхронизации”, следующие итерации не будут обновлять записи.
Это не смешно, когда это команда, которая работает в течение пары часов, а в конце не обновляет ничего, что должна.
Итак, урок, который мы здесь извлекли, прост. Всегда имейте в виду, что каждая открытая транзакция должна быть заключена с фиксацией или откатом. Особенно в замыканиях.