SQL и Qt

Транзакции в QSqlDatabase: откат изменений при ошибках

Введение

Транзакция — группа операций с базой данных, которая должна быть выполнена целиком (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-команду, специфичную для используемой СУБД, а не полагаться на умолчания.