Что такое транзакция

Транзакция — это граница, внутри которой несколько SQL-операций должны пройти как единое изменение данных. Если всё получилось, приложение делает commit(). Если что-то пошло не так, делает rollBack(), и база возвращает данные к состоянию до начала транзакции.

Типичный пример — перенос денег между счетами. Недостаточно выполнить два отдельных UPDATE: списание с одного счёта и зачисление на другой должны либо сохраниться вместе, либо не сохраниться вообще.

<?php

$pdo->beginTransaction();

try {
    $stmt = $pdo->prepare(
        'UPDATE accounts SET balance = balance - :amount WHERE id = :id'
    );
    $stmt->execute(['amount' => 1000, 'id' => $fromAccountId]);

    $stmt = $pdo->prepare(
        'UPDATE accounts SET balance = balance + :amount WHERE id = :id'
    );
    $stmt->execute(['amount' => 1000, 'id' => $toAccountId]);

    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    throw $e;
}

Здесь важны две границы. SQL-значения всё ещё идут через placeholders, как в Prepared statements и SQL injection. А атомарность операции задаёт уже не prepare(), а транзакция.

flowchart TD A[beginTransaction] --> B[SQL operation 1] B --> C[SQL operation 2] C --> D{Все операции успешны?} D -- Да --> E[commit] D -- Нет: PDOException или другой Throwable --> F[rollBack] E --> G[Изменения сохранены] F --> H[Изменения отменены]
flowchart TD
    A[beginTransaction] --> B[SQL operation 1]
    B --> C[SQL operation 2]
    C --> D{Все операции успешны?}
    D -- Да --> E[commit]
    D -- Нет: PDOException или другой Throwable --> F[rollBack]
    E --> G[Изменения сохранены]
    F --> H[Изменения отменены]
Базовая ветка транзакции: фиксировать только после успешного завершения всей бизнес-операции.
Quick recall
Почему перенос денег между двумя счетами нельзя делать двумя независимыми `UPDATE` без транзакции?

beginTransaction(), commit() и rollBack()

PDO::beginTransaction() начинает транзакцию для текущего подключения. Если драйвер и база поддерживают транзакции, PDO переключает соединение из обычного autocommit-поведения в режим, где изменения ждут явного решения.

PDO::commit() фиксирует изменения. После успешного commit база считает их постоянными, и откатить их обычным rollBack() уже нельзя.

PDO::rollBack() отменяет изменения, сделанные после beginTransaction(). Обычно его вызывают в catch, но откат может быть нужен и в бизнес-ветке: например, если приложение обнаружило, что итоговая операция нарушает доменное правило.

Практический шаблон лучше писать так, чтобы rollback происходил при любой ошибке, не только при PDOException:

<?php

function createOrder(PDO $pdo, int $userId, array $items): int
{
    $pdo->beginTransaction();

    try {
        $stmt = $pdo->prepare(
            'INSERT INTO orders (user_id, status) VALUES (:user_id, :status)'
        );
        $stmt->execute([
            'user_id' => $userId,
            'status' => 'draft',
        ]);

        $orderId = (int) $pdo->lastInsertId();

        $itemStmt = $pdo->prepare(
            'INSERT INTO order_items (order_id, sku, quantity)
             VALUES (:order_id, :sku, :quantity)'
        );

        foreach ($items as $item) {
            if ($item['quantity'] <= 0) {
                throw new InvalidArgumentException('Quantity must be positive');
            }

            $itemStmt->execute([
                'order_id' => $orderId,
                'sku' => $item['sku'],
                'quantity' => $item['quantity'],
            ]);
        }

        $pdo->commit();

        return $orderId;
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $e;
    }
}

Throwable здесь уместен: откат нужен и при ошибке базы, и при обычном исключении из PHP-кода. Общая модель таких ошибок описана в Ошибки, исключения и Throwable.

Quick recall
В типичном шаблоне PDO-транзакции где должен стоять `rollBack()` при ошибке?

Autocommit и границы ответственности

В большинстве прикладных сценариев отдельный SQL-запрос фиксируется автоматически: это и называют autocommit. Транзакция временно меняет это поведение для соединения. Пока она открыта, изменения остаются внутри транзакционной границы.

Из этого следует неприятная, но полезная привычка: транзакцию надо держать короткой. Не открывайте её до HTTP-запроса к внешнему API, генерации PDF, отправки email или долгой обработки файла. Чем дольше открыта транзакция, тем выше риск блокировок, deadlock и ожидания со стороны других запросов.

Хорошая форма: сначала валидировать входные данные, подготовить расчёты в памяти, затем открыть транзакцию, сделать минимальный набор SQL-операций и сразу закрыть её через commit() или rollBack().

Quick recall
Как autocommit меняет поведение одиночных SQL-запросов вне явной транзакции?

Implicit commits: ловушка DDL внутри транзакции

Не всё, что выглядит как SQL внутри транзакции, обязательно откатывается так, как ожидает приложение. Некоторые СУБД автоматически фиксируют текущую транзакцию при выполнении DDL-команд: CREATE TABLE, ALTER TABLE, DROP TABLE и похожих операций. В документации PDO отдельно предупреждается, что такие implicit commits зависят от базы и драйвера.

Практическое правило: не смешивайте миграции схемы и обычные пользовательские изменения в одной прикладной транзакции.

<?php

$pdo->beginTransaction();

try {
    $pdo->exec('INSERT INTO audit_log (event) VALUES ("before schema change")');

    // Плохо для прикладного кода: в некоторых СУБД это может сделать implicit commit.
    $pdo->exec('ALTER TABLE users ADD COLUMN nickname VARCHAR(255)');

    $pdo->rollBack(); // Может уже не отменить то, что вы рассчитывали отменить.
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    throw $e;
}

Миграции лучше держать в отдельном процессе и отдельной дисциплине деплоя. Это уже ближе к темам composer.lock, install/update и audit и Миграции между версиями PHP, а не к повседневной обработке request-response.

Режимы ошибок PDO

У PDO есть несколько режимов обработки ошибок. Исторически встречались три варианта: silent mode, warning mode и exception mode. В современном коде почти всегда выбирают исключения:

<?php

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

PDO::ERRMODE_EXCEPTION означает, что ошибка базы превращается в PDOException. Это удобно для транзакций: не нужно проверять результат каждого execute() вручную, а общий catch гарантированно увидит сбой и сделает rollback.

Даже если ваша версия PHP уже использует exception mode по умолчанию, явная настройка остаётся нормальной практикой: она делает фабрику подключения читаемой, переносимой и понятной для ревью. Это хорошо сочетается с базовой настройкой из PDO и подключение к базе.

Что логировать, а что показывать пользователю

PDOException может нести SQLSTATE-код и driver-specific детали. Для диагностики это ценно: по ним отличают unique constraint violation, deadlock, потерю соединения, синтаксическую ошибку SQL и другие случаи. Но эти детали не должны утекать в HTML-ответ пользователю.

<?php

try {
    $pdo->beginTransaction();

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

    $pdo->commit();
} catch (PDOException $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    error_log('Database error: ' . $e->getMessage());

    throw new RuntimeException('Не удалось сохранить данные', 0, $e);
}

В production нормальный путь такой: подробности — в логи, пользователю — нейтральное сообщение, HTTP-статус — по смыслу ошибки. display_errors и похожие настройки относятся уже к Конфигурация безопасности PHP.

Deadlock и retry

Транзакции не отменяют конкуренцию между запросами. Два процесса могут взять блокировки в разном порядке и получить deadlock. База обычно откатывает одну из транзакций, чтобы система могла продолжить работу.

Если операция идемпотентна или безопасно повторяема, retry делают вокруг всей транзакции, а не вокруг одного последнего statement. Иначе можно повторить только часть бизнес-операции и получить неконсистентные данные.

<?php

$attempts = 0;

while (true) {
    $attempts++;

    try {
        $pdo->beginTransaction();

        // Весь набор SQL-операций одной бизнес-команды.

        $pdo->commit();
        break;
    } catch (PDOException $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        if ($attempts >= 3) {
            throw $e;
        }

        // В реальном коде здесь проверяют SQLSTATE/driver code,
        // чтобы повторять только deadlock/serialization failures.
        usleep(100_000);
    }
}

Не превращайте этот шаблон в универсальный «повторять всё». Retry нужен только для ошибок, которые действительно могут пройти при повторной попытке.

Nested transactions и savepoints

PDO не даёт универсальной абстракции вложенных транзакций. Если вызвать beginTransaction() повторно на том же соединении, когда транзакция уже открыта, это не становится независимой внутренней транзакцией.

Для сложных случаев в СУБД есть savepoints: промежуточные точки внутри транзакции. Но их синтаксис и гарантии зависят от базы, поэтому фреймворки и DBAL-библиотеки часто строят поверх них свой transaction manager.

В обычном PHP-коде лучше держать правило: одна бизнес-команда — одна явная транзакционная граница. Внутренние функции не должны молча начинать и закрывать транзакции, если вызывающий код уже управляет общей операцией.

См. также

Sources

  1. PHP Manual: Transactions and auto-commit
  2. PHP Manual: Errors and error handling
  3. PHP Manual: PDO::beginTransaction
  4. PHP Manual: PDO::inTransaction