Когда ваш сайт начинает обрабатывать много запросов одновременно, возникает проблема: два пользователя одновременно меняют одни и те же данные. Итог — потерянные обновления, некорректные остатки на счетах или странные баги. Чтобы этого избежать, нужны транзакции и блокировки. В Yii2 это делается просто, но есть подводные камни. В этой статье разберем, как правильно работать с транзакциями, какие бывают блокировки и как не наступить на грабли.
Что такое транзакция и зачем она нужна
Транзакция — это группа операций с базой данных, которые выполняются как единое целое. Либо все выполняются успешно, либо ни одна. Классический пример — перевод денег: списали с одного счета, зачислили на другой. Если после списания произойдет сбой, деньги пропадут. Транзакция гарантирует, что либо обе операции будут выполнены, либо ни одна.
В 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).
Уровни изоляции транзакций
Транзакции могут работать с разным уровнем изоляции. Это определяет, насколько одна транзакция видит изменения другой. В 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. Применяйте эти знания, и ваши данные будут в безопасности.


