05.02.2026 12 мин чтения

Транзакции в Битрикс: атомарные операции без «полусохранений»

Транзакция — это способ выполнить несколько SQL-операций как одно целое: либо применятся все изменения, либо ни одно.

Кирилл Новожилов

Кирилл Новожилов

Автор

Транзакции в Битрикс: атомарные операции без «полусохранений»

В 1С‑Битрикс транзакции особенно важны, потому что многие бизнес-операции состоят из цепочки действий: запись в несколько таблиц, обновление остатков, привязка файлов, логирование, очереди, события. Если такую операцию выполнять «по шагам» без транзакции, вы рано или поздно поймаете классическую проблему: часть данных сохранилась, часть — нет.

Проблема: одна бизнес-операция — много записей

Представим типичный сценарий: создаём заказ, добавляем позиции, записываем платежную информацию и историю статусов.

        // Типичный «пошаговый» код без транзакции
$orderId = OrderTable::add($orderFields)->getId();

foreach ($items as $item)
{
    BasketTable::add(['ORDER_ID' => $orderId] + $item);
}

PaymentTable::add(['ORDER_ID' => $orderId, 'AMOUNT' => $amount]);
StatusHistoryTable::add(['ORDER_ID' => $orderId, 'STATUS' => 'NEW']);

    

На первый взгляд всё нормально. Но что если:

  • БД упала на добавлении оплаты?
  • в одной из позиций корзины прилетела ошибка валидации?
  • сработал уникальный индекс (дубликат внешнего номера)?
  • возник дедлок и один запрос не прошёл?

В итоге вы получаете полусобранный заказ: заказ есть, корзина частично есть, оплаты нет, история статусов отсутствует. Такие «обломки» потом вылезают в админке, отчетах и интеграциях.

Почему это больно в Битрикс

1. Ошибка может быть не исключением

В D7 многие операции возвращают Result/AddResult и не бросают исключение сами по себе. Если вы забыли проверить isSuccess(), транзакция «в голове» есть, а в коде — нет.

2. Данные размазаны по слоям

Часть логики в сервисе, часть в репозитории, часть в обработчиках событий. Без явной границы атомарности легко потерять контроль.

3. Побочные эффекты не откатить

Файлы, письма, внешние API — всё это не откатывается транзакцией БД. Нужен правильный порядок действий.

Решение: транзакции

Транзакция делает из цепочки SQL-операций атомарную операцию:

  • успехcommit (фиксируем изменения)
  • ошибкаrollback (возвращаем БД в исходное состояние)

Базовый шаблон (D7)

        use Bitrix\Main\Application;

$connection = Application::getConnection();
$connection->startTransaction();

try {
    // ... несколько операций с БД ...

    $connection->commitTransaction();
} catch (\Throwable $e) {
    $connection->rollbackTransaction();
    throw $e;
}

    

Это минимальный каркас, который должен быть «рефлексом»: всё, что должно сохраниться вместе — выполняем в транзакции.

API транзакций в Битрикс

В Битрикс есть два основных способа работать с транзакциями: через D7‑соединение и через старый $DB.

D7: Bitrix\Main\Application::getConnection()

Это предпочтительный вариант для нового кода:

        use Bitrix\Main\Application;

$connection = Application::getConnection();          // default connection
// $connection = Application::getConnection('name'); // если у вас несколько соединений

$connection->startTransaction();
// ...
$connection->commitTransaction();
// или
$connection->rollbackTransaction();

    

Legacy: $DB->StartTransaction()

Старый API по‑прежнему встречается и тоже работает (под капотом он вызывает те же методы соединения):

        global $DB;

$DB->StartTransaction();

try {
    // ... старое ядро, CIBlockElement, CUser, прямые запросы ...
    $DB->Commit();
} catch (\Throwable $e) {
    $DB->Rollback();
    throw $e;
}

    

Что важно понимать про транзакции

1) Транзакция — это про БД, а не про «всё на свете»

Транзакция откатывает только изменения в базе данных. Она не откатит:

  • запись файла на диск
  • отправку письма / webhook / запрос в API
  • публикацию сообщения в очередь (если очередь не в той же БД и не в той же транзакции)

Отсюда главный вывод: побочные эффекты либо после коммита, либо через очередь.

2) Транзакция привязана к соединению

В Битрикс можно иметь несколько соединений (и даже master/slave). Транзакция действует только на то соединение, на котором вы её начали.

Если вы внутри транзакции случайно используете другое соединение, атомарность ломается.

3) Транзакции должны быть короткими

Чем дольше открыта транзакция, тем больше риск:

  • блокировок и ожиданий
  • дедлоков
  • деградации производительности (особенно при REPEATABLE READ и большом количестве чтений)

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

Практический пример: атомарное создание сущности (D7 + Result)

Ниже пример, который показывает два типичных нюанса Битрикс:

  • операции могут возвращать Result, а не исключение
  • важно собрать ошибки и корректно откатиться
        use Bitrix\Main\Application;
use Bitrix\Main\SystemException;

final class OrderCreateService
{
    public function create(array $orderFields, array $items, array $paymentFields): int
    {
        $connection = Application::getConnection();
        $connection->startTransaction();

        try {
            $orderAdd = OrderTable::add($orderFields);
            if (!$orderAdd->isSuccess()) {
                throw new SystemException(implode('; ', $orderAdd->getErrorMessages()));
            }

            $orderId = (int)$orderAdd->getId();

            foreach ($items as $item) {
                $basketAdd = BasketTable::add(['ORDER_ID' => $orderId] + $item);
                if (!$basketAdd->isSuccess()) {
                    throw new SystemException(implode('; ', $basketAdd->getErrorMessages()));
                }
            }

            $paymentAdd = PaymentTable::add(['ORDER_ID' => $orderId] + $paymentFields);
            if (!$paymentAdd->isSuccess()) {
                throw new SystemException(implode('; ', $paymentAdd->getErrorMessages()));
            }

            $connection->commitTransaction();

            return $orderId;
        } catch (\Throwable $e) {
            $connection->rollbackTransaction();
            throw $e;
        }
    }
}

    

Обратите внимание: мы переводим ошибки Result → в исключение, чтобы гарантированно уйти в rollback.

Где держать границу транзакции (архитектура)

Транзакция должна «обнимать» бизнес-операцию, а не отдельный запрос.

Идеальная точка — сервис, который координирует несколько репозиториев/таблиц:

        ┌─────────────────────────────────────────┐
│            Controller                   │
│  Валидация запроса, DTO, ответ          │
└─────────────────┬───────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────┐
│             Service                     │
│  Старт/коммит/роллбек транзакции        │
│  Координация репозиториев               │
└─────────────────┬───────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────┐
│            Repository                   │
│  CRUD/ORM, без startTransaction()       │
└─────────────────────────────────────────┘

    

Почему не в репозитории?

Потому что репозиторий часто не знает, будет ли он единственным участником операции. Если каждый репозиторий начнёт «сам» открывать транзакции, вы очень быстро придёте к вложенности и странным ошибкам.

Вложенные транзакции в Битрикс: savepoints и нюансы

В ядре Битрикс вложенность транзакций реализована через механизм SAVEPOINT. Это позволяет открывать «транзакцию внутри транзакции» без фактического коммита во внешнюю базу до самого верхнего уровня.

На уровне SQL это выглядит примерно так:

        -- 1-й уровень: startTransaction()
START TRANSACTION;

-- 2-й уровень: startTransaction()
SAVEPOINT TRANS2;

-- ... операции ...

-- 2-й уровень: commitTransaction()
RELEASE SAVEPOINT TRANS2;

-- 1-й уровень: commitTransaction()
COMMIT;

    

Как это работает в коде

D7-соединение (Bitrix\Main\DB\Connection) отслеживает уровень вложенности:

  1. Первый вызов startTransaction() отправляет в БД реальную команду START TRANSACTION.
  2. Последующие вызовы создают именованные точки сохранения (SAVEPOINT TRANS{N}).
  3. commitTransaction() уменьшает счетчик вложенности. Реальный COMMIT происходит только тогда, когда счетчик достигает нуля.
  4. rollbackTransaction() на вложенном уровне выполняет ROLLBACK TO SAVEPOINT ....

Критический нюанс: TransactionException

В Битрикс (особенно в MySQL-драйвере) есть важная особенность: если на вложенном уровне произошел rollbackTransaction(), то при попытке сделать commitTransaction() на внешнем уровне ядро выбросит исключение Bitrix\Main\DB\TransactionException с текстом вроде "Rollback was called on a nested transaction.".

Это сделано для безопасности: если внутренняя часть логики «сломалась» и откатилась, внешняя часть не должна иметь возможности успешно зафиксировать свои изменения, так как целостность всей бизнес-операции уже нарушена.

Практические правила:

  • Не делайте rollback во внутренних методах, если планируете «проглотить» ошибку и продолжить работу во внешнем слое.
  • Если внутренний слой столкнулся с ошибкой — бросайте исключение. Пусть самый верхний слой (граница транзакции в сервисе) решит, что делать: откатывать всё или обрабатывать ошибку.
  • Если вы используете вложенные транзакции, будьте готовы, что после вложенного отката вся цепочка транзакций считается «испорченной» и подлежит полному откату.

Транзакции и cluster (master/slave): не делайте SELECT «мимо» транзакции

В D7‑соединении метод query() может отправлять SELECT на slave‑соединение (если установлен модуль cluster и включено распределение чтения). Для транзакций это критично: транзакция живёт на master‑соединении, и чтения «на стороне» ломают идею атомарности и блокировок.

Если вы работаете в окружении с master/slave и хотите гарантировать, что внутри операции все запросы идут на master, включайте режим master‑only на время транзакции:

        use Bitrix\Main\Application;

$app = Application::getInstance();
$pool = $app->getConnectionPool();
$pool->useMasterOnly(true);

$connection = $pool->getConnection();
$connection->startTransaction();

try {
    // ... любые SELECT/INSERT/UPDATE/DELETE ...
    $connection->commitTransaction();
} catch (\Throwable $e) {
    $connection->rollbackTransaction();
    throw $e;
} finally {
    $pool->useMasterOnly(false);
}

    

Типичные ошибки

Кейс A Забыли rollback в catch

        try {
    $connection->startTransaction();
    // ...
} catch (\Throwable $e) {
    // Плохо: транзакция не откатится
}

    

Кейс B Проверили Result, но не прервали выполнение

        $res = SomeTable::add($fields);
if (!$res->isSuccess()) {
    // Плохо: записали в лог и продолжаем
}
// дальше код всё равно коммитит

    

Кейс C commit/rollback без startTransaction

В ядре это приводит к TransactionException («Transaction was not started.»). Такое часто появляется при сложных ветвлениях или ранних return.

Кейс D Долгая транзакция

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

Рекомендации

1. Одна транзакция — одна бизнес-операция

Открывайте транзакцию на границе (в сервисе), закрывайте там же.

2. Делайте «rollback по умолчанию»

Структура try { ... commit } catch { rollback } должна быть в каждом месте, где транзакция открывается.

3. Переводите Result в исключение

Это самый простой способ не забыть откат.

4. Побочные эффекты — после коммита

Если нужно гарантировать доставку уведомления — обрабатывайте очередь отдельно.

Заключение

Транзакции в Битрикс — это не «опциональная штука», а базовый инструмент для надёжности. Как только операция затрагивает больше одной записи/таблицы, транзакция превращает набор запросов в предсказуемое атомарное действие.

Начните с простого: найдите места, где «создание/обновление» делается в несколько шагов, и оберните их в транзакцию на уровне сервиса. Уже это заметно снизит количество странных багов и «полусохранённых» данных.

Опубликовано 17 часов назад

Похожие статьи

Мы используем файлы cookie для улучшения работы сайта. Продолжая использовать сайт, вы соглашаетесь с нашей политикой конфиденциальности.