Модуль: Базы данных · Уровень: 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 подробно#
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.