Что такое транзакция
Транзакция — это граница, внутри которой несколько 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[Изменения отменены]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.
Autocommit и границы ответственности
В большинстве прикладных сценариев отдельный SQL-запрос фиксируется автоматически: это и называют autocommit. Транзакция временно меняет это поведение для соединения. Пока она открыта, изменения остаются внутри транзакционной границы.
Из этого следует неприятная, но полезная привычка: транзакцию надо держать короткой. Не открывайте её до HTTP-запроса к внешнему API, генерации PDF, отправки email или долгой обработки файла. Чем дольше открыта транзакция, тем выше риск блокировок, deadlock и ожидания со стороны других запросов.
Хорошая форма: сначала валидировать входные данные, подготовить расчёты в памяти, затем открыть транзакцию, сделать минимальный набор SQL-операций и сразу закрыть её через commit() или rollBack().
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-коде лучше держать правило: одна бизнес-команда — одна явная транзакционная граница. Внутренние функции не должны молча начинать и закрывать транзакции, если вызывающий код уже управляет общей операцией.
См. также
- PDO и подключение к базе — DSN, options, fetch modes и фабрика подключения.
- Prepared statements и SQL injection — безопасная передача значений в SQL внутри и вне транзакций.
- Ошибки, исключения и Throwable — почему rollback часто пишут в
catch (Throwable $e). - Конфигурация безопасности PHP — production-настройки ошибок, логирования и поверхности атаки.
- CLI и встроенный сервер — где обычно запускают скрипты обслуживания и проверочные команды, отделённые от веб-запроса.