Три INSERT подряд в обработчике оплаты - и в админке уже висит заказ "Оплачен", хотя остаток не списался и лог пустой. MySQL в режиме autocommit фиксирует каждый запрос отдельно, а D7 ORM при ошибке add() не бросает исключение - только Result с isSuccess() = false. Ниже разберём, как обернуть цепочку OrderTable::add, StockTable::update и OrderLogTable::add в startTransaction, проверить Result до commit и откатить всё одним rollbackTransaction.
Транзакция БД - это "всё или ничего" для нескольких SQL-операций: либо все изменения попадают в MySQL, либо ни одно. В 1С-Битрикс: Управление сайтом граница задаётся через Application::getConnection(): startTransaction, commitTransaction, rollbackTransaction. ORM-методы *Table::add/update внутри транзакции используют то же соединение. Если Result::isSuccess() вернул false, нужно бросить исключение до commit - иначе получите "полузаказ". С main 22.200.0 вложенный startTransaction создаёт SAVEPOINT, а не вторую транзакцию MySQL.
Материал про коробочную CMS "1С-Битрикс: Управление сайтом", не про Bitrix24. Речь о DB-транзакциях Connection API, а не о CSaleUserTransact. Если *Table ещё нет - сначала создайте DataManager и getMap.
Выберите, когда нужна транзакция Connection, а когда хватит одного CRUD или onBeforeAdd
Одиночный Table::add() с autocommit уже атомарен для одной строки. Проблема - когда операция трогает заказ, склад и лог: без транзакции при падении на втором шаге остаётся "призрачный" заказ.
| Сценарий | Одиночный CRUD (B71) | onBeforeAdd / validators (B80) | Connection::startTransaction |
|---|---|---|---|
| Записать одну строку лога | Да | Избыточно | Нет |
| Отклонить невалидные поля до INSERT | Нет - дубли в коде | Да - addError в onBeforeAdd | Нет - валидация не заменяет атомарность |
| Заказ + списание остатка + лог | Нет - три отдельных commit | Нет - события не объединяют разные таблицы | Да - одна граница try/catch |
| Несколько HL-записей в одном сценарии (B78) | Нет | Частично - только внутри одной сущности | Да - compileEntity DataClass в той же транзакции |
| ORM + сырой SQL queryExecute | Нет | Нет | Да - одно соединение Application::getConnection() |
События - в гайде по onBeforeAdd, HL CRUD - в материале B78, схему таблиц - через Sprint Migration (B65).
Делайте: оборачивайте в транзакцию цепочку из двух и более записей, которые бизнес считает одной операцией. Не делайте: не ставьте startTransaction вокруг каждого Table::update - это лишняя блокировка без пользы.
Настройте шаблон Connection API: startTransaction, commit и rollback с проверкой Result
Точка входа - Application::getConnection(), объект Bitrix\Main\DB\Connection. startTransaction() с main 14.0.0, см. официальный API.
Workflow атомарной операции:
startTransaction() → Table::add/update (проверка Result) → commitTransaction() при успехе → rollbackTransaction() в catch
- Получите соединение:
$connection = Application::getConnection(); - Вызовите
$connection->startTransaction();до первого изменения данных. - Выполните цепочку ORM-операций; после каждой проверяйте
$result->isSuccess(). - Бросьте
SystemExceptionс текстомimplode(', ', $result->getErrorMessages()), если isSuccess() = false. - Зафиксируйте изменения:
$connection->commitTransaction();только если все шаги прошли без исключения. - Откатите в catch:
$connection->rollbackTransaction();и пробросьте исключение выше или верните ошибку клиенту.
use Bitrix\Main\Application;
use Bitrix\Main\SystemException;
$connection = Application::getConnection();
$connection->startTransaction();
try {
$orderResult = OrderTable::add(['STATUS' => 'PAID', 'SUM' => 1500]);
if (!$orderResult->isSuccess()) {
throw new SystemException(implode(', ', $orderResult->getErrorMessages()));
}
$orderId = $orderResult->getId();
$stockResult = StockTable::update($productId, ['QTY' => $newQty]);
if (!$stockResult->isSuccess()) {
throw new SystemException(implode(', ', $stockResult->getErrorMessages()));
}
OrderLogTable::add(['ORDER_ID' => $orderId, 'ACTION' => 'PAID']);
$connection->commitTransaction();
} catch (\Throwable $e) {
$connection->rollbackTransaction();
throw $e;
}
Делайте: держите границу в сервисе, не в *Table. Не делайте: commit без start - TransactionException "Transaction was not started".
Смешайте ORM и сырой SQL в одной транзакции без разрыва атомарности
Остаток можно списать через SQL, лог - через ORM. Правило: одно соединение Application::getConnection(). queryExecute() на $connection участвует в транзакции наравне с OrderTable::add.
$connection = Application::getConnection();
$connection->startTransaction();
try {
$orderResult = OrderTable::add(['STATUS' => 'PAID']);
if (!$orderResult->isSuccess()) {
throw new SystemException(implode(', ', $orderResult->getErrorMessages()));
}
$connection->queryExecute(
"UPDATE b_vendor_stock SET QTY = QTY - 1 WHERE PRODUCT_ID = " . (int)$productId
);
OrderLogTable::add(['ORDER_ID' => $orderResult->getId(), 'ACTION' => 'STOCK_DEC']);
$connection->commitTransaction();
} catch (\Throwable $e) {
$connection->rollbackTransaction();
throw $e;
}
При другом getConnectionName() ORM и SQL окажутся в разных транзакциях. Делайте: один $connection. Не делайте: сырой ввод в SQL без приведения типов.
Поймайте типичные ошибки: частичный успех, пропущенный rollback и "заказ без остатка"
Типичная ошибка на практике: StockTable::update вернул ошибку "недостаточно остатка", код залогировал getErrorMessages(), но дошёл до commit. Заказ "Оплачен", остаток не списан. ORM не бросает исключение - проверяйте isSuccess() сами.
- Нет isSuccess() - commit фиксирует уже записанные шаги.
- Нет rollback в catch - первые INSERT остаются в БД.
- Почта и файлы внутри транзакции - откат их не отменит, только БД.
onBeforeAdd (B80) валидирует одну сущность, транзакция - согласованность нескольких таблиц. Используйте оба слоя.
Делайте: переводите любой !isSuccess() в исключение до commit. Не делайте: не полагайтесь на autocommit MySQL как на защиту бизнес-логики из нескольких шагов.
Разберите SAVEPOINT с main 22.200.0: линейная логика vs вложенный rollback
С main 22.200.0 повторный startTransaction() создаёт SAVEPOINT TRANS{N}, не второй START TRANSACTION. Промежуточные commit не пишут данные до внешнего commit - см. урок 14380. Вложенный rollbackTransaction() бросает TransactionException; внешний commit после него запрещён. В 99% случаев пишите линейный код.
Делайте: проектируйте одну плоскую транзакцию на бизнес-операцию. Не делайте: не вызывайте commitTransaction на внешнем уровне после TransactionException от вложенного rollback - поймайте исключение и откатите всю операцию целиком.
Проверьте master/slave и короткие транзакции перед выкладкой на staging
С модулем cluster SELECT может уйти на slave. На время транзакции включайте ConnectionPool::useMasterOnly(true) и сбрасывайте в finally.
- Оберните одну бизнес-операцию в одну транзакцию - не держите её открытой между HTTP-запросами.
- Уберите из тела транзакции отправку почты, вызовы внешних API и запись файлов - выполняйте после commit.
- Прогоните на staging сценарий с принудительной ошибкой на втором шаге: в БД не должно остаться ни заказа, ни списания.
- Убедитесь, что HL DataClass (B78) и модульные *Table (B71) пишут в то же соединение, что и ваш queryExecute.
- Зафиксируйте схему таблиц миграцией (B65), чтобы staging и prod совпадали перед тестом транзакций.
Нужна помощь с атомарными сценариями в боевом модуле - обсудим задачу. Похожие кейсы по Bitrix - в портфолио.
Автор: Максим Мольков, разработчик 1С-Битрикс.
Источники: API startTransaction, API rollbackTransaction, урок 14380 (SAVEPOINT), класс Connection.
Частые вопросы
Чем транзакция D7 отличается от autocommit MySQL?
Autocommit фиксирует каждый отдельный запрос сразу. startTransaction откладывает фиксацию: все INSERT/UPDATE внутри блока попадут в БД только после commitTransaction. При rollback или необработанной ошибке с откатом изменения не сохраняются.
Нужно ли оборачивать каждый Table::add в транзакцию?
Нет. Одиночный add/update/delete уже атомарен для одной строки. Транзакция нужна, когда бизнес-операция затрагивает две и более таблицы или смешивает ORM и SQL - иначе при сбое на середине останется частичный результат.
Можно ли смешивать ORM и raw SQL в одной транзакции?
Да, если оба вызова идут через Application::getConnection() (или одно и то же имя соединения через getConnectionName). queryExecute и *Table::add в одном try/catch с общим commit/rollback сохраняют атомарность.
Что делать при TransactionException на вложенном rollback?
С main 22.200.0 вложенный rollbackTransaction откатывает к SAVEPOINT и бросает TransactionException. Внешний commit после этого запрещён. Ловите исключение на верхнем уровне, вызывайте rollbackTransaction для всей операции и не пытайтесь "докоммитить" внешний уровень.
Как транзакции сочетаются с onBeforeAdd (B80)?
onBeforeAdd валидирует и нормализует поля одной сущности до INSERT. Транзакция Connection объединяет несколько операций в разных таблицах. Типичный стек: onBeforeAdd ловит неверные поля, а сервис оборачивает цепочку add/update в startTransaction для межтабличной целостности.
Работают ли транзакции с Highload-блоками через D7 ORM?
Да. Скомпилированный HL DataClass - тот же DataManager; add/update внутри startTransaction участвуют в общем откате. Подробнее про compileEntity и CRUD - в гайде B78 по Highload ORM.
Было полезно?
Поставьте лайк, если статья помогла разобраться.
Поделиться статьёй
Отправьте тому, кому это пригодится.
Можно поделиться и фрагментом: выделите текст статьи.