Как обернуть цепочку Table::add в startTransaction на 1С-Битрикс?

Три 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

Таблица: когда нужен Connection::startTransaction, а когда хватит одного CRUD или onBeforeAdd в D7 ORM

Одиночный 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

Чеклист шаблона Connection API: startTransaction, commitTransaction, rollbackTransaction и проверка Result

Точка входа - Application::getConnection(), объект Bitrix\Main\DB\Connection. startTransaction() с main 14.0.0, см. официальный API.

Workflow атомарной операции:
startTransaction() → Table::add/update (проверка Result) → commitTransaction() при успехе → rollbackTransaction() в catch
  1. Получите соединение: $connection = Application::getConnection();
  2. Вызовите $connection->startTransaction(); до первого изменения данных.
  3. Выполните цепочку ORM-операций; после каждой проверяйте $result->isSuccess().
  4. Бросьте SystemException с текстом implode(', ', $result->getErrorMessages()), если isSuccess() = false.
  5. Зафиксируйте изменения: $connection->commitTransaction(); только если все шаги прошли без исключения.
  6. Откатите в 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 в одной транзакции без разрыва атомарности

Workflow: ORM Table::add и сырой SQL queryExecute в одной транзакции Application::getConnection

Остаток можно списать через 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.

  1. Оберните одну бизнес-операцию в одну транзакцию - не держите её открытой между HTTP-запросами.
  2. Уберите из тела транзакции отправку почты, вызовы внешних API и запись файлов - выполняйте после commit.
  3. Прогоните на staging сценарий с принудительной ошибкой на втором шаге: в БД не должно остаться ни заказа, ни списания.
  4. Убедитесь, что HL DataClass (B78) и модульные *Table (B71) пишут в то же соединение, что и ваш queryExecute.
  5. Зафиксируйте схему таблиц миграцией (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.

Обсудить вашу задачу

Было полезно?

Поставьте лайк, если статья помогла разобраться.

Поделиться статьёй

Отправьте тому, кому это пригодится.

Telegram ↗ MAX ↗ ВКонтакте ↗ Одноклассники ↗
Все статьи ↗

Как настроить регистрацию и личный кабинет покупателя на 1С-Битрикс?

Структура шаблонов сайта в Битриксе и поиск header footer

Как настроить скидки и промокоды на 1С-Битрикс: правила корзины и купоны