Почему нельзя забывать явно завершать вложенные транзакции в Yii2

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

Простой пример. Есть метод, который пересчитывает и обновляет в базе данных сводные данные за указанный период. Так как логика пересчета не тривиальна, тело метода заключено в транзакцию. Этот метод запускается планировщиком. Но есть еще метод для импорта данных из внешнего источника, который тоже выполняется в транзакции. И после окончания импорта необходимо запускать пересчет сводных данных для импортированного периода. Таким образом внутри транзакции вызывается метод, который сам выполняется внутри еще одной транзакции.

Такая ситуация вполне обыденна и Yii2 предоставляет удобные методы для работы с транзакциями: Connection::beginTransaction(), Transaction::commit() и Transaction::rollBack(). Используя их можно работать с вложенными транзакциями.

В этой статье я хочу рассказать об одном неочевидном моменте в этом механизме из-за которого я потратил впустую много времени и нервов.

Базовая работа с транзакциями

Для примера создадим простую таблицу, в которую мы будем добавлять данные:

CREATE TABLE test_tbl (
  msg VARCHAR(10) PRIMARY KEY
)

Вставим в таблицу две строки:

Примечание В статье приводится упрощенный код. Без обработки исключений, ошибок и т.п. Минимум для передачи смысла.

$db = \Yii::$app->getDb();
$transaction = $db->beginTransaction();
$db->createCommand()->insert('test_tbl', ['msg' => 'message 1'])->execute();
$db->createCommand()->insert('test_tbl', ['msg' => 'message 2'])->execute();
$transaction->commit();

В итоге будет сгенерирован и выполнен следующий SQL запрос:

BEGIN;
    INSERT INTO test_tbl (msg) VALUES ('message 1');
COMMIT;

Важно понимать, что транзакция в любом случае будет завершена. Мы можем сделать это при помощи методов Transaction::commit() и Transaction::rollBack(). Но если мы не сделаем этого явно, она автоматически применится или отмениться в зависимости от настроек СУБД. Обычно незавершенные транзакции отменяются. И код

$db = \Yii::$app->getDb();
$transaction = $db->beginTransaction();
$db->createCommand()->insert('test_tbl', ['msg' => 'message 1'])->execute();
$db->createCommand()->insert('test_tbl', ['msg' => 'message 2'])->execute();

сгенерирует и выполнит следующий SQL запрос:

BEGIN;
    INSERT INTO test_tbl (msg) VALUES ('message 1');
    INSERT INTO test_tbl (msg) VALUES ('message 2');
ROLLBACK;

Работа с вложенными транзакциями

Работа с вложенными транзакциями в Yii2 так же проста.

$db = \Yii::$app->getDb();
$outerTransaction = $db->beginTransaction();
    $db->createCommand()->insert('test_tbl', ['msg' => 'message 1'])->execute();

    $innerTransaction = $db->beginTransaction();
        $db->createCommand()->insert('test_tbl', ['msg' => 'message 2'])->execute();
    $innerTransaction->rollBack();

    $db->createCommand()->insert('test_tbl', ['msg' => 'message 3'])->execute();
$outerTransaction->commit();

В результате мы ожидаем получить следующий SQL запрос:

BEGIN;
  INSERT INTO test_tbl (msg) VALUES ('message 1');

  BEGIN;
    INSERT INTO test_tbl (msg) VALUES ('message 2');
  ROLLBACK;

  INSERT INTO test_tbl (msg) VALUES ('message 3');
COMMIT;

Но подобный запрос некорректен. Дело в том, что не все СУБД поддерживают вложенные транзакции. В Yii2 чистые вложенные транзакции используются только для mssql. Для остальных СУБД используется savepoint. Это механизм, который имитирует работу вложенных транзакций. Поэтому вышеприведенный PHP код сгенерирует следующий SQL запрос:

BEGIN;
  INSERT INTO test_tbl (msg) VALUES ('message 1');

  SAVEPOINT pnt1;
    INSERT INTO test_tbl (msg) VALUES ('message 2');
  ROLLBACK TO SAVEPOINT pnt1;

  INSERT INTO test_tbl (msg) VALUES ('message 3');
COMMIT;

Результат его работы будет точно таким же, как если бы PostgreSQL поддерживал вложенные транзакции.

Важно завершать транзакции явно

А теперь о том, почему в Yii2 важно явно завершать все транзакции. Для соблюдения полиморфизма, работа с savepoint в Yii2 скрыта за интерфейсом работы с транзакциями. Благодаря этому разработчику не приходится следить за этим. Он просто работает с вложенными транзакциями, как если бы СУБД их поддерживала, а фремворк сам использует savepoint там, где нужно.

Фактически метод Connection::beginTransaction() всегда возвращает один и тот же объект транзакции. При повторном вызове Yii2 создаст новый savepoint и вернет эту же транзакцию. При отмене или завершении транзакции все происходит наоборот: сначала завершаются все savepoint, а в конце завершается сама транзакция. Таким образом, если суммарное количество завершений транзакций будет меньше количества их созданий, то транзакция не будет завершена.

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

$db = \Yii::$app->getDb();
$outerTransaction = $db->beginTransaction();
    $db->createCommand()->insert('test_tbl', ['msg' => 'message 1'])->execute();

    $innerTransaction = $db->beginTransaction();
        $db->createCommand()->insert('test_tbl', ['msg' => 'message 2'])->execute();
    // $innerTransaction->rollBack();

    $db->createCommand()->insert('test_tbl', ['msg' => 'message 3'])->execute();
$outerTransaction->commit();

сгенерирует не такой запрос:

BEGIN;
  INSERT INTO test_tbl (msg) VALUES ('message 1');

  SAVEPOINT pnt1;
    INSERT INTO test_tbl (msg) VALUES ('message 2');
  ROLLBACK TO SAVEPOINT pnt1;

  INSERT INTO test_tbl (msg) VALUES ('message 3');
COMMIT;

а такой:

BEGIN;
  INSERT INTO test_tbl (msg) VALUES ('message 1');

  SAVEPOINT pnt1;
    INSERT INTO test_tbl (msg) VALUES ('message 2');
  RELEASE SAVEPOINT pnt1;

  INSERT INTO test_tbl (msg) VALUES ('message 3');
ROLLBACK;

И вместо сообщений message 1 и message 3, в таблицу не будет вставлено ничего.

Заключение

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

Вы можете прочитать эту статью на английском здесь.

Комментарии

Используйте Markdown
Спасибо за коментарий!
Ваше сообщение будет доступно после проверки.