Рефакторинг легаси на Yii2: как один beforeSave уронил прод
Плановый рефакторинг активной записи в проекте на Yii2 обернулся дублями заказов и мёртвыми блокировками в MySQL. Разбираем, что именно сломалось в жизненном цикле ActiveRecord и как это чинить.

Что произошло
Проект на Yii2 2.0.x, интернет-магазин, около 40 заказов в минуту в пик. После деплоя рефакторинга модуля заказов служба поддержки за час получила 60 жалоб на двойное списание. В базе — дубли строк в таблице order с разницей в создании 100–300 мс. Параллельно в логах MySQL росли Lock wait timeout exceeded. Откатились через 50 минут, но осадок и возвраты остались.
Причина оказалась не в бизнес-логике, а в том, как рефакторинг изменил порядок вызовов внутри жизненного цикла ActiveRecord. Ниже — механизм, почему так вышло, и что ревьюить, чтобы не повторить.
Что было до рефакторинга
В легаси-коде вся логика заказа висела в одном методе контроллера: считался итог, писалась строка заказа, дёргался внешний платёжный шлюз, отправлялось письмо. Уродливо, но работало последовательно и в одной транзакции, открытой руками через Yii::$app->db->beginTransaction().
Рефакторинг преследовал разумную цель — вынести побочные эффекты из контроллера в модель, чтобы их нельзя было забыть при создании заказа из консольной команды и из API. Логику переложили в события ActiveRecord: afterSave для письма, afterInsert-подобную ветку внутри afterSave для вызова шлюза.
Где спрятался баг
Ключевая ошибка — вызов внешнего HTTP-запроса к платёжному шлюзу внутри afterSave, который в новом коде выполнялся внутри той же транзакции, что и вставка строки. Шлюз отвечал за 2–4 секунды. Всё это время строка заказа держала блокировку. При всплеске нагрузки запросы вставали в очередь на блокировку, часть отваливалась по Lock wait timeout, а фронтенд по таймауту повторял POST — и создавал второй заказ.
Событие модели — не то место, где стоит ходить в сеть. Всё, что делает медленный ввод-вывод внутри afterSave, растягивает открытую транзакцию на время ответа чужого сервера.Как устроен жизненный цикл ActiveRecord
Чтобы понять, почему порядок вызовов оказался критичным, разберём последовательность при $model->save() для нового объекта в Yii2 2.0.x:
beforeValidate→ валидация →afterValidate;beforeSave(с флагомinsert = true) — здесь ещё можно поменять атрибуты и отменить сохранение, вернув событиюisValid = false;- сам
INSERTв базу, присвоение первичного ключа; afterSave— строка уже в базе, ключ известен.
Событие afterSave вызывается синхронно и в том же контексте выполнения, что и save(). Если save() вызван внутри открытой транзакции — весь код обработчиков afterSave тоже выполняется внутри неё. В легаси это было неочевидно, потому что раньше побочные эффекты жили в контроллере после явного commit(). После переноса в событие они уехали до коммита.
Почему повторный POST создавал дубль
На таблице order не было уникального ограничения по идемпотентному ключу — только автоинкрементный id. Пока первый запрос висел на блокировке и медленном шлюзе, браузер по своему таймауту слал POST заново. Второй запрос проходил валидацию (никакой уникальности проверять было нечего) и создавал вторую строку. Идемпотентность держалась только на надежде, что пользователь не нажмёт кнопку дважды.
Как чинили
Сначала — остановить кровотечение, потом — лечить причину.
- Немедленно: откат деплоя, возврат последовательной логики в контроллере с ручной транзакцией только вокруг вставки, а вызов шлюза — после коммита.
- В течение дня: добавили в таблицу колонку
idempotency_keyи уникальный индекс на неё. Ключ фронтенд генерирует один раз на форму оформления и шлёт с каждым повтором. - На неделе: вынесли обращение к шлюзу в фоновую очередь через
yii2-queue, чтобы HTTP-запрос вообще не касался транзакции заказа.
Правильный порядок: транзакция и побочные эффекты
Yii2 умеет сам оборачивать save() в транзакцию через метод transactions() модели. Но важно понимать, что afterSave при этом всё равно внутри транзакции. Поэтому сеть и постановку в очередь вешают не на afterSave, а на событие фиксации.
<?php
// Yii2 2.0.x. Ставим задачу в очередь только после реального commit,
// а не внутри afterSave.
use yii\db\ActiveRecord;
use yii\db\Transaction;
class Order extends ActiveRecord
{
// Оборачиваем вставку/обновление в транзакцию средствами Yii
public function transactions()
{
return [
self::SCENARIO_DEFAULT => self::OP_INSERT | self::OP_UPDATE,
];
}
public function afterSave($insert, $changedAttributes)
{
parent::afterSave($insert, $changedAttributes);
if (!$insert) {
return;
}
$orderId = $this->id;
// Не ходим в сеть здесь. Регистрируем колбэк на commit
// текущей транзакции соединения.
$transaction = $this->getDb()->getTransaction();
if ($transaction !== null && $transaction->getIsActive()) {
// afterCommit нет в базовом Transaction Yii2 из коробки,
// поэтому используем pdo или ставим задачу после save() в сервисе.
// Надёжный путь — не в модели, а в сервисном слое (ниже).
}
}
}
Важная оговорка: в базовом классе yii\db\Transaction в Yii2 2.0.x нет встроенного хука вида afterCommit. Не изобретайте его имя — точный набор методов сверяйте в документации класса yii\db\Transaction вашей версии. Практичнее не завязывать побочные эффекты на события модели вообще, а вынести их в сервисный слой, где транзакция и постановка в очередь видны явно:
<?php
// Сервисный слой. Транзакция закрыта до похода в очередь и сеть.
use Yii;
class OrderService
{
public function create(array $data, string $idempotencyKey): Order
{
$db = Yii::$app->db;
$transaction = $db->beginTransaction();
try {
$order = new Order();
$order->idempotency_key = $idempotencyKey;
$order->setAttributes($data, false);
// save() внутри транзакции. При гонке второй запрос
// упадёт на уникальном индексе idempotency_key.
if (!$order->save()) {
throw new \RuntimeException('order save failed');
}
$transaction->commit();
} catch (\yii\db\IntegrityException $e) {
$transaction->rollBack();
// Дубль по idempotency_key — возвращаем уже созданный заказ.
return Order::findOne(['idempotency_key' => $idempotencyKey]);
} catch (\Throwable $e) {
$transaction->rollBack();
throw $e;
}
// Транзакция закоммичена. Только теперь ставим задачу в очередь.
Yii::$app->queue->push(new ChargePaymentJob(['orderId' => $order->id]));
return $order;
}
}
Здесь Yii::$app->queue — компонент из расширения yiisoft/yii2-queue, его нужно настроить в конфиге приложения. Класс задачи ChargePaymentJob реализует интерфейс yii\queue\JobInterface. Синтаксис постановки задачи и интерфейс сверяйте с README расширения — API у разных мажорных версий очереди отличался.
Где такой подход всё ещё ломается
Вынос побочных эффектов в очередь и уникальный ключ закрывают исходный инцидент, но появляются новые граничные случаи, за которыми надо следить.
| Ситуация | Что происходит | Что делать |
|---|---|---|
| Задача в очереди упала после коммита заказа | Заказ есть, оплата не инициирована | Ретраи в очереди + флаг статуса оплаты, отдельный монитор зависших заказов |
| Гонка на уникальном индексе | Второй запрос ловит IntegrityException | Ловить именно yii\db\IntegrityException, а не общий Exception, и возвращать существующий заказ |
Несколько save() в одной транзакции | Все afterSave внутри одной транзакции, любой сетевой вызов растягивает её | Никаких HTTP и очередей в событиях модели — только после commit |
| MySQL уровень изоляции REPEATABLE READ | Проверка «есть ли уже заказ» через SELECT перед вставкой ненадёжна | Полагаться на уникальный индекс, а не на SELECT-then-INSERT |
Что ревьюить в чужом рефакторинге легаси на Yii2
Формулировки замечаний, которые ловят именно этот класс ошибок:
- «Здесь
afterSaveделает HTTP-запрос — он внутри транзакции, вынеси в сервис после commit». - «Транзакция открывается в контроллере, а закрывается в модели — граница транзакции размазана, собери в одном месте».
- «Идемпотентность держится на том, что пользователь не нажмёт дважды — нужен уникальный индекс на бизнес-ключ».
- «Ловится
\Exception, а обрабатывается как дубль — так проглотится и настоящая ошибка БД, ловиIntegrityException».
Что делать дальше
Если у вас похожий легаси на Yii2, начните с трёх измеримых вещей. Первое — включите лог долгих транзакций в MySQL (innodb_lock_wait_timeout и long_query_time) и посмотрите, нет ли транзакций дольше секунды: почти всегда внутри окажется сетевой вызов. Второе — проверьте бизнес-таблицы на наличие уникальных ограничений по смысловым ключам, а не только по автоинкременту. Третье — при переносе логики из контроллеров в модели заведите правило: события ActiveRecord меняют только данные, всё остальное живёт в сервисном слое после коммита. Точную сигнатуру событий и методов транзакций сверяйте с официальным гайдом Yii2 для вашей минорной версии.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Yii2 Guide: Active Record, yiisoft/yii2-queue on GitHub
Частые вопросы
У меня Yii2 версии 2.0.40, а не самая свежая — код подойдёт?
Почему нельзя просто добавить блокировку и не городить очередь?
А если задача в очереди упадёт — оплата же не пройдёт?
Можно оставить логику в afterSave, но обернуть сетевой вызов в try/catch?
Не сработало: после переноса в очередь заказы создаются, но оплата не стартует. Где смотреть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.