Транзакции в PostgreSQL и Go (database/sql, pgx)#
Модуль: Базы данных · Уровень: Senior
TL;DR#
- ACID = Atomicity (всё или ничего, реализуется через WAL и откат), Consistency (инварианты БД: constraints, triggers, FK), Isolation (параллельные транзакции не мешают друг другу — в PostgreSQL через MVCC + уровни изоляции), Durability (после
COMMITданные переживут крах — гарантируется WAL +fsync). - PostgreSQL не имеет настоящих вложенных транзакций —
SAVEPOINTсоздаёт субтранзакции (получают свойsubxid), которые дёшевы в небольшом количестве, но при >64 на бэкенд вызывают SLRU overflow и резкую деградацию (subtransSLRU misses). - Длинные транзакции — главное зло в PostgreSQL: удерживают
xminгоризонт →VACUUMне может удалить dead tuples → bloat, распухание индексов, деградация.idle in transactionособенно опасен. - В Go: всегда
BeginTx(ctx, opts), паттернdefer tx.Rollback()(послеCommitэто no-op), не забыватьctxдля отмены. Вpgx—pgxpool+tx.Begin/pgx.BeginFunc. - На уровне
SERIALIZABLE(иREPEATABLE READпри write skew конфликтах) транзакция может упасть с 40001 (serialization_failure) или 40P01 (deadlock_detected) — это нормально и ожидаемо, надо реализовать retry с экспоненциальным backoff.
Простыми словами#
Транзакция — группа операций, которая выполняется целиком или не выполняется вовсе.
Классический пример — перевод денег: списание с одного счёта и зачисление на другой обязаны случиться вместе. Свойства, которые это обеспечивают, прячутся за аббревиатурой ACID: атомарность (всё или ничего), согласованность (ограничения не нарушаются), изолированность (параллельные транзакции не мешают друг другу), долговечность (после подтверждения данные переживут отключение питания).
Что действительно спрашивают на senior-собеседовании:
- длинные транзакции — главное зло PostgreSQL. Пока транзакция открыта, база обязана хранить все версии строк, которые она может увидеть. Уборщик не может удалить мусор, таблицы распухают, запросы замедляются. Особенно опасно состояние
idle in transaction— когда транзакцию открыли и ушли думать; - ошибка
40001— это нормально. На строгих уровнях изоляции база вправе откатить транзакцию из-за конфликта, и приложение обязано её повторить. Ретрай с экспоненциальной задержкой — обычная часть кода, а не признак поломки.
В Go шаблон один: BeginTx(ctx, ...), следом defer tx.Rollback() — после успешного Commit откат уже ничего не делает, поэтому такая пара безопасна и защищает от забытого отката на ранних return.
Если ты с другого языка. Если привык к @Transactional в Spring, готовься писать границы транзакции руками. Плюс в том, что видно, где транзакция началась и закончилась; минус — забыть откат легче, отсюда и обязательный defer.
Словарь этой статьи:
- SAVEPOINT — точка внутри транзакции, к которой можно откатиться, не отменяя всё;
40001/40P01— коды ошибок «конфликт сериализации» и «обнаружен дедлок»; оба лечатся повтором;- горизонт xmin — граница, до которой уборщик имеет право удалять старые версии строк; открытая транзакция держит её на месте.
Теория#
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 read | Non-repeatable read | Phantom read | Write 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 = off | COMMIT не ждёт 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):
- Назначение реального
subxid. Субтранзакция получает настоящий xid только когда она пишет. До этого она «virtual». - SLRU
pg_subtransи порог 64. Каждый бэкенд может закэшировать до 64 субтранзакций (PGPROC_MAX_CACHED_SUBXIDS). При превышении происходит subxid overflow: снимок транзакции помечается какsuboverflowed, и проверки видимости вынуждены лезть вpg_subtransSLRU. Это вызывает lock contention наSubtransSLRULockи резкую деградацию производительности на всём кластере (не только у виновного бэкенда). - Цена 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:
| Поле | Тип | Назначение |
|---|---|---|
Isolation | sql.IsolationLevel | LevelReadCommitted (дефолт PG), LevelRepeatableRead, LevelSerializable и др. Не все драйверы поддерживают все уровни. |
ReadOnly | bool | транслируется в 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/sql | pgx (native) |
|---|---|---|
| Уровни изоляции | sql.TxOptions{Isolation, ReadOnly} | pgx.TxOptions{IsoLevel, AccessMode, DeferrableMode} |
| Helper-обёртка | нет (пишешь сам defer Rollback) | pgx.BeginTxFunc |
| Вложенные tx | через ручной SAVEPOINT | tx.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 субтранзакций →SubtransSLRULockcontention → деградация ВСЕГО кластера.- Чтение после ошибки без 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: перед изменением страниц данных пишется журнальная запись, при COMMIT (с synchronous_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.