Модуль: Базы данных · Уровень: Senior

TL;DR#

  • ACID = Atomicity (всё или ничего, реализуется через WAL и откат), Consistency (инварианты БД: constraints, triggers, FK), Isolation (параллельные транзакции не мешают друг другу — в PostgreSQL через MVCC + уровни изоляции), Durability (после COMMIT данные переживут крах — гарантируется WAL + fsync).
  • PostgreSQL не имеет настоящих вложенных транзакций — SAVEPOINT создаёт субтранзакции (получают свой subxid), которые дёшевы в небольшом количестве, но при >64 на бэкенд вызывают SLRU overflow и резкую деградацию (subtrans SLRU misses).
  • Длинные транзакции — главное зло в PostgreSQL: удерживают xmin горизонт → VACUUM не может удалить dead tuples → bloat, распухание индексов, деградация. idle in transaction особенно опасен.
  • В Go: всегда BeginTx(ctx, opts), паттерн defer tx.Rollback() (после Commit это no-op), не забывать ctx для отмены. В pgxpgxpool + tx.Begin/pgx.BeginFunc.
  • На уровне SERIALIZABLEREPEATABLE READ при write skew конфликтах) транзакция может упасть с 40001 (serialization_failure) или 40P01 (deadlock_detected) — это нормально и ожидаемо, надо реализовать retry с экспоненциальным backoff.

Теория#

ACID подробно#

ACID — набор гарантий, которые СУБД даёт относительно транзакций. Важно понимать не определения из учебника, а как именно они реализованы в PostgreSQL.

СвойствоЧто гарантируетКак реализовано в PostgreSQL
AtomicityТранзакция применяется целиком или не применяется вовсеMVCC: изменения видны только при валидном xmax/xmin; ROLLBACK помечает транзакцию как aborted в pg_clog/pg_xact, версии строк просто не становятся видны
ConsistencyНе нарушаются инварианты БД (PK, FK, CHECK, NOT NULL, триггеры)Проверка constraints на момент commit (или сразу, или DEFERRED)
IsolationКонкурентные транзакции не «протекают» друг в другаMVCC + уровни изоляции (snapshot per statement/transaction)
DurabilityЗафиксированные данные не теряются при крахеWAL (Write-Ahead Log) + fsync журнала при commit

Atomicity#

Атомарность означает: если внутри транзакции 10 UPDATE, и на 7-м произошла ошибка — ни один из них не должен остаться. В PostgreSQL это естественное следствие MVCC: каждая версия строки помечена xmin (id транзакции-создателя) и xmax (id транзакции-удалителя). Видимость строки определяется статусом этих транзакций в pg_xact (commit log). Если транзакция откатилась — её xmin имеет статус ABORTED, и созданные ею версии строк просто никогда не станут видимы. Никакого «физического отката» данных не происходит — это делает ROLLBACK очень дешёвым (откатить почти так же быстро, как и не делать).

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- если здесь произойдёт сбой / ROLLBACK, оба UPDATE «исчезнут»
COMMIT;

Consistency#

Это самое «прикладное» свойство: БД переходит из одного валидного состояния в другое. PostgreSQL обеспечивает её через декларативные ограничения. Важная деталь senior-уровня — DEFERRED constraints: FK можно отложить до конца транзакции.

-- Отложенная проверка FK позволяет временно нарушить инвариант внутри транзакции
ALTER TABLE orders
  ADD CONSTRAINT fk_customer FOREIGN KEY (customer_id)
  REFERENCES customers(id) DEFERRABLE INITIALLY DEFERRED;

BEGIN;
  -- можно вставить order до customer, проверка будет на COMMIT
  INSERT INTO orders(id, customer_id) VALUES (1, 42);
  INSERT INTO customers(id) VALUES (42);
COMMIT; -- проверка FK здесь

Isolation — MVCC#

PostgreSQL использует MVCC (Multi-Version Concurrency Control): читатели не блокируют писателей и наоборот. Вместо блокировок на чтение каждая транзакция видит снимок (snapshot) данных.

  • Снимок — это набор: xmin (нижняя граница активных транзакций), xmax (верхняя), и список xip (in-progress xids). Строка видна, если её создавшая транзакция закоммитилась до снимка и не видна удаляющая.
  • На уровне READ COMMITTED (дефолт) новый снимок берётся на каждый statement. Поэтому два SELECT в одной транзакции могут вернуть разные данные (non-repeatable read).
  • На уровне REPEATABLE READ и SERIALIZABLE снимок берётся один раз на всю транзакцию (на первый statement, читающий/пишущий данные).
Уровень изоляцииDirty readNon-repeatable readPhantom readWrite skewРеализация в PG
READ UNCOMMITTEDнет*возможновозможновозможно= READ COMMITTED (PG не имеет dirty read)
READ COMMITTEDнетвозможновозможновозможноsnapshot per statement
REPEATABLE READнетнетнет**возможноsnapshot per transaction (Snapshot Isolation)
SERIALIZABLEнетнетнетнетSSI (Serializable Snapshot Isolation)

* В PostgreSQL грязное чтение невозможно ни на одном уровне. ** В стандарте SQL REPEATABLE READ допускает фантомы, но в PostgreSQL за счёт снимка фантомов нет (это строже стандарта).

Senior-деталь: PostgreSQL REPEATABLE READ — это Snapshot Isolation, которая защищает от фантомов, но не от write skew. От write skew защищает только SERIALIZABLE (SSI), который отслеживает зависимости чтения/записи (predicate locks, SIReadLock) и при обнаружении опасного цикла откатывает одну из транзакций с ошибкой 40001.

-- Write skew: классический пример с дежурствами врачей.
-- Инвариант: всегда хотя бы один врач на дежурстве.
-- На REPEATABLE READ обе транзакции видят "на дежурстве 2 врача" и обе снимают себя → инвариант нарушен.
-- На SERIALIZABLE одна из них упадёт с 40001.
BEGIN ISOLATION LEVEL SERIALIZABLE;
  SELECT count(*) FROM doctors WHERE on_call = true; -- видит 2
  UPDATE doctors SET on_call = false WHERE id = 1;
COMMIT;

Durability — WAL#

WAL (Write-Ahead Log) — принцип «сначала пиши в журнал, потом в данные». Перед тем как изменить страницу данных в shared buffers, PostgreSQL пишет запись об изменении в WAL. При COMMIT происходит fsync WAL-сегмента на диск (если synchronous_commit = on). Даже если сервер упадёт до того, как «грязные» страницы данных будут сброшены на диск (это делает checkpoint), при старте PostgreSQL проиграет WAL (redo / crash recovery) и восстановит изменения.

Ключевые параметры долговечности:

ПараметрЗначениеЭффект
synchronous_commit = onдефолтCOMMIT ждёт fsync WAL → полная durability
synchronous_commit = offCOMMIT не ждёт fsync; при крахе можно потерять последние ~wal_writer_delay мс закоммиченных транзакций, но БД не повредится (consistency сохранится)
fsync = offотключает fsync совсем — НИКОГДА в проде, риск повреждения БД
synchronous_commit = remote_applyдля синхронной репликации: ждать применения на реплике

Senior-деталь: synchronous_commit = off — частый способ ускорить bulk-нагрузку. Он жертвует durability последних транзакций, но НЕ atomicity/consistency. Это компромисс, не баг. fsync = off — другое дело: жертвует и целостностью.

BEGIN / COMMIT / ROLLBACK#

BEGIN;                      -- или START TRANSACTION
  -- ... запросы ...
COMMIT;                     -- зафиксировать
-- или
ROLLBACK;                   -- откатить
  • Вне явного BEGIN каждый statement — это автокоммит (своя неявная транзакция).
  • BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE; — можно задать уровень, режим и DEFERRABLE.
  • READ ONLY DEFERRABLE — особый режим: транзакция дождётся «безопасного» снимка, при котором она гарантированно не упадёт с serialization failure (полезно для долгих отчётов на SERIALIZABLE без риска retry).
  • Если в транзакции произошла ошибка, она переходит в состояние aborted, и все последующие команды отвергаются с 25P02 (in_failed_sql_transaction) до ROLLBACK.

Savepoints и субтранзакции#

SAVEPOINT создаёт точку, к которой можно откатиться, не отменяя всю транзакцию. В PostgreSQL каждый savepoint реализуется как субтранзакция (subtransaction) со своим виртуальным/реальным subxid.

BEGIN;
  INSERT INTO logs(msg) VALUES ('start');
  SAVEPOINT sp1;
    INSERT INTO accounts(id) VALUES (1); -- допустим, упадёт на duplicate key
  ROLLBACK TO SAVEPOINT sp1;             -- откатили только эту часть
  -- транзакция снова в рабочем состоянии, не aborted!
  INSERT INTO logs(msg) VALUES ('recovered');
  RELEASE SAVEPOINT sp1;                 -- "забыть" savepoint (опционально)
COMMIT;
  • SAVEPOINT name — создать точку.
  • ROLLBACK TO SAVEPOINT name — откатить изменения после точки, но транзакция продолжается.
  • RELEASE SAVEPOINT name — убрать savepoint (его субтранзакция «вливается» в родителя), сделанные изменения сохраняются.
  • Вложенность: savepoint внутри savepoint → дерево субтранзакций.

Влияние на производительность (важно для senior):

  1. Назначение реального subxid. Субтранзакция получает настоящий xid только когда она пишет. До этого она «virtual».
  2. SLRU pg_subtrans и порог 64. Каждый бэкенд может закэшировать до 64 субтранзакций (PGPROC_MAX_CACHED_SUBXIDS). При превышении происходит subxid overflow: снимок транзакции помечается как suboverflowed, и проверки видимости вынуждены лезть в pg_subtrans SLRU. Это вызывает lock contention на SubtransSLRULock и резкую деградацию производительности на всём кластере (не только у виновного бэкенда).
  3. Цена abort-а субтранзакции. Откат savepoint требует обработки — отмены lock-ов, очистки и т.п. Частые savepoints в горячем цикле дороги.

Антипаттерн: ORM/драйверы, которые оборачивают каждый запрос в savepoint для эмуляции «вложенных транзакций» или для отказоустойчивости. Под нагрузкой это пробивает порог 64 и кладёт производительность кластера. Это одна из любимых тем на senior+ собеседованиях.

-- Диагностика overflow:
SELECT subxact_overflowed FROM pg_stat_get_activity(pid) ...  -- через мониторинг
-- метрики: pg_stat_slru (name = 'Subtrans'), рост blks_read / blks_hit

Длинные транзакции и их вред#

Это критическая тема для PostgreSQL. Длинная транзакция (особенно idle in transaction) — частая причина деградации продакшена.

Механизм вреда — xmin горизонт:

VACUUM может удалить (vacuum) только те dead tuples (мёртвые версии строк), которые не видны ни одной активной транзакции. Граница видимости — xmin горизонт = самый старый xid среди всех активных транзакций / снимков / replication slots / prepared transactions. Длинная транзакция держит свой снимок, тем самым «замораживает» горизонт на своём xid. Пока она жива:

  • VACUUM (и autovacuum) не может убрать dead tuples, накопившиеся за это время по всему кластеру.
  • Растёт bloat таблиц и индексов — физический размер пухнет, кэш «забивается мусором», производительность падает.
  • Висящие dead tuples заставляют сканировать больше страниц.
  • В крайнем случае — риск приближения к transaction ID wraparound (если горизонт стоит очень долго).

Дополнительные проблемы:

ПроблемаОписание
idle in transactionТранзакция открыта (BEGIN сделан), но приложение ничего не шлёт (часто из-за бизнес-логики/внешнего вызова внутри транзакции). Держит горизонт и блокировки впустую.
Удержание блокировокДолгая транзакция держит row/table locks → конкуренты ждут, копятся в очереди.
Bloat индексовОсобенно больно для часто обновляемых таблиц.
Репликиmax_standby_streaming_delay / конфликты восстановления; долгие запросы на реплике тоже двигают горизонт при hot_standby_feedback = on.

Защитные настройки:

-- Убивать транзакции, висящие в idle in transaction дольше N
SET idle_in_transaction_session_timeout = '30s';
-- Ограничить время выполнения statement
SET statement_timeout = '5s';
-- (PG 17+) ограничить время в idle-сессии вне транзакции
SET idle_session_timeout = '5min';
-- (PG 14+) ограничить время удержания транзакцией снимка / горизонта
SET transaction_timeout = '60s';
-- Поиск долгих/idle транзакций
SELECT pid, state, xact_start, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'active')
  AND xact_start IS NOT NULL
ORDER BY xact_start;

-- Кто держит xmin горизонт
SELECT max(age(backend_xmin)) FROM pg_stat_activity;

Senior-правило: не делать сетевые/внешние вызовы (HTTP, очереди, RPC) внутри открытой транзакции. Выполни всё, что можно, до BEGIN, держи транзакцию максимально короткой. Транзакция должна охватывать только атомарную работу с БД.

Транзакции в Go: database/sql#

Базовый паттерн с BeginTx#

func transfer(ctx context.Context, db *sql.DB, from, to int64, amount int64) (err error) {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelSerializable, // уровень изоляции
        ReadOnly:  false,
    })
    if err != nil {
        return fmt.Errorf("begin: %w", err)
    }
    // КЛЮЧЕВОЙ паттерн: defer Rollback.
    // После успешного Commit Rollback вернёт sql.ErrTxDone — это no-op, безопасно.
    defer func() {
        if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
            // логируем, но не перетираем основную ошибку
            log.Printf("rollback failed: %v", rbErr)
        }
    }()

    if _, err = tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, from); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    if _, err = tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, to); err != nil {
        return fmt.Errorf("credit: %w", err)
    }

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit: %w", err)
    }
    return nil
}

Почему defer tx.Rollback() — правильный паттерн:

  • Гарантирует освобождение соединения при любом раннем return (включая panic, если обёрнуто).
  • После Commit() транзакция завершена; Rollback() вернёт sql.ErrTxDone, который мы игнорируем. Это идиоматично.
  • Без defer Rollback при ошибке посередине транзакция «протечёт»: соединение из пула останется занятым с открытой транзакцией → idle in transaction на стороне PG.

sql.TxOptions:

ПолеТипНазначение
Isolationsql.IsolationLevelLevelReadCommitted (дефолт PG), LevelRepeatableRead, LevelSerializable и др. Не все драйверы поддерживают все уровни.
ReadOnlyboolтранслируется в READ ONLY — PG может оптимизировать и запрещает запись

Контекст и отмена:

  • Все методы должны быть ...Context (ExecContext, QueryContext, QueryRowContext).
  • Если ctx отменяется/истекает таймаут — database/sql пытается отменить запрос (через pgx/драйвер отправляется cancel-запрос на сервер) и откатывает транзакцию.
  • Подводный камень: все запросы транзакции должны использовать один и тот же ctx, привязанный к жизни транзакции. Нельзя выполнять запрос на tx после отмены ctx.
  • Внутри транзакции работает одно физическое соединениеsql.Tx «прибивает» соединение из пула на всё время транзакции (важно для расчёта SetMaxOpenConns).

database/sql пул и транзакции#

  • db.SetMaxOpenConns(n) — максимум открытых соединений. Каждая активная sql.Tx занимает одно — длинные транзакции исчерпывают пул.
  • db.SetConnMaxLifetime / SetConnMaxIdleTime — ротация соединений.
  • Запросы на одном tx сериализуются — нельзя гонять их параллельно из разных горутин на одном *sql.Tx.

Транзакции в Go: pgx#

pgx — нативный драйвер PostgreSQL (быстрее, чем через database/sql, поддерживает больше типов и protocol-level фичи). Используется либо напрямую (pgx.Conn/pgxpool.Pool), либо как драйвер для database/sql (stdlib).

pgxpool + транзакция#

import (
    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

pool, err := pgxpool.New(ctx, "postgres://user:pass@host:5432/db?pool_max_conns=20")
// ...
defer pool.Close()

func transfer(ctx context.Context, pool *pgxpool.Pool, from, to, amount int64) error {
    tx, err := pool.BeginTx(ctx, pgx.TxOptions{
        IsoLevel:   pgx.Serializable,
        AccessMode: pgx.ReadWrite,
    })
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx) // после Commit — no-op (pgx.ErrTxClosed)

    if _, err = tx.Exec(ctx,
        `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, from); err != nil {
        return err
    }
    if _, err = tx.Exec(ctx,
        `UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, to); err != nil {
        return err
    }
    return tx.Commit(ctx)
}

pgx.BeginFunc — безопасная обёртка#

pgx предоставляет helper, который сам делает Commit/Rollback: если функция вернула ошибку → Rollback, иначе → Commit. Меньше шансов забыть откат.

err := pgx.BeginTxFunc(ctx, pool, pgx.TxOptions{IsoLevel: pgx.Serializable},
    func(tx pgx.Tx) error {
        if _, err := tx.Exec(ctx, `UPDATE ...`); err != nil {
            return err // -> Rollback
        }
        return nil // -> Commit
    })

pgx.Tx поддерживает вложенные транзакции через savepoints прозрачно: tx.Begin(ctx) на уже открытой tx создаёт SAVEPOINT (помни про порог 64!).

Особенностьdatabase/sqlpgx (native)
Уровни изоляцииsql.TxOptions{Isolation, ReadOnly}pgx.TxOptions{IsoLevel, AccessMode, DeferrableMode}
Helper-обёртканет (пишешь сам defer Rollback)pgx.BeginTxFunc
Вложенные txчерез ручной SAVEPOINTtx.Begin() → savepoint автоматически
Batch / COPYограниченноtx.SendBatch, tx.CopyFrom
Производительность+ слой database/sqlнативный wire protocol, prepared statement cache

Retry на serialization failure (40001) и deadlock (40P01)#

При SERIALIZABLE (а deadlock — на любом уровне) транзакция легально может быть отклонена сервером. Приложение обязано уметь её повторить. Это не ошибка кода, а контракт SERIALIZABLE.

  • 40001 serialization_failure — SSI обнаружил опасный цикл зависимостей или write skew → откатил транзакцию.
  • 40P01 deadlock_detected — взаимная блокировка, detector выбрал жертву.

Оба класса начинаются с 40 (SQLSTATE class 40 — Transaction Rollback) и являются transient — повтор с большой вероятностью пройдёт.

Важно: повторять надо всю транзакцию целиком (с нуля, BEGIN ... COMMIT), а не отдельный statement — снимок надо взять заново. Поэтому код транзакции оформляют как идемпотентную замыкание-функцию.

import (
    "context"
    "errors"
    "math/rand"
    "time"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgconn"
    "github.com/jackc/pgx/v5/pgxpool"
)

// isRetryable проверяет SQLSTATE: 40001 (serialization) и 40P01 (deadlock).
func isRetryable(err error) bool {
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) {
        switch pgErr.Code {
        case "40001", "40P01":
            return true
        }
    }
    return false
}

// runSerializable повторяет транзакцию с экспоненциальным backoff + jitter.
func runSerializable(
    ctx context.Context,
    pool *pgxpool.Pool,
    maxAttempts int,
    fn func(tx pgx.Tx) error,
) error {
    const baseDelay = 5 * time.Millisecond
    const maxDelay = 1 * time.Second

    var lastErr error
    for attempt := 0; attempt < maxAttempts; attempt++ {
        // Проверяем контекст перед каждой попыткой.
        if err := ctx.Err(); err != nil {
            return err
        }

        err := pgx.BeginTxFunc(ctx, pool,
            pgx.TxOptions{IsoLevel: pgx.Serializable}, fn)
        if err == nil {
            return nil // успех
        }
        lastErr = err

        if !isRetryable(err) {
            return err // не transient — нет смысла повторять
        }

        // Экспоненциальный backoff: base * 2^attempt, с потолком.
        backoff := baseDelay * (1 << attempt)
        if backoff > maxDelay {
            backoff = maxDelay
        }
        // Full jitter: случайная задержка в [0, backoff) — снижает thundering herd.
        jittered := time.Duration(rand.Int63n(int64(backoff) + 1))

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(jittered):
            // повторяем
        }
    }
    return fmt.Errorf("transaction failed after %d attempts: %w", maxAttempts, lastErr)
}

Использование:

err := runSerializable(ctx, pool, 5, func(tx pgx.Tx) error {
    var balance int64
    if err := tx.QueryRow(ctx,
        `SELECT balance FROM accounts WHERE id = $1`, fromID).Scan(&balance); err != nil {
        return err
    }
    if balance < amount {
        return errors.New("insufficient funds") // НЕ retryable — выйдет сразу
    }
    if _, err := tx.Exec(ctx,
        `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, fromID); err != nil {
        return err
    }
    _, err := tx.Exec(ctx,
        `UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, toID)
    return err
})

Нюансы retry, которые ждут на senior:

  • Транзакционная функция должна быть идемпотентной/чистой: её повторят. Никаких побочных эффектов вне БД (отправка email, инкремент внешнего счётчика) до коммита.
  • Ограничивать maxAttempts — иначе при постоянной contention бесконечный retry усилит нагрузку.
  • Jitter обязателен — без него все конкурирующие транзакции повторятся одновременно и снова столкнутся (thundering herd).
  • Различать retryable (40001/40P01) и не-retryable ошибки (23505 unique_violation, бизнес-ошибки) — повторять только первые.
  • На READ COMMITTED тоже бывают deadlock (40P01) — retry полезен и там, но 40001 практически только на REPEATABLE READ/SERIALIZABLE.
  • Метрики: считать число retry — рост сигнализирует о hot-spot contention, который, возможно, лучше решить иначе (изменить порядок доступа, SELECT ... FOR UPDATE, advisory locks).

Подводные камни / gotchas#

  • Забыли defer tx.Rollback() → при ошибке посередине соединение «утекает» с открытой транзакцией → idle in transaction в PG + исчерпание пула. Самый частый баг.
  • Внешний вызов (HTTP/RPC) внутри транзакции → транзакция держится секундами/минутами → замораживает xmin горизонт → bloat по всему кластеру.
  • SAVEPOINT на каждый запрос (ORM-эмуляция nested tx) → пробивает порог 64 субтранзакций → SubtransSLRULock contention → деградация ВСЕГО кластера.
  • Чтение после ошибки без savepoint: после любой ошибки транзакция в PG переходит в aborted; все запросы дают 25P02, пока не ROLLBACK (или ROLLBACK TO SAVEPOINT).
  • Параллельные запросы на одном *sql.Tx/pgx.Tx из разных горутин — гонка, одно физическое соединение. Нельзя.
  • SERIALIZABLE без retry → приложение падает на 40001 «на ровном месте» под нагрузкой. SERIALIZABLE требует retry-логики by design.
  • READ COMMITTED и «потерянное обновление»: UPDATE accounts SET balance = balance - 100 безопасен (PG перечитает строку), но read-modify-write в коде (SELECT → вычислить в Go → UPDATE) на READ COMMITTED теряет апдейты. Нужен SELECT ... FOR UPDATE или SERIALIZABLE.
  • ReadOnly: true не делает реплику — это просто READ ONLY режим транзакции на том же узле; роутинг на реплику — отдельная задача (на уровне connection string / proxy).
  • synchronous_commit = off ≠ потеря целостности, но потеря последних коммитов при крахе. Частая путаница на собеседовании.
  • Context cancel в середине транзакции: запрос отменится, транзакция должна откатиться; но если ctx уже мёртв, сам tx.Rollback(ctx) может не пройти — используйте context.Background() или короткий отдельный ctx для финального rollback в edge-кейсах.

Вопросы на собеседовании#

В: Как PostgreSQL обеспечивает Durability и что произойдёт при крахе сервера сразу после COMMIT? О: Через WAL: перед изменением страниц данных пишется журнальная запись, при COMMITsynchronous_commit = on) делается fsync WAL на диск. Если сервер упадёт до сброса грязных страниц данных, при старте выполнится crash recovery (redo) — WAL проигрывается и зафиксированные изменения восстанавливаются. Закоммиченная транзакция не потеряется.

В: Чем REPEATABLE READ в PostgreSQL отличается от стандарта SQL и от SERIALIZABLE? О: В стандарте RR допускает фантомы — в PG за счёт снимка на всю транзакцию (Snapshot Isolation) фантомов нет, это строже стандарта. Но RR не защищает от write skew. От него защищает только SERIALIZABLE через SSI: он отслеживает зависимости чтение/запись (predicate locks) и при опасном цикле откатывает транзакцию с 40001.

В: Почему длинная транзакция — это проблема, даже если она просто idle in transaction и ничего не делает? О: Она держит свой снимок и тем самым xmin горизонт на своём xid. VACUUM не может удалить dead tuples, которые «новее» горизонта, по всему кластеру → накапливается bloat таблиц и индексов, растёт размер, падает производительность, в пределе — риск приближения к wraparound. Плюс может держать блокировки. Лечится idle_in_transaction_session_timeout, transaction_timeout и архитектурно — короткими транзакциями без внешних вызовов.

В: Что такое субтранзакции и почему «savepoint на каждый запрос» опасен? О: Каждый SAVEPOINT — это субтранзакция со своим subxid. Бэкенд кэширует до 64 субтранзакций; при превышении снимок помечается suboverflowed, и проверки видимости начинают лезть в pg_subtrans SLRU, вызывая contention на SubtransSLRULock. Это деградирует производительность всего кластера, а не только виновного соединения.

В: Объясните паттерн defer tx.Rollback() — не сломает ли он успешный коммит? О: Нет. После Commit() транзакция завершена, и Rollback() вернёт sql.ErrTxDone (в pgx — pgx.ErrTxClosed), который мы игнорируем — это no-op. Паттерн гарантирует, что при любом раннем return/ошибке/панике транзакция откатится и соединение вернётся в пул, предотвращая утечку idle in transaction.

В: Когда транзакция может упасть с 40001/40P01 и что с этим делать? О: 40001 (serialization_failure) — на REPEATABLE READ/SERIALIZABLE при конфликте сериализации/write skew; 40P01 (deadlock_detected) — на любом уровне при взаимной блокировке. Это transient-ошибки класса 40. Нужно повторять всю транзакцию целиком с экспоненциальным backoff и jitter, ограничив число попыток. Транзакционная функция должна быть идемпотентной (её повторят).

В: Чем pgx (native) отличается от использования его через database/sql? О: Native pgx использует бинарный wire-протокол напрямую: быстрее, богаче типы (массивы, json, hstore, range), есть CopyFrom, SendBatch, кэш prepared statements, helper BeginTxFunc, вложенные транзакции через tx.Begin() (savepoints). Через database/sql (stdlib) теряется часть фич ради совместимости со стандартным интерфейсом, но получаем единый API и совместимость с инструментами.

В: На READ COMMITTED у вас «теряются» обновления баланса при конкуренции. Почему и как чинить? О: Если делается read-modify-write (SELECT balance → вычисление в коде → UPDATE balance = $new), две транзакции читают одно значение и затирают друг друга — lost update. Чинится либо атомарным UPDATE ... SET balance = balance - $1 (PG перечитает актуальную строку), либо SELECT ... FOR UPDATE (блокировка строки), либо переходом на SERIALIZABLE с retry.

В: Что именно делает synchronous_commit = off и безопасно ли это? О: COMMIT перестаёт ждать fsync WAL — отвечает клиенту сразу. При крахе можно потерять несколько последних закоммиченных транзакций (до wal_writer_delay), НО база останется консистентной и не повредится — теряется только durability хвоста. Это легитимный компромисс для throughput. В отличие от fsync = off, который рискует повреждением БД и недопустим в проде.


На что копают на senior+#

  • MVCC внутри: структура снимка (xmin/xmax/xip), t_xmin/t_xmax/t_cid/ctid в кортежах, как hint bits ускоряют проверку видимости, роль pg_xact (clog). Что такое HOT-updates и как они снижают bloat.
  • SSI устройство: predicate locks (SIReadLock), отслеживание rw-зависимостей, dangerous structures (rw-conflict in + out), почему false positive serialization failures возможны.
  • Subtransaction overflow в деталях: PGPROC_MAX_CACHED_SUBXIDS = 64, suboverflowed снимок, эффект на всех бэкендах, мониторинг через pg_stat_slru, связь с replication (subtrans на репликах).
  • xmin горизонт со всех источников: не только активные транзакции, но и replication slots (неактивный слот замораживает горизонт навсегда), prepared transactions (2PC), hot_standby_feedback. Диагностика через pg_stat_activity.backend_xmin, pg_replication_slots.
  • Transaction ID wraparound и freezing: autovacuum_freeze_max_age, aggressive vacuum, опасность «висящего» горизонта, отказ кластера в read-only при приближении wraparound.
  • 2PC (PREPARE TRANSACTION): распределённые транзакции, висящие prepared-транзакции как причина зависшего горизонта, max_prepared_transactions.
  • Connection pooling и транзакции: transaction-pooling режим PgBouncer ломает session-level фичи (prepared statements, SET, advisory locks по сессии) — как это взаимодействует с pgx/database/sql пулом.
  • Lock vs MVCC: когда нужны явные блокировки (SELECT FOR UPDATE/SHARE, SKIP LOCKED для очередей, advisory locks), NOWAIT, эскалация и lock queue в PG (PG не эскалирует row→table locks, в отличие от SQL Server).
  • Идемпотентность и saga vs 2PC для распределённых бизнес-транзакций, outbox-паттерн как замена транзакций между БД и брокером сообщений.
  • Точная семантика retry: почему повторять надо транзакцию целиком, почему jitter обязателен, как считать budget попыток, и когда contention лечится архитектурно (изменение порядка lock-ов, шардирование hot-row) вместо retry.