Когда ваш сайт начинает обрабатывать много запросов одновременно, возникает проблема: два пользователя одновременно меняют одни и те же данные. Итог — потерянные обновления, некорректные остатки на счетах или странные баги. Чтобы этого избежать, нужны транзакции и блокировки. В Yii2 это делается просто, но есть подводные камни. В этой статье разберем, как правильно работать с транзакциями, какие бывают блокировки и как не наступить на грабли.

Digital-студия WNDER

Что такое транзакция и зачем она нужна

Транзакция — это группа операций с базой данных, которые выполняются как единое целое. Либо все выполняются успешно, либо ни одна. Классический пример — перевод денег: списали с одного счета, зачислили на другой. Если после списания произойдет сбой, деньги пропадут. Транзакция гарантирует, что либо обе операции будут выполнены, либо ни одна.

В Yii2 транзакции реализованы через объект Transaction. Он создается из компонента базы данных (обычно это Yii::$app->db).

Пример простой транзакции:

$db = Yii::$app->db;
$transaction = $db->beginTransaction();
try {
    // списание денег
    $db->createCommand()->update('account', ['balance' => new Expression('balance - 100')], ['id' => 1])->execute();
    // зачисление денег
    $db->createCommand()->update('account', ['balance' => new Expression('balance + 100')], ['id' => 2])->execute();
    $transaction->commit();
} catch (\Exception $e) {
    $transaction->rollBack();
    throw $e;
}

Здесь мы начинаем транзакцию, выполняем две операции, и если все хорошо — фиксируем (commit). Если возникает исключение — откатываем (rollBack).

Правило: Всегда оборачивайте операции, которые должны быть атомарными, в try-catch и не забывайте про rollBack. Иначе при ошибке часть данных останется в базе, и это будет трудно отследить.

Уровни изоляции транзакций

Транзакции могут работать с разным уровнем изоляции. Это определяет, насколько одна транзакция видит изменения другой. В Yii2 можно задать уровень изоляции при создании транзакции.

Вот основные уровни:

Уровень Описание Когда использовать
READ UNCOMMITTED Видит незакоммиченные изменения других транзакций. Самый быстрый, но опасный. Редко, когда точность не критична.
READ COMMITTED Видит только закоммиченные изменения. По умолчанию в PostgreSQL. Стандарт для большинства приложений.
REPEATABLE READ Гарантирует, что повторное чтение данных в транзакции даст тот же результат. Когда нужно избежать несогласованности данных.
SERIALIZABLE Полная изоляция, транзакции выполняются как последовательные. Для критичных операций, например, финансовых.

В Yii2 уровень изоляции задается так:

$transaction = $db->beginTransaction(Transaction::REPEATABLE_READ);
// ... операции ...
$transaction->commit();

Чем выше уровень, тем больше блокировок и медленнее работа. Поэтому не стоит ставить SERIALIZABLE везде подряд.

Блокировки: пессимистичные и оптимистичные

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

Пессимистичные блокировки

Суть: мы блокируем строку или таблицу на время транзакции, чтобы другие не могли ее изменить. Обычно это делается через SQL-команды SELECT ... FOR UPDATE или LOCK IN SHARE MODE.

В Yii2 можно использовать query builder:

$transaction = $db->beginTransaction();
try {
    // Блокируем строку с id=1 для обновления
    $account = Account::find()->where(['id' => 1])->forUpdate()->one();
    // Теперь другие транзакции не смогут изменить эту строку, пока мы не завершим транзакцию
    // ... операции ...
    $transaction->commit();
} catch (\Exception $e) {
    $transaction->rollBack();
    throw $e;
}

Метод forUpdate() добавляет блокировку на выбранные строки. Это удобно, когда мы читаем данные, а потом обновляем их, и хотим, чтобы никто не вклинился между чтением и записью.

Оптимистичные блокировки

Здесь мы не блокируем данные заранее, а проверяем, не изменились ли они с момента чтения. Обычно используется поле version, которое увеличивается при каждом обновлении.

В Yii2 это делается через поведение OptimisticLockBehavior или вручную. Например:

$account = Account::findOne(1);
$account->balance = 500;
$account->version++; // увеличиваем версию
if ($account->save()) {
    // успешно
} else {
    // кто-то уже изменил запись, обрабатываем конфликт
}

Но лучше использовать встроенное поведение:

use yii\behaviors\OptimisticLockBehavior;

class Account extends ActiveRecord
{
    public function behaviors()
    {
        return [
            [
                'class' => OptimisticLockBehavior::className(),
                'attribute' => 'version',
            ],
        ];
    }
}

Тогда при сохранении Yii2 автоматически проверит, что версия не изменилась, и вызовет исключение StaleObjectException, если кто-то успел обновить запись.

Лайфхак: Если у вас высоконагруженный проект, используйте оптимистичные блокировки — они не держат соединения с БД и работают быстрее. Пессимистичные лучше для коротких операций, где вероятность конфликта велика.

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

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

Пример:

$outer = $db->beginTransaction();
try {
    // ... операции ...
    $inner = $db->beginTransaction();
    try {
        // ... операции ...
        $inner->commit();
    } catch (\Exception $e) {
        $inner->rollBack();
        throw $e;
    }
    $outer->commit();
} catch (\Exception $e) {
    $outer->rollBack();
    throw $e;
}

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

Как избежать дедлоков

Дедлок (взаимная блокировка) — это когда две транзакции ждут друг друга и не могут завершиться. База данных обычно сама разруливает такие ситуации, убивая одну из транзакций, но это приводит к ошибкам.

Вот несколько советов, как их избежать:

  • Всегда блокируйте строки в одном и том же порядке. Например, сначала строка с id=1, потом с id=2. Если две транзакции будут блокировать в разном порядке, они могут ждать друг друга.
  • Старайтесь делать транзакции короткими. Чем дольше транзакция, тем больше шансов на конфликт.
  • Используйте таймауты. В Yii2 можно задать таймаут для транзакции: $db->beginTransaction(null, 5) — ждать не более 5 секунд.
  • Не делайте внутри транзакции внешние HTTP-запросы или длительные операции.

Пример с таймаутом:

$transaction = $db->beginTransaction(null, 5);
try {
    // ... операции ...
    $transaction->commit();
} catch (\Exception $e) {
    $transaction->rollBack();
    throw $e;
}
Правило: Если в вашем приложении возникают дедлоки, сначала проверьте порядок блокировок. Чаще всего проблема именно в этом.

Практический пример: бронирование билетов

Давайте рассмотрим реальный сценарий: бронирование мест в кинотеатре. Два пользователя одновременно хотят забронировать одно и то же место. Как сделать правильно?

$transaction = Yii::$app->db->beginTransaction();
try {
    // Проверяем, свободно ли место, и блокируем его
    $seat = Seat::find()->where(['hall_id' => 1, 'row' => 5, 'seat_number' => 10])->forUpdate()->one();
    if ($seat->status !== Seat::STATUS_FREE) {
        throw new \Exception('Место уже занято');
    }
    // Бронируем
    $seat->status = Seat::STATUS_BOOKED;
    $seat->user_id = Yii::$app->user->id;
    $seat->save();
    $transaction->commit();
    echo 'Успешно';
} catch (\Exception $e) {
    $transaction->rollBack();
    echo 'Ошибка: ' . $e->getMessage();
}

Здесь мы используем пессимистичную блокировку, чтобы гарантировать, что никто другой не забронирует это место, пока мы проверяем и обновляем. Без блокировки два пользователя могли бы увидеть, что место свободно, и оба забронировать.

Итоги: что запомнить

Транзакции и блокировки — это мощный инструмент, но требуют осторожности. Вот основные выводы:

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

Теперь вы знаете, как правильно работать с транзакциями в Yii2. Применяйте эти знания, и ваши данные будут в безопасности.

Студия WNDER