Введение
Транзакция — группа операций с базой данных, которая должна быть выполнена целиком (commit) или не выполнена вовсе (rollback), без промежуточных, частично применённых состояний. Это критично для операций, состоящих из нескольких взаимосвязанных шагов — например, перевод средств между счетами должен либо списать с одного счёта и зачислить на другой одновременно, либо не выполниться вовсе, но никогда не в «наполовину выполненном» состоянии. В статье разберём использование транзакций через QSqlDatabase на практическом примере.
Концепция
QSqlDatabase::transaction() начинает транзакцию, commit() подтверждает все изменения, сделанные с момента начала транзакции, а rollback() отменяет их полностью. Поддержка транзакций зависит от драйвера и самой СУБД — большинство современных СУБД (PostgreSQL, MySQL с InnoDB, SQLite) поддерживают транзакции полноценно, но это стоит явно проверять через QSqlDriver::hasFeature(QSqlDriver::Transactions) для конкретного используемого драйвера, поскольку некоторые конфигурации (например, MySQL с движком MyISAM) не поддерживают откат.
Пример кода: Транзакции в QSqlDatabase
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QDebug>
bool transferFunds(QSqlDatabase &db, int fromAccountId, int toAccountId, double amount)
{
if (!db.driver()->hasFeature(QSqlDriver::Transactions)) {
qWarning() << "Драйвер базы данных не поддерживает транзакции";
return false;
}
if (!db.transaction()) {
qWarning() << "Не удалось начать транзакцию:" << db.lastError().text();
return false;
}
QSqlQuery checkBalance(db);
checkBalance.prepare("SELECT balance FROM accounts WHERE id = :id");
checkBalance.bindValue(":id", fromAccountId);
if (!checkBalance.exec() || !checkBalance.next()) {
db.rollback();
qWarning() << "Не удалось получить баланс счёта-отправителя";
return false;
}
const double currentBalance = checkBalance.value(0).toDouble();
if (currentBalance < amount) {
db.rollback();
qWarning() << "Недостаточно средств на счёте" << fromAccountId;
return false;
}
QSqlQuery withdraw(db);
withdraw.prepare("UPDATE accounts SET balance = balance - :amount WHERE id = :id");
withdraw.bindValue(":amount", amount);
withdraw.bindValue(":id", fromAccountId);
if (!withdraw.exec()) {
db.rollback();
qWarning() << "Ошибка списания средств:" << withdraw.lastError().text();
return false;
}
QSqlQuery deposit(db);
deposit.prepare("UPDATE accounts SET balance = balance + :amount WHERE id = :id");
deposit.bindValue(":amount", amount);
deposit.bindValue(":id", toAccountId);
if (!deposit.exec()) {
db.rollback();
qWarning() << "Ошибка зачисления средств:" << deposit.lastError().text();
return false;
}
QSqlQuery logTransfer(db);
logTransfer.prepare(
"INSERT INTO transfer_log (from_account, to_account, amount, timestamp) "
"VALUES (:from, :to, :amount, datetime('now'))");
logTransfer.bindValue(":from", fromAccountId);
logTransfer.bindValue(":to", toAccountId);
logTransfer.bindValue(":amount", amount);
if (!logTransfer.exec()) {
db.rollback();
qWarning() << "Ошибка записи в журнал переводов:" << logTransfer.lastError().text();
return false;
}
if (!db.commit()) {
qWarning() << "Не удалось закоммитить транзакцию:" << db.lastError().text();
db.rollback();
return false;
}
qDebug() << "Перевод" << amount << "успешно выполнен";
return true;
}
Пояснения к коду
transferFunds выполняет четыре логически связанных шага (проверка баланса, списание, зачисление, запись в журнал) внутри одной транзакции, начатой через db.transaction(). Каждый промежуточный шаг проверяется на успешность, и при любой неудаче вызывается db.rollback() с немедленным возвратом — это гарантирует, что ни одно из изменений (списание без зачисления, например) не останется частично применённым в базе данных при сбое на любом из шагов.
Только если все четыре операции выполнены успешно, вызывается db.commit(), фактически фиксирующий все изменения атомарно — с точки зрения любого другого клиента базы данных, читающего эти данные, либо весь перевод виден целиком (после успешного коммита), либо не виден вовсе (до коммита или после отката), но никогда в промежуточном состоянии.
Подводные камни
- Незавершённая транзакция, оставленная «висящей» из-за раннего
returnбез явногоrollback(). Если в коде есть путь выхода из функции, не проходящий черезcommit()илиrollback()(например, исключение, выбрасываемое посреди транзакции и не перехваченное), соединение может остаться в состоянии открытой транзакции, блокируя дальнейшую работу с базой данных через это соединение — критичный код внутри транзакции должен либо быть исключение-безопасным, либо явно оборачиваться в try/catch с гарантированным rollback в catch-блоке. - Длительные транзакции, удерживающие блокировки на строки/таблицы продолжительное время. Если внутри транзакции выполняется медленная операция (например, ожидание пользовательского ввода или сетевой запрос к внешнему сервису между шагами), блокировки, удерживаемые СУБД для затронутых строк, могут существенно снижать параллелизм для других клиентов базы данных — транзакции должны быть как можно короче по времени выполнения.
- Вложенные транзакции не поддерживаются стандартным API
QSqlDatabaseнапрямую. Повторный вызовtransaction()внутри уже открытой транзакции в большинстве драйверов не создаёт настоящую вложенную транзакцию (с независимым частичным откатом) — для имитации вложенности нужны именованные точки сохранения (SAVEPOINT), выполняемые через прямые SQL-команды, поскольку Qt не предоставляет высокоуровневой обёртки для них. - Уровень изоляции транзакции по умолчанию может не соответствовать ожиданиям. Разные СУБД используют разные уровни изоляции по умолчанию (
READ COMMITTED,REPEATABLE READи так далее), влияющие на то, какие «фантомные» или «грязные» чтения возможны при конкурентном доступе — для критичных к консистентности операций уровень изоляции стоит устанавливать явно через SQL-команду, специфичную для используемой СУБД, а не полагаться на умолчания.