MidПрактика5 min

Транзакции: BEGIN, COMMIT, ROLLBACK, Savepoints

Управление транзакциями в SQL: синтаксис, savepoints, вложенные транзакции, autocommit

Транзакция — группа SQL-операций, которые выполняются как единое целое. Это ключевой механизм обеспечения целостности данных.

Основной синтаксис

BEGIN / START TRANSACTION

-- Both are equivalent in PostgreSQL
BEGIN;
-- or
START TRANSACTION;

-- With isolation level
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- With access mode
BEGIN READ ONLY;              -- no writes allowed
BEGIN READ WRITE;             -- default

COMMIT

Завершает транзакцию, сохраняя все изменения.

BEGIN;
    INSERT INTO users (email, name) VALUES ('[email protected]', 'Иван');
    UPDATE accounts SET balance = balance + 1000 WHERE user_id = 42;
COMMIT;
-- Changes are now permanent and visible to other transactions

ROLLBACK

Откатывает все изменения транзакции.

BEGIN;
    DELETE FROM important_data WHERE created_at < '2020-01-01';
    -- Oops, wrong WHERE clause
ROLLBACK;
-- All deletes cancelled, data restored

Autocommit

По умолчанию в большинстве СУБД и клиентских библиотек включён autocommit — каждая инструкция автоматически оборачивается в транзакцию.

-- With autocommit ON (default):
UPDATE users SET name = 'Test';   -- automatically: BEGIN; UPDATE...; COMMIT;
DELETE FROM logs WHERE old = true; -- automatically: BEGIN; DELETE...; COMMIT;

-- To use explicit transaction:
BEGIN;
    UPDATE users SET name = 'Test';
    DELETE FROM logs WHERE old = true;
COMMIT;

Autocommit в драйверах

// PHP PDO — autocommit is ON by default
$pdo = new PDO($dsn, $user, $pass);

// Manual transaction
$pdo->beginTransaction();
try {
    $pdo->exec("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
    $pdo->exec("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
    $pdo->commit();
} catch (\Exception $e) {
    $pdo->rollBack();
    throw $e;
}
// Go database/sql
tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
    ReadOnly:  false,
})
if err != nil {
    return err
}
defer func() {
    if p := recover(); p != nil {
        tx.Rollback()
        panic(p)
    }
}()

_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID)
if err != nil {
    tx.Rollback()
    return err
}

_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID)
if err != nil {
    tx.Rollback()
    return err
}

return tx.Commit()

SAVEPOINT — точки сохранения

Savepoint позволяет откатить часть транзакции, не отменяя всю транзакцию.

Синтаксис

SAVEPOINT savepoint_name;
ROLLBACK TO SAVEPOINT savepoint_name;
RELEASE SAVEPOINT savepoint_name;  -- removes savepoint (keeps changes)

Практический пример

BEGIN;
    INSERT INTO orders (user_id, total) VALUES (42, 5000);
    -- order_id = 1 (generated)

    SAVEPOINT after_order;

    INSERT INTO payments (order_id, method) VALUES (1, 'card');
    -- If payment processing fails:
    ROLLBACK TO SAVEPOINT after_order;
    -- order is still here, payment insert is rolled back

    INSERT INTO payments (order_id, method) VALUES (1, 'cash');
    -- Trying alternative payment method

COMMIT;  -- only if everything went well

Вложенные savepoints

BEGIN;
    INSERT INTO log (msg) VALUES ('start');

    SAVEPOINT sp1;
        INSERT INTO temp_data VALUES (1);
        SAVEPOINT sp2;
            INSERT INTO temp_data VALUES (2);
        ROLLBACK TO SAVEPOINT sp2;  -- removes temp_data(2), sp1 still active
        INSERT INTO temp_data VALUES (3);
    ROLLBACK TO SAVEPOINT sp1;      -- removes temp_data(1) and temp_data(3)

    INSERT INTO log (msg) VALUES ('end');
COMMIT;  -- only log rows committed

Savepoints в ОРМ-фреймворках

// Doctrine ORM — uses savepoints for nested transactions
$em->beginTransaction();
try {
    $em->beginTransaction(); // creates savepoint internally
    try {
        $em->persist($order);
        $em->flush();
        $em->commit(); // releases savepoint
    } catch (\Exception $e) {
        $em->rollback(); // rollback to savepoint
    }
    $em->commit(); // real commit
} catch (\Exception $e) {
    $em->rollback(); // real rollback
}

Вложенные транзакции (Nested Transactions)

Большинство СУБД не поддерживают истинно вложенные транзакции — только через savepoints.

BEGIN;
    -- This is the outer transaction
    BEGIN;  -- PostgreSQL: WARNING: there is already a transaction in progress
    -- Inner BEGIN is ignored, still in the outer transaction
COMMIT;

Совет: если нужны вложенные транзакции в приложении — используйте явные savepoints или механизм, предоставляемый ORM.


Транзакции и DDL

В PostgreSQL DDL (CREATE TABLE, ALTER TABLE) транзакционны — их можно откатить.

BEGIN;
    CREATE TABLE test_migration (id SERIAL PRIMARY KEY);
    ALTER TABLE users ADD COLUMN last_login TIMESTAMPTZ;
    -- If something goes wrong:
ROLLBACK;
-- Both CREATE TABLE and ALTER TABLE are rolled back

MySQL не поддерживает транзакционный DDL — ALTER TABLE неявно делает COMMIT.


Долгие транзакции — антипаттерн

Проблемы долгих транзакций

BEGIN;
    SELECT * FROM large_report; -- 30 seconds query
    -- During this time:
    -- - Rows visible to this transaction cannot be vacuumed
    -- - Locks may block other transactions
    -- - WAL cannot be truncated
    UPDATE settings SET value = 'x' WHERE key = 'y';
COMMIT;

Последствия:

  • Table bloat — VACUUM не может убрать мёртвые версии строк
  • Lock contention — удерживаемые блокировки тормозят других
  • WAL накопление — журнал растёт, replikation lag увеличивается

Как обнаружить долгие транзакции

-- Show transactions running for more than 5 minutes
SELECT pid, now() - pg_stat_activity.query_start AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle'
  AND query_start < now() - interval '5 minutes'
ORDER BY duration DESC;

Паттерн: батчевая обработка

-- WRONG: one huge transaction
BEGIN;
UPDATE orders SET status = 'archived' WHERE created_at < '2020-01-01';
-- 10 million rows — transaction open for minutes
COMMIT;

-- CORRECT: small batches
DO $$
DECLARE
    batch_size INT := 1000;
    rows_updated INT;
BEGIN
    LOOP
        UPDATE orders SET status = 'archived'
        WHERE id IN (
            SELECT id FROM orders
            WHERE created_at < '2020-01-01' AND status != 'archived'
            LIMIT batch_size
        );
        GET DIAGNOSTICS rows_updated = ROW_COUNT;
        EXIT WHEN rows_updated = 0;
        PERFORM pg_sleep(0.1); -- small pause between batches
    END LOOP;
END $$;

Обработка ошибок и retry

Retry-логика для SERIALIZABLE

// Retry on serialization error
func withRetry(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
    const maxRetries = 3
    for attempt := 0; attempt < maxRetries; attempt++ {
        tx, err := db.BeginTx(ctx, &sql.TxOptions{
            Isolation: sql.LevelSerializable,
        })
        if err != nil {
            return err
        }

        err = fn(tx)
        if err != nil {
            tx.Rollback()
            // Check if it's a serialization error (PostgreSQL error code 40001)
            if isSerializationError(err) && attempt < maxRetries-1 {
                continue // retry
            }
            return err
        }

        if err = tx.Commit(); err != nil {
            if isSerializationError(err) && attempt < maxRetries-1 {
                continue // retry
            }
            return err
        }
        return nil
    }
    return fmt.Errorf("max retries exceeded")
}

Транзакции в распределённых системах

Одна транзакция в нескольких микросервисах — сложная задача. Решения:

2PC (Two-Phase Commit)

Phase 1 (Prepare):
  Coordinator → Service A: "Prepare to commit"
  Coordinator → Service B: "Prepare to commit"
  A, B: "Ready"

Phase 2 (Commit):
  Coordinator → A, B: "Commit"
  A, B: "Done"

Проблема: coordinator failure между фазами — система зависает.

Saga Pattern

Последовательность локальных транзакций с компенсирующими действиями при отказе.

OrderService: CreateOrder → emit OrderCreated
PaymentService: ProcessPayment → emit PaymentProcessed
                (if fails) → emit PaymentFailed → compensate: CancelOrder
InventoryService: ReserveItem → emit ItemReserved
                 (if fails) → emit ReservationFailed → compensate: RefundPayment + CancelOrder

Saga не гарантирует изоляцию — промежуточные состояния видны другим сервисам.


Типичные вопросы на интервью

Q: «Что произойдёт, если не вызвать COMMIT?»

Транзакция остаётся открытой до закрытия соединения (тогда автоматический ROLLBACK) или истечения таймаута (lock_timeout, idle_in_transaction_session_timeout).

Q: «Можно ли сделать DDL транзакционным в MySQL?»

Нет. В MySQL ALTER TABLE неявно делает COMMIT перед выполнением и после. Это ключевое отличие от PostgreSQL.

Q: «Зачем нужен RELEASE SAVEPOINT?»

Освобождает память, занятую savepoint. После RELEASE savepoint нельзя использовать для ROLLBACK, но все изменения сохраняются. Полезно для оптимизации при большом числе savepoints.

Проверь себя

Что произойдёт с транзакцией, если соединение с базой данных закроется без явного COMMIT или ROLLBACK?

Разработчик хочет попробовать один вариант вставки данных, и если ошибка — попробовать другой, не отменяя всю транзакцию. Что использовать?

В чём главная опасность долгих транзакций в PostgreSQL?

Поддерживает ли MySQL транзакционный DDL (откат ALTER TABLE через ROLLBACK)?