voidlearn
 

6.2Транзакции, ACID, уровни изоляции

Посмотри на транзакцию как на способ сохранить бизнес-инвариант при параллельной работе запросов. Разбери аномалии через порядок операций, сопоставь их с уровнями изоляции PostgreSQL и пойми, зачем приложению обрабатывать конфликты и дедлоки.

Транзакция - это не «BEGIN/COMMIT»

Транзакцией называют единицу работы, которая переводит базу из одного согласованного состояния в другое и для внешнего мира выглядит атомарной и одиночной. Вся соль в двух словах: «атомарной» (либо вся, либо никакой) и «одиночной» (как будто других транзакций в этот момент не было). Остальное относится к механике: как СУБД поддерживает эту иллюзию, хотя на деле транзакции идут не по одной.

Выполнять транзакции строго по одной было бы надёжно, но чудовищно медленно: параллельно работают сотни соединений. Поэтому база чередует их операции и создаёт иллюзию, будто каждая работала в одиночестве. Иллюзия неполная, и чем она полнее, тем дороже обходится. Уровни изоляции как раз и отмеряют, насколько тщательно база её поддерживает.

ACID на сквозном примере: перевод 100 рублей

BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;   -- списали у Ани
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;   -- зачислили Боре
  INSERT INTO transfers(from_id, to_id, amount) VALUES (1, 2, 100);
COMMIT;
Перевод 100 рублей: что даёт каждая буква ACID A - Atomicity оба UPDATE и INSERT применяются либо все, либо ни один без неё: списали и упали C - Consistency инварианты соблюдены: CHECK balance >= 0, FK, сумма денег в системе без неё: баланс ушёл в минус I - Isolation параллельный перевод не увидит промежуточное состояние «списали, не зачли» без неё: отчёт на 100 руб. меньше D - Durability после ответа «OK» перевод переживёт выключение питания (fsync WAL) без неё: клиенту сказали да, денег нет Что физически обеспечивает каждую букву в PostgreSQL A - WAL + правило «изменения видны только после записи о COMMIT»; откат = никто не видит новые версии строк. C - это НЕ работа СУБД в чистом виде: половину даёт движок (констрейнты, FK, типы), половину - прикладной код. I - MVCC: снапшот + xmin/xmax дают чтению «своё» состояние базы; для записи - блокировки строк и SSI. D - fsync журнала до ответа клиенту; параметры synchronous_commit, wal_sync_method, синхронная реплика.
ACID не четыре равноправные буквы. За A, I и D стоят реальные механизмы движка (журнал, снапшоты, fsync). C движок только помогает поддерживать: если в коде списали, но не зачислили, СУБД честно и атомарно сохранит несогласованное состояние.
Ответ, который отличает мидла от джуна

«Спорнее всего в ACID буква C. Atomicity, Isolation и Durability обеспечивают конкретные механизмы СУБД. А Consistency описывает прикладной инвариант: “сумма денег в системе не меняется при переводе”. СУБД проверит только то, что ей объявили: типы, NOT NULL, CHECK, FK. Если разработчик написал только первый UPDATE, транзакция будет атомарной, изолированной и долговечной - и при этом оставит базу в бизнес-несогласованном состоянии.» Дальше можно добавить, что в теореме CAP буква C означает совсем другое (линеаризуемость), и путать их не надо.

Аномалии конкурентного доступа

Аномалией называют результат, который нельзя получить ни при каком последовательном выполнении тех же транзакций. Проверка простая: мысленно прогони две транзакции строго по очереди, сначала T1, потом T2, и наоборот; если фактический результат не совпал ни с одним из вариантов, это аномалия. Ниже пять классических аномалий, каждая на временной диаграмме двух транзакций. Читай диаграммы слева направо: по горизонтали время, две строки отданы параллельным транзакциям T1 и T2, прямоугольник обозначает операцию, которую та или другая выполняет в этот момент.

1. Грязное чтение (dirty read): прочитали то, чего в итоге не было T1 UPDATE accounts bal = bal - 100 ROLLBACK изменения отменены T2 SELECT bal видит списание решение принято по данным, которых нет Запрещено на Read Committed и выше. В PostgreSQL невозможно ни на одном уровне: незакоммиченные версии не видны никому. 2. Неповторяющееся чтение (non-repeatable read): один и тот же SELECT дал разные значения T1 SELECT bal → 500 начало отчёта SELECT bal → 400 то же условие, другой ответ T2 UPDATE bal = 400 COMMIT Запрещено на Repeatable Read и выше. Ломает любой отчёт из нескольких запросов: итог не сходится с деталями. время →
Чтение чужих изменений. Аномалии 1 и 2 различаются тем, закоммичена ли чужая транзакция. При грязном чтении прочитали незакоммиченное, при неповторяющемся прочитали дважды, и между чтениями кто-то успел закоммитить.
3. Фантомное чтение (phantom read): повторный запрос вернул НОВЫЕ строки T1 SELECT count(*) WHERE dept = 5 → 10 тот же SELECT → 11 появилась строка-фантом T2 INSERT ... dept = 5 COMMIT Отличие от неповторяющегося чтения: там изменилась прочитанная строка, здесь изменился НАБОР строк под предикатом. В PostgreSQL на Repeatable Read фантомов нет: снапшот транзакции не увидит строку, закоммиченную позже его старта. 4. Потерянное обновление (lost update): read-modify-write двух транзакций T1 SELECT qty → 100 UPDATE qty = 150 COMMIT T2 SELECT qty → 100 UPDATE qty = 130 +50 от T1 потеряны Лечение: UPDATE qty = qty + 50 (атомарно в БД), либо SELECT FOR UPDATE, либо version-колонка. Уровень изоляции сам по себе не спасает.
Фантомы и потерянные обновления. Потерянное обновление опасно тем, что не проявляется как ошибка: обе транзакции завершились успешно, просто одно из изменений бесследно исчезло. Его чаще всего и встречаешь в реальном коде вида «прочитал в Go, посчитал, записал».
5. Аномалия записи (write skew): каждый читает, каждый пишет - но в разные строки Инвариант: на дежурстве должен остаться минимум один врач. Сейчас дежурят двое: Аня и Боря. T1 SELECT count(*) → 2 «можно уйти» UPDATE Аня: off COMMIT T2 SELECT count(*) → 2 «можно уйти» UPDATE Боря: off COMMIT Ни одна транзакция не изменила строку, которую прочитала другая - конфликта записи НЕТ, обе коммитятся. Результат: дежурных ноль. Инвариант нарушен. Repeatable Read это НЕ ловит - нужен SERIALIZABLE (SSI) или явная блокировка.
Write skew. Самая коварная аномалия. Чужую запись здесь никто не перетёр, просто «мы оба приняли решение по снимку, который перестал быть верным». Её не закрывает Repeatable Read, и как раз ради неё существует Serializable Snapshot Isolation.

Уровни изоляции по стандарту SQL

УровеньГрязное чтениеНеповторяющееся чтениеФантомыПотерянное обновлениеWrite skew
READ UNCOMMITTEDвозможновозможновозможновозможновозможно
READ COMMITTEDнетвозможновозможновозможновозможно
REPEATABLE READнетнетпо стандарту возможно, в PG нетв PG нет (ошибка 40001)возможно
SERIALIZABLEнетнетнетнетнет
Уровни в PostgreSQL: три вместо четырёх

PostgreSQL реализует изоляцию через MVCC-снапшоты, а не через блокировки чтения, поэтому таблица стандарта на него ложится иначе:

  • READ UNCOMMITTED физически = READ COMMITTED. Уровень принимается синтаксически, но грязное чтение невозможно в принципе: незакоммиченная версия строки не видна никому, потому что её xmin принадлежит активной транзакции.
  • READ COMMITTED берёт новый снапшот на каждый оператор. Отсюда неповторяющееся чтение и фантомы между операторами одной транзакции.
  • На REPEATABLE READ снапшот один на всю транзакцию, его берут при первом операторе. Отсюда бесплатно пропадают неповторяющееся чтение и даже фантомы, которые стандарт разрешает: уровень выходит строже стандарта. Взамен появляется could not serialize access due to concurrent update, если ты пытаешься обновить строку, изменённую после твоего снапшота.
  • SERIALIZABLE реализован через SSI (Serializable Snapshot Isolation): движок отслеживает зависимости чтение-запись между транзакциями и, обнаружив «опасную структуру» из двух подряд идущих rw-конфликтов, откатывает одну из транзакций с ошибкой сериализации. Блокировок чтения по-прежнему нет - платишь не ожиданием, а вероятностью отката.
Подвох Read Committed в PostgreSQL

На Read Committed UPDATE ... WHERE ведёт себя хитро. Если строку поменяла другая транзакция и успела закоммититься, пока мы ждали блокировку, PostgreSQL не откатывает нас, а перечитывает строку заново (EvalPlanQual) и повторно проверяет на ней условие WHERE. То есть один оператор может работать с «более свежими» данными, чем его собственный снапшот. Следствие: UPDATE t SET x = x + 1 на Read Committed безопасен и не теряет обновления, а вот «прочитал SELECTом, посчитал в Go, записал UPDATEом» - теряет. Здесь и проходит граница, за которой нужны FOR UPDATE или version-колонка.

СУБДУровень по умолчаниюЧем реализовано
PostgreSQLREAD COMMITTEDснапшот на каждый оператор, MVCC в heap
MySQL / InnoDBREPEATABLE READconsistent read из undo-лога + gap locks и next-key locks против фантомов
OracleREAD COMMITTEDundo-сегменты; SERIALIZABLE есть, но это snapshot isolation, а не настоящая сериализуемость
SQL ServerREAD COMMITTED (блокировочный)по умолчанию блокировки, опционально READ_COMMITTED_SNAPSHOT

Ошибка сериализации и обязательный ретрай

Как только ты вышел с Read Committed на Repeatable Read или Serializable, приложение получает новый класс ошибок: SQLSTATE 40001 serialization_failure. Туда же обычно добавляют 40P01 deadlock_detected: дедлок бывает и на Read Committed, но лечится тем же повтором. Это не баги и не сбои. Так PostgreSQL штатно сообщает: «я не смог выполнить твою транзакцию так, будто она была одна; повтори её». Транзакция откачена целиком, база согласована, повтор корректен.

// Ретраим транзакцию с экспоненциальной паузой и джиттером.
// Обязателен для Repeatable Read и Serializable.
func WithRetry(ctx context.Context, db *pgxpool.Pool, fn func(pgx.Tx) error) error {
    const maxAttempts = 5
    var lastErr error

    for attempt := 0; attempt < maxAttempts; attempt++ {
        tx, err := db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
        if err != nil {
            return err
        }

        err = fn(tx)
        if err == nil {
            if err = tx.Commit(ctx); err == nil {
                return nil // получилось
            }
        }
        _ = tx.Rollback(ctx) // после Commit это no-op, вызывать безопасно

        if !isRetryable(err) {
            return err // бизнес-ошибка или нарушение констрейнта: повтор не поможет
        }
        lastErr = err

        // Пауза со случайным разбросом: без джиттера конкуренты
        // синхронно повторят конфликт и снова столкнутся.
        backoff := time.Duration(1<<attempt) * 10 * time.Millisecond
        jitter := time.Duration(rand.Int63n(int64(backoff)))
        select {
        case <-time.After(backoff + jitter):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return fmt.Errorf("транзакция не удалась за %d попыток: %w", maxAttempts, lastErr)
}

func isRetryable(err error) bool {
    var pgErr *pgconn.PgError
    if !errors.As(err, &pgErr) {
        return false
    }
    switch pgErr.Code {
    case "40001": // serialization_failure
        return true
    case "40P01": // deadlock_detected
        return true
    }
    return false
}
Три правила ретрая, о которых спрашивают

1) Повторять надо всю транзакцию с самого начала, а не последний запрос: снапшот уже мёртв. 2) Функция fn обязана быть идемпотентной по побочным эффектам. Внутри неё нельзя отправлять письма, списывать деньги во внешнем API и писать в Kafka: при повторе это произойдёт дважды. Побочные эффекты запускай только после успешного коммита (или через outbox). 3) Джиттер обязателен: без него N конкурентов синхронно повторят конфликт, снова его получат, и выйдет живой лок на ровном месте.

Явные блокировки: FOR UPDATE, FOR SHARE, SKIP LOCKED, NOWAIT

Блокировка (lock, «замок») работает как пометка «этот объект сейчас занят мной»: транзакция вешает её на строку или на таблицу и держит до своего конца. Другая транзакция, которой этот объект нужен в несовместимом режиме, встаёт и ждёт, пока первая не завершится. Блокировка нужна не для безопасности данных, а для очереди: параллельный доступ становится последовательным там, где параллельность даёт неверный результат.

SELECT ... FOR UPDATE берёт на прочитанные строки эксклюзивную блокировку строки до конца транзакции: другие транзакции не смогут ни обновить, ни удалить, ни взять на них FOR UPDATE. Так паттерн «прочитал - посчитал - записал» становится безопасным: между чтением и записью строку никто не тронет. Обычные SELECT при этом не блокируются - читатели в PostgreSQL никогда не ждут писателей.

ФормаЧто блокируетТипичный сценарий
FOR UPDATEполная эксклюзивная блокировка строкиread-modify-write: списание баланса, бронирование
FOR NO KEY UPDATEслабее: разрешает FOR KEY SHAREто, что берётся неявно при обычном UPDATE неключевых колонок
FOR SHAREразделяемая: читать можно всем, менять нельзя«эта строка не должна исчезнуть, пока я работаю»
FOR KEY SHAREслабейшая: запрещает только удаление и смену ключаберётся неявно при проверке FK
... NOWAITне ждать, сразу ошибка 55P03интерактивная операция: лучше сказать «занято», чем висеть
... SKIP LOCKEDмолча пропустить занятые строкиочередь на таблице: каждый воркер берёт свою пачку
Очередь на таблице: FOR UPDATE SKIP LOCKED Три воркера одновременно выполняют один и тот же запрос - и не мешают друг другу. tasks (status = new) id status 1 new 2 new 3 new 4 new 5 new Воркер A заблокировал строку 1 Воркер B строка 1 занята → пропустил, Воркер C пропустил 1 и 2, взял 3 взял 2 Без SKIP LOCKED B и C встают в очередь на строку 1 и ждут - параллелизма нет вообще Почему это лучше брокера не всегда + транзакционно с бизнес-данными + не нужен ещё один сервис - нагрузка на БД, bloat от UPDATE статуса - на десятках тысяч задач/с уже не тянет
SKIP LOCKED. Эта конструкция превращает обычную таблицу в рабочую очередь с конкурентными воркерами - без брокера и без гонок. До PostgreSQL 9.5 приходилось изобретать advisory-локи и «взять случайную строку», а сейчас хватает двух строчек SQL.
-- Очередь: воркер атомарно забирает пачку задач и помечает их своими
UPDATE tasks
SET    status = 'processing', locked_by = $1, locked_at = now()
WHERE  id IN (
    SELECT id FROM tasks
    WHERE  status = 'new' AND run_after <= now()
    ORDER  BY priority DESC, id
    LIMIT  10
    FOR UPDATE SKIP LOCKED          -- занятые строки пропускаем
)
RETURNING id, payload;

-- Read-modify-write под блокировкой: списание с проверкой остатка
BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;   -- ждём, если строка занята
  -- проверили в приложении, что хватает
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- Не висеть на занятой строке в интерактивном сценарии
SELECT * FROM documents WHERE id = 42 FOR UPDATE NOWAIT;  -- ошибка 55P03, если занято

Оптимистичные и пессимистичные блокировки

Пессимистичная - «конфликт будет, займу заранее». SELECT FOR UPDATE.

BEGIN;
SELECT qty FROM stock
 WHERE id = 7 FOR UPDATE;   -- конкурент ждёт
UPDATE stock SET qty = qty - 1
 WHERE id = 7;
COMMIT;

Плюсы: операция гарантированно пройдёт с первого раза, логика простая. Минусы: конкуренты стоят в очереди, растёт время удержания блокировки, возможны дедлоки, плохо работает через «долгий» бизнес-процесс с участием пользователя.

Оптимистичная - «конфликт маловероятен, проверю при записи». Version-колонка.

-- прочитали: qty = 100, version = 7
UPDATE stock
   SET qty = 99, version = version + 1
 WHERE id = 7 AND version = 7;
-- RowsAffected = 0 → кто-то опередил,
-- перечитать и повторить бизнес-логику

Плюсы: никто никого не ждёт, работает через долгий диалог с пользователем и через stateless-сервисы. Минусы: при высокой конкуренции много повторов, и повтор надо уметь делать корректно.

Правило выбора

Если конкуренция за одну и ту же строку высокая и короткая (списание с популярного счёта, остаток товара на распродаже), бери пессимистичную: оптимистичная выродится в бесконечные ретраи. Если конкуренция редкая или транзакция длинная (пользователь открыл форму редактирования и думает 5 минут), бери оптимистичную: держать блокировку строки на время раздумий человека нельзя никогда. Часто лучше обоих третий вариант: переписать в атомарный SQL, например UPDATE stock SET qty = qty - 1 WHERE id = 7 AND qty >= 1. Тогда нет ни блокировок, ни версий, а проверка RowsAffected == 0 заменяет всю логику конфликта.

Блокировки в PostgreSQL: уровни и конфликты

Блокировки в PG бывают трёх сортов. Табличные (8 режимов) берутся автоматически под каждую команду. Строчные (4 режима) хранятся не в отдельной таблице замков в памяти, а прямо в служебном заголовке самой строки на диске и в multixact, поэтому память под них не кончается, сколько строк ни заблокируй. Бесплатными они от этого не становятся: каждая блокировка пишется в заголовок строки, а значит, грязнит страницу и попадает в WAL. Замки advisory пользователь вешает сам по произвольному числовому ключу, и ни к строке, ни к таблице они не привязаны. Пригодятся, когда синхронизировать нужно не данные, а действие («крон-задачу выполняет только один инстанс»).

Режим таблицыКто берётС чем конфликтует
ACCESS SHARESELECTтолько с ACCESS EXCLUSIVE
ROW SHARESELECT FOR UPDATE/SHAREс EXCLUSIVE и выше
ROW EXCLUSIVEINSERT, UPDATE, DELETEс SHARE и выше
SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLYсам с собой и выше - DML не мешает
SHARECREATE INDEX (без CONCURRENTLY)с любой записью
SHARE ROW EXCLUSIVECREATE TRIGGER, часть ALTERс записью и сам с собой
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYсо всем, кроме ACCESS SHARE
ACCESS EXCLUSIVEDROP, TRUNCATE, большинство ALTER TABLE, VACUUM FULLсо всем, включая SELECT
Как одна миграция кладёт прод

ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT ... хочет ACCESS EXCLUSIVE. Он встаёт в очередь за уже идущим долгим SELECT. Но очередь блокировок в PostgreSQL честная (FIFO): все новые SELECT, пришедшие после ALTER, встают за ним. Итог: один аналитический запрос на 10 минут + одна безобидная миграция = таблица недоступна на 10 минут, приложение падает по таймаутам. Лечится это через SET lock_timeout = '3s' перед DDL и повтор в цикле: миграция сдастся, а не заблокирует всех.

-- Кто кого ждёт прямо сейчас (PG 10+): самый полезный запрос при инциденте
SELECT a.pid, a.state, now() - a.xact_start AS tx_age,
       a.wait_event_type, a.wait_event,
       pg_blocking_pids(a.pid) AS "заблокирован кем",
       left(a.query, 80) AS query
FROM   pg_stat_activity a
WHERE  a.backend_type = 'client backend'
  AND  (a.state <> 'idle' OR a.state IS NULL)
ORDER  BY tx_age DESC NULLS LAST;

-- Детали блокировок: что именно и в каком режиме удерживается
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM   pg_locks l
LEFT   JOIN pg_class c ON c.oid = l.relation
WHERE  NOT l.granted OR l.relation IS NOT NULL
ORDER  BY l.granted, l.pid;

-- Аварийные меры
SELECT pg_cancel_backend(pid);     -- отменить запрос: соединение живо, транзакция ждёт ROLLBACK
SELECT pg_terminate_backend(pid);  -- убить соединение целиком

Deadlock

Классический дедлок: перекрёстный порядок обновления T1 UPDATE row A блокировка получена UPDATE row B ждёт T2 T2 UPDATE row B блокировка получена UPDATE row A ждёт T1 Граф ожидания T1 T2 цикл → ждать бесполезно Что делает PostgreSQL раз в deadlock_timeout (по умолчанию 1 с) строит граф ожидания; найдя цикл - откатывает одну транзакцию с ошибкой 40P01, вторая продолжает работу
Дедлок и его разрешение. PostgreSQL не предотвращает дедлоки, а обнаруживает их постфактум. Проверка запускается, только когда транзакция прождала дольше deadlock_timeout: так процессор не тратится на граф при каждом ожидании.
Как проектировать, чтобы дедлоков не было
  1. Единый порядок захвата. Всегда обновляй строки в одном и том же порядке, например по возрастанию id. В переводе денег это буквально ORDER BY id перед блокировкой обоих счетов; без него переводы A→B и B→A под нагрузкой гарантированно поймают дедлок.
  2. Короткие транзакции. Чем меньше окно удержания блокировки, тем меньше шанс пересечения.
  3. Никаких сетевых вызовов внутри транзакции. HTTP-запрос на 2 секунды внутри BEGIN держит все блокировки те же 2 секунды.
  4. Блокировать всё нужное сразу, одним SELECT ... FOR UPDATE с IN (...) ORDER BY id, а не по одной строке за раз.
  5. И всё равно ретраить 40P01 - полностью исключить дедлоки в сложной системе нельзя.
-- Перевод без дедлока: фиксированный порядок захвата строк
BEGIN;
  SELECT id, balance FROM accounts
   WHERE id IN (1, 2)
   ORDER BY id                 -- всегда по возрастанию, куда бы ни шёл перевод
     FOR UPDATE;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

Чем опасны долгие транзакции

Открытая транзакция, даже если она ничего не делает и висит в состоянии idle in transaction, держит свой xmin, а значит:

  • Блокирует VACUUM. Autovacuum не может очистить мёртвые версии строк новее самого старого активного снапшота, причём во всей базе, а не только в таблицах этой транзакции. Таблицы пухнут, индексы пухнут, планы деградируют.
  • Держит блокировки, взятые в начале, до самого конца, в том числе табличные, из-за которых миграции встают в очередь.
  • Тормозит реплики. При hot_standby_feedback = on долгий запрос на реплике удерживает xmin на мастере и блокирует VACUUM уже там. При выключенном фидбеке наоборот: запрос на реплике будет отменён с canceling statement due to conflict with recovery.
  • Приближает transaction ID wraparound. Самая старая транзакция задаёт горизонт заморозки; если она живёт сутками, база может дойти до аварийного режима «остановить приём новых транзакций».
  • Растёт вероятность дедлоков и конфликтов сериализации, ведь окно пересечения шире.
-- Долгие транзакции и «idle in transaction»: их смотрят первым делом
SELECT pid, state, now() - xact_start AS tx_age, now() - state_change AS idle_age,
       left(query, 100)
FROM   pg_stat_activity
WHERE  xact_start IS NOT NULL
ORDER  BY xact_start
LIMIT  20;
Настройки, которые надо назвать

idle_in_transaction_session_timeout убивает соединения, забывшие закоммитить (ставь всегда, хоть 60 секунд: спасает от «разработчик открыл BEGIN в psql и ушёл на обед»). statement_timeout ограничивает один запрос, lock_timeout - ожидание блокировки (для миграций обязателен), а transaction_timeout (PG 17) - всю транзакцию целиком. В Go-приложении к этому добавляется context.WithTimeout на каждый запрос, но помни: отмена контекста не всегда мгновенно останавливает работу на сервере.

Вопросы

11
Суть: A - «всё или ничего», C - инварианты не нарушены, I - параллельные транзакции не видят промежуточных состояний друг друга, D - после «OK» изменения переживут падение сервера. Из четырёх только C не является чистым механизмом СУБД.

Перевод 100 рублей со счёта 1 на счёт 2 состоит из трёх операций: списание, зачисление, запись в журнал переводов.

A - Atomicity (атомарность)

Применяются либо все три операции, либо ни одной. Без атомарности сценарий «сервер упал между UPDATEами» оставляет деньги списанными, но не зачисленными - они исчезают из системы. Механизм: WAL плюс правило, что изменения становятся видимыми только после записи о коммите. Откат в PostgreSQL почти бесплатен: новые версии строк просто никто никогда не увидит, потому что их xmin принадлежит откаченной транзакции.

C - Consistency (согласованность)

Транзакция переводит базу из одного корректного состояния в другое: CHECK (balance >= 0) не нарушен, FK на счета целы, сумма денег в системе не изменилась. Без неё баланс уходит в минус. Но есть оговорка: СУБД проверяет только объявленные ей инварианты. Инвариант «сумма денег постоянна» ей неизвестен, и если разработчик забыл второй UPDATE, транзакция будет атомарной, изолированной, долговечной и при этом бизнес-несогласованной. Поэтому C часто называют «половина ответственности приложения».

I - Isolation (изоляция)

Параллельный отчёт «сколько всего денег в банке» не должен увидеть момент, когда со счёта 1 уже списали, а на счёт 2 ещё не зачислили, иначе он покажет на 100 рублей меньше. Без изоляции получаем весь набор аномалий: грязное чтение, потерянные обновления, фантомы. В PostgreSQL для чтения работают MVCC-снапшоты, для записи блокировки строк и SSI. Изоляцию настраивают чаще других букв: у неё четыре уровня, и это сознательный размен строгости на пропускную способность. Своя ручка есть и у D - о ней ниже.

D - Durability (долговечность)

После того как клиент получил «перевод выполнен», выключение питания не должно его отменить. Для этого перед подтверждением коммита запись WAL сбрасывается на диск через fsync. Отсюда практические ручки: synchronous_commit = off даёт кратный прирост скорости записи ценой потери транзакций за последние сотни миллисекунд при аварии (по умолчанию до ~600 мс) (база при этом остаётся согласованной - теряются целые транзакции, а не половинки), а synchronous_standby_names расширяет durability до «переживёт потерю всего сервера».

Чем добить

«C в ACID и C в CAP надо различать: в ACID это “инварианты соблюдены”, в CAP - “все узлы видят одинаковые данные” (линеаризуемость). Совпадение буквы историческое, смысл разный.» И вторая добивка: «Durability не бинарна, это шкала от “записали в page cache ОС” до “подтвердили на трёх узлах в разных ЦОД”, и каждая ступень стоит задержки на коммите.»

Суть: аномалия - это результат, недостижимый ни при каком последовательном выполнении тех же транзакций. Первые три - про чтение, последние две - про запись, и именно последние две чаще всего встречаются в реальном коде.

1. Грязное чтение (dirty read)

T1 списала 100 рублей и ещё не закоммитила. T2 читает баланс и видит списание. T1 делает ROLLBACK. T2 приняла решение по данным, которых никогда не существовало. Запрещено начиная с Read Committed; в PostgreSQL невозможно вообще ни на каком уровне.

2. Неповторяющееся чтение (non-repeatable read)

T1 читает баланс - 500. T2 обновляет его на 400 и коммитит. T1 читает тот же баланс ещё раз - 400. Один и тот же запрос в одной транзакции дал разные ответы. Ломает любой отчёт из нескольких запросов: итоговая строка не сходится с детализацией. Запрещено с Repeatable Read.

3. Фантомное чтение (phantom read)

T1 считает count(*) WHERE dept = 5 - 10. T2 вставляет новую строку с dept = 5 и коммитит. T1 повторяет запрос - 11. Отличие от предыдущей аномалии: изменилась не строка, а множество строк, попадающих под предикат. По стандарту разрешено на Repeatable Read, но в PostgreSQL на Repeatable Read фантомов нет - снапшот берётся один на транзакцию. В MySQL/InnoDB для этого используются gap locks.

4. Потерянное обновление (lost update)

T1 читает qty = 100, T2 читает qty = 100, T1 пишет 150 и коммитит, T2 пишет 130 и коммитит. Прибавка T1 бесследно исчезла, при этом обе транзакции завершились успешно и ошибок не было. Это самая частая аномалия в реальном коде, потому что паттерн «прочитал в приложении → посчитал → записал» пишут все. Лечится не уровнем изоляции, а одним из трёх: атомарным UPDATE x = x + n, SELECT FOR UPDATE или version-колонкой.

5. Аномалия записи (write skew)

Инвариант: на дежурстве должен остаться хотя бы один врач; сейчас дежурят двое. T1 читает «дежурных 2», решает, что можно уйти, и снимает с дежурства Аню. T2 параллельно читает «дежурных 2» и снимает Борю. Обе транзакции изменили разные строки, поэтому конфликта записи нет и обе коммитятся. Дежурных ноль - инвариант нарушен. Snapshot isolation (Repeatable Read) это не ловит принципиально; нужен SERIALIZABLE с SSI, либо явная блокировка того, что читали, либо материализация инварианта в одну строку-счётчик.

Read skew: шестая аномалия, о которой забывают

Частный случай неповторяющегося чтения на нескольких строках: T1 читает баланс счёта A (500), T2 переводит 100 с A на B и коммитит, T1 читает баланс B и видит его уже с переводом. Сумма по двум чтениям не сходится, хотя каждое чтение по отдельности корректно. Ради этого в PostgreSQL отчёты и гоняют на REPEATABLE READ: один снапшот на всю транзакцию гарантирует «согласованный срез базы».

Суть: READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE; каждый следующий запрещает на одну аномалию больше. Стандарт определяет уровни через набор запрещённых аномалий, а не через реализацию.
УровеньDirty readNon-repeatable readPhantom readLost updateWrite skew
READ UNCOMMITTEDдадададада
READ COMMITTEDнетдададада
REPEATABLE READнетнетпо стандарту да, в PG нетв PG нет (ошибка 40001)да
SERIALIZABLEнетнетнетнетнет

В стандарте классическая таблица содержит только первые три колонки: потерянное обновление и write skew в ANSI SQL-92 вообще не упомянуты, их добавили позже в статье «A Critique of ANSI SQL Isolation Levels» (Berenson et al., 1995). Там же появились термины snapshot isolation и write skew, поэтому «уровень стандарта» и «уровень PostgreSQL» не одно и то же.

Как это выглядит в коде

-- на транзакцию
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- или уже внутри, до первого запроса
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- посмотреть текущий
SHOW transaction_isolation;
tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
    ReadOnly:  true,   // read-only на Serializable дешевле: SSI почти не отслеживает такие транзакции
})
Как выбирать уровень на практике

Дефолтного Read Committed хватает для 95 % OLTP-операций, если добавить явные FOR UPDATE и атомарные UPDATE там, где есть read-modify-write. Repeatable Read берут для отчётов и выгрузок, которым нужен согласованный срез базы из нескольких запросов. Serializable нужен операциям со сложным инвариантом через несколько таблиц (бронирование, лимиты, расписание), и только вместе с готовым механизмом ретрая. Уровень задают на транзакцию, а не глобально: глобальный Serializable почти всегда оказывается преждевременной пессимизацией.

Суть: PostgreSQL реализует изоляцию через MVCC-снапшоты, а не через блокировки чтения. Отсюда: незакоммиченное не видно физически (RU = RC), а один снапшот на транзакцию убирает фантомы бесплатно (RR строже стандарта).

Почему READ UNCOMMITTED = READ COMMITTED

Грязное чтение возможно только там, где читатель может увидеть данные, изменённые незавершённой транзакцией. В PostgreSQL каждая версия строки помечена xmin, номером создавшей её транзакции, и правило видимости требует, чтобы эта транзакция была закоммичена. Незакоммиченная версия невидима технически, показать её просто нечем. Поэтому SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED принимается без ошибки (для совместимости со стандартом), но ведёт себя как Read Committed. Уровней у PostgreSQL фактически три.

Почему на REPEATABLE READ нет фантомов

RC и RR в PostgreSQL различаются моментом, когда берётся снапшот. На Read Committed снапшот берётся заново на каждый оператор: следующий SELECT увидит всё, что закоммитили соседи. На Repeatable Read снапшот берётся один раз, при первом операторе транзакции, и живёт до конца. У строки, вставленной и закоммиченной после этого момента, xmin больше границы снапшота, и она невидима, сколько раз запрос ни повторяй. Фантом не может появиться просто потому, что база для этой транзакции «застыла во времени». Стандарт фантомы на RR разрешает, PostgreSQL их запрещает: реализация здесь строже спецификации.

Что взамен появляется на RR

Запись. Если транзакция на Repeatable Read пытается обновить строку, изменённую и закоммиченную другой транзакцией после её снапшота, PostgreSQL не может корректно применить изменение (её снапшот описывает устаревший мир) и откатывает транзакцию с ERROR: could not serialize access due to concurrent update, SQLSTATE 40001. На Read Committed в этой же ситуации сработал бы EvalPlanQual: перечитал бы строку и заново проверил WHERE. Поэтому переход на RR всегда идёт в комплекте с ретраями.

SERIALIZABLE = SSI

С версии 9.1 PostgreSQL реализует настоящую сериализуемость через Serializable Snapshot Isolation: поверх обычных снапшотов движок отслеживает предикатные блокировки и ищет в графе зависимостей опасную структуру из двух подряд идущих rw-конфликтов. Найдя её, откатывает одну из участниц с could not serialize access due to read/write dependencies among transactions. Читатели по-прежнему никого не блокируют: платишь не ожиданием, а вероятностью отката и памятью под отслеживание предикатов (max_pred_locks_per_transaction).

Ловушка: Read Committed и «неатомарный» оператор

Многие уверены, что один оператор на Read Committed видит один согласованный срез. Это так для SELECT, но для UPDATE/DELETE есть EvalPlanQual: если строка изменилась, пока мы ждали блокировку, PG перечитает её новую версию и заново применит к ней WHERE. Отсюда неинтуитивное поведение: UPDATE t SET x = x + 1 корректен и не теряет обновлений, а UPDATE t SET x = 5 WHERE x = 3 может внезапно не сработать, если конкурент успел поменять x на 4.

Суть: PostgreSQL - READ COMMITTED, MySQL/InnoDB - REPEATABLE READ. Разница историческая: в MySQL уровень RR был нужен для корректности statement-based репликации.

В PostgreSQL по умолчанию стоит READ COMMITTED: снапшот на каждый оператор. Компромисс «достаточно строго для большинства задач, минимум откатов». Глобально его почти никогда не меняют, уровень поднимают точечно на конкретную транзакцию.

В MySQL/InnoDB по умолчанию REPEATABLE READ, и дело не в заботе о строгости. Исторически MySQL использовал statement-based репликацию: на реплику передавался текст запроса, который там выполнялся заново. При Read Committed порядок применения операторов мог дать на реплике другой результат, поэтому потребовался более строгий уровень плюс gap locks - блокировки «промежутков» между значениями индекса, которые не дают вставить строку в диапазон, прочитанный чужой транзакцией. Gap locks и закрывают фантомы в InnoDB, и они же порождают знаменитые дедлоки MySQL на, казалось бы, невинных вставках.

PostgreSQLMySQL / InnoDB
ДефолтREAD COMMITTEDREPEATABLE READ
Как убираются фантомы на RRснапшот на всю транзакциюgap locks / next-key locks
Ценаошибка 40001, нужен ретрайожидание и дедлоки на вставках
Read Uncommittedне отличим от RCреально работает, грязное чтение возможно
SerializableSSI, настоящая сериализуемостьобычные SELECT внутри транзакции превращаются в FOR SHARE
Тонкость, которая ловит при миграции с MySQL на PostgreSQL

Код, написанный под MySQL, часто неявно полагается на RR: «внутри транзакции данные не меняются». После переезда на PostgreSQL с его Read Committed этот код внезапно ловит неповторяющиеся чтения. И наоборот: код, написанный под PG с расчётом на явные FOR UPDATE, в MySQL начинает ловить дедлоки из-за gap locks там, где их не ждали. Хороший пример к тезису «уровень изоляции - часть контракта приложения, а не деталь инфраструктуры».

Суть: SQLSTATE 40001 - не сбой, а штатный ответ «я не смог сделать вид, что твоя транзакция была одна, повтори её». На Repeatable Read и Serializable отсутствие ретрая - это баг, который проявится под нагрузкой.

Откуда берётся

  • Repeatable Read: транзакция пытается обновить строку, изменённую и закоммиченную после её снапшота, и получает could not serialize access due to concurrent update.
  • Serializable: SSI находит в графе зависимостей опасную структуру и отвечает could not serialize access due to read/write dependencies among transactions.
  • Рядом стоит 40P01 deadlock_detected - причина другая, обработка та же.

Откат при этом полный: база осталась согласованной, ничего наполовину не применилось, повтор той же транзакции корректен по определению. Поэтому SSI и работает: движок обещает «либо результат будет как при последовательном выполнении, либо я откачу». За вторую половину обещания отвечает приложение.

Как это выглядит правильно

// Оборачиваем всю бизнес-операцию, а не отдельный запрос
err := WithRetry(ctx, pool, func(tx pgx.Tx) error {
    var seats int
    if err := tx.QueryRow(ctx,
        `SELECT count(*) FROM bookings WHERE flight_id = $1`, flightID).Scan(&seats); err != nil {
        return err
    }
    if seats >= capacity {
        return ErrNoSeats           // бизнес-ошибка, не ретраим
    }
    _, err := tx.Exec(ctx,
        `INSERT INTO bookings(flight_id, user_id) VALUES ($1, $2)`, flightID, userID)
    return err
})
// Письмо отправляем только здесь, после успешного коммита:
// внутри fn оно ушло бы столько раз, сколько было попыток.

Что должно быть в реализации ретрая

  1. Повторять всю транзакцию с нуля - старый снапшот невалиден.
  2. Экспоненциальная пауза с джиттером. Без разброса конкуренты синхронно повторят конфликт и снова столкнутся.
  3. Ограничение числа попыток и осмысленная финальная ошибка.
  4. Никаких внешних побочных эффектов внутри: ни писем, ни платежей, ни сообщений в Kafka. Только чистая работа с БД; всё остальное после коммита или через outbox.
  5. Различать ретраибельные и нет. 23505 unique_violation или 23514 check_violation повторять бессмысленно, они просто повторятся.
  6. Метрика на число ретраев. Растёт доля конфликтов, значит, горячая точка разрастается и пора менять схему (шардировать счётчик, перейти на атомарный UPDATE).
Про идемпотентность в цифрах

Самый частый инцидент с ретраями звучит так: «клиенту трижды списали деньги». Причина всегда одна - внутри ретраимой функции был вызов внешнего API. Правило абсолютное: внутри транзакции - только SQL. Если внешний вызов обязателен, используй transactional outbox: записываешь намерение в таблицу в той же транзакции, а отдельный воркер отправляет его наружу с ключом идемпотентности.

Суть: FOR UPDATE резервирует строки под будущую запись и делает безопасным паттерн read-modify-write. SKIP LOCKED превращает таблицу в рабочую очередь, NOWAIT - способ не висеть в интерактивной операции.

FOR UPDATE и его слабые формы

SELECT ... FOR UPDATE берёт эксклюзивную блокировку на каждую возвращённую строку до конца транзакции. Другие транзакции не смогут её обновить, удалить или тоже заблокировать; обычные SELECT при этом работают свободно - в PostgreSQL читатели никогда не ждут писателей. Есть более слабые режимы: FOR NO KEY UPDATE (берётся неявно обычным UPDATE неключевых колонок), FOR SHARE («пусть читают, но менять нельзя») и FOR KEY SHARE (запрещает только удаление и смену ключа; его и берёт проверка внешнего ключа).

SKIP LOCKED - очередь на таблице

UPDATE tasks SET status = 'processing', locked_at = now()
WHERE id IN (
    SELECT id FROM tasks
    WHERE status = 'new' AND run_after <= now()
    ORDER BY priority DESC, id
    LIMIT 10
    FOR UPDATE SKIP LOCKED
)
RETURNING id, payload;

Без SKIP LOCKED десять воркеров выстроились бы в очередь за одной и той же первой строкой - параллелизм нулевой. С ним каждый пропускает занятое и забирает свою пачку. Это стандартный способ сделать очередь без брокера: плюс - задачи ставятся транзакционно вместе с бизнес-данными (никаких «в базе есть, в очереди нет»), минус - нагрузка на БД и bloat от постоянных UPDATE статуса, поэтому на десятках тысяч задач в секунду нужен настоящий брокер.

NOWAIT

FOR UPDATE NOWAIT вместо ожидания сразу отдаёт ошибку 55P03 lock_not_available. Нужен там, где ждать хуже, чем отказать: интерактивное редактирование документа («сейчас редактирует другой пользователь»), административная операция, которую можно повторить. Родственная настройка - SET LOCAL lock_timeout = '2s': ждать, но не бесконечно.

Три подвоха FOR UPDATE

1) Блокируются только строки, реально возвращённые запросом. Отсутствующую строку заблокировать нельзя, и от гонки «два потока вставляют одну и ту же запись» FOR UPDATE не спасает: там нужен UNIQUE-индекс или INSERT ... ON CONFLICT. 2) Нельзя использовать с агрегатами, GROUP BY, DISTINCT, UNION - будет ошибка. 3) С LIMIT без ORDER BY на конкурентной нагрузке легко поймать дедлок: два воркера возьмут пересекающиеся наборы в разном порядке. Порядок захвата всегда должен быть детерминированным.

Суть: пессимистичная - «займу заранее, конкурент подождёт» (FOR UPDATE). Оптимистичная - «проверю при записи, что никто не опередил» (WHERE version = N). Выбор определяется вероятностью конфликта и длиной операции.

Пессимистичная

Блокировка берётся до начала работы и держится до конца транзакции. Плюс: операция гарантированно проходит с первого раза, логика линейная, ретраи не нужны. Минусы: конкуренты стоят в очереди (пропускная способность по горячей строке падает до «одна транзакция за раз»), растёт риск дедлоков, и она принципиально не работает через «длинный» бизнес-процесс - блокировку нельзя держать, пока пользователь заполняет форму.

Оптимистичная через version-колонку

CREATE TABLE documents (
    id      bigint PRIMARY KEY,
    body    text   NOT NULL,
    version int    NOT NULL DEFAULT 1
);

-- 1) читаем без блокировки, отдаём пользователю body и version = 7
-- 2) через 5 минут он присылает изменения; пишем условно:
UPDATE documents
   SET body = $1, version = version + 1
 WHERE id = $2 AND version = $3;   -- $3 = 7
res, err := db.ExecContext(ctx,
    `UPDATE documents SET body=$1, version=version+1 WHERE id=$2 AND version=$3`,
    body, id, version)
if err != nil {
    return err
}
n, _ := res.RowsAffected()
if n == 0 {
    // Ноль строк = либо документа нет, либо version изменилась.
    // Это не ошибка БД, конфликт разбирает бизнес-логика:
    // перечитать, показать пользователю расхождение или смержить.
    return ErrConcurrentModification
}

Вместо целочисленной версии можно использовать updated_at (хуже: разрешение времени и переводы часов) или хеш содержимого (годится для HTTP ETag / If-Match: по сути та же оптимистичная блокировка, только вынесенная в протокол).

ПессимистичнаяОптимистичная
Когда проверяется конфликтдо работыпри записи
Конкурентждётработает и, возможно, проиграет
Стоимость конфликтаожиданиеповтор всей работы
Длинные операции с участием человеканепригоднаединственный вариант
Высокая конкуренция за одну строкуподходитвырождается в ретраи
Риск дедлокаестьнет
Третий вариант, который часто лучше обоих

Убрать read-modify-write вообще: UPDATE stock SET qty = qty - 1 WHERE id = $1 AND qty >= 1. Один атомарный оператор, никаких блокировок в коде, никаких версий, а RowsAffected() == 0 означает «не хватило остатка». Так же работает INSERT ... ON CONFLICT DO UPDATE вместо «проверил, есть ли строка, потом вставил». Хороший ответ на собесе всегда включает этот вариант: лучшая блокировка та, которая не понадобилась.

Суть: три сорта - табличные (8 режимов), строчные (4 режима) и advisory. Смотреть через pg_locks + pg_stat_activity, а быстрее всего - через pg_blocking_pids().

Табличные

Берутся автоматически под каждую команду. Запомни минимум: SELECT берёт ACCESS SHARE и конфликтует только с ACCESS EXCLUSIVE; INSERT/UPDATE/DELETE берут ROW EXCLUSIVE и не мешают друг другу на уровне таблицы (конфликты разрешаются построчно); VACUUM, ANALYZE и CREATE INDEX CONCURRENTLY берут SHARE UPDATE EXCLUSIVE и не мешают DML; ACCESS EXCLUSIVE (DROP, TRUNCATE, большинство ALTER TABLE, VACUUM FULL) блокирует вообще всё, включая SELECT.

Строчные

Их четыре: FOR KEY SHARE, FOR SHARE, FOR NO KEY UPDATE, FOR UPDATE. Хранятся они не в общей памяти, а прямо в заголовке версии кортежа (плюс структура multixact, когда строку держат несколько транзакций). Выходит, число строчных блокировок не ограничено: можно заблокировать миллион строк и не бояться исчерпать память блокировок. А вот табличных блокировок конечное число (max_locks_per_transaction).

Advisory

pg_advisory_lock(key) и pg_try_advisory_xact_lock(key) вешают замок по произвольному числу, ни с какой строкой не связанный. Удобен как распределённый мьютекс: «только один инстанс сервиса выполняет эту фоновую задачу». Транзакционная форма (_xact_) снимается автоматически при завершении транзакции, её и используй; обычная требует явного unlock и легко теряется.

-- Кто кого блокирует прямо сейчас: первый запрос при инциденте
SELECT pid, now() - xact_start AS tx_age, state, wait_event_type, wait_event,
       pg_blocking_pids(pid) AS blocked_by, left(query, 90)
FROM   pg_stat_activity
WHERE  backend_type = 'client backend' AND state <> 'idle'
ORDER  BY tx_age DESC;

-- Что именно удерживается и в каком режиме
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM   pg_locks l LEFT JOIN pg_class c ON c.oid = l.relation
ORDER  BY l.granted, l.pid;

SELECT pg_cancel_backend(12345);     -- мягко: отменить запрос
SELECT pg_terminate_backend(12345);  -- жёстко: закрыть соединение
Честная очередь блокировок опаснее, чем кажется

Если ALTER TABLE ждёт ACCESS EXCLUSIVE за долгим SELECT, то все новые SELECT встают в очередь за ALTER, а не проходят мимо. Один десятиминутный аналитический запрос плюс одна миграция = таблица недоступна десять минут. Поэтому перед любым DDL на проде ставь SET lock_timeout = '3s' и повторяй в цикле - миграция сдастся, а не положит сервис.

Суть: цикл в графе ожидания блокировок. PostgreSQL не предотвращает дедлоки, а обнаруживает их по таймауту и откатывает одну транзакцию с ошибкой 40P01. Лечится единым порядком захвата ресурсов.

Как возникает

T1 обновляет строку A и получает на неё блокировку, затем хочет строку B. T2 в это же время обновила B и хочет A. Каждая ждёт ресурс, который держит другая, и получается цикл. Классический источник: перевод денег «со счёта X на счёт Y» и одновременно «с Y на X»; или два процесса, обновляющие набор строк в разном порядке (например, потому что один шёл ORDER BY id, а второй - ORDER BY created_at).

Как разрешает PostgreSQL

Транзакция, прождавшая блокировку дольше deadlock_timeout (по умолчанию 1 с), запускает детектор: тот строит граф «кто кого ждёт» и ищет в нём цикл. Если цикл нашёлся, одна из транзакций (обычно та, что запустила проверку) откатывается с ERROR: deadlock detected, SQLSTATE 40P01, а в лог пишется, какие процессы и запросы участвовали. Остальные продолжают работу. Проверка запускается по таймауту, а не при каждом ожидании, чтобы не жечь процессор - обычное ожидание блокировки ещё не дедлок.

Как проектировать

  1. Единый порядок захвата. Главное правило: если операция трогает несколько строк, блокируй их в детерминированном порядке (по возрастанию первичного ключа), независимо от бизнес-направления операции.
  2. Захватывать всё сразу одним SELECT ... WHERE id IN (...) ORDER BY id FOR UPDATE, а не последовательно по одной строке.
  3. Короткие транзакции и никаких сетевых вызовов внутри - окно пересечения меньше.
  4. Осторожно с ON CONFLICT и FK: параллельные вставки в таблицу с уникальным индексом и разным порядком значений тоже дают дедлоки.
  5. Всё равно ретраить. Полностью исключить дедлоки в системе со сложными транзакциями нельзя, поэтому обработка 40P01 обязательна.
-- Плохо: порядок зависит от направления перевода
UPDATE accounts SET balance = balance - 100 WHERE id = $from;
UPDATE accounts SET balance = balance + 100 WHERE id = $to;

-- Хорошо: сначала захватываем оба счёта в фиксированном порядке
SELECT id FROM accounts WHERE id IN ($from, $to) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = $from;
UPDATE accounts SET balance = balance + 100 WHERE id = $to;
Отличие от «просто долгого ожидания»

Дедлок разрешается автоматически за секунду и виден в логе как 40P01. А «всё висит, но дедлока нет» означает обычную блокировку: кто-то держит транзакцию открытой. Диагностика разная: для первого смотрят лог и порядок обновлений в коде, для второго - pg_stat_activity и pg_blocking_pids(). На собесе эти два случая часто путают.

Суть: открытая транзакция держит свой xmin, а значит блокирует VACUUM во всей базе, удерживает захваченные блокировки, тормозит реплики и приближает transaction ID wraparound. Причём вредит даже транзакция, которая ничего не делает.

Пять последствий

  1. Блокировка очистки. Autovacuum не имеет права удалить мёртвую версию строки, если она может быть нужна самому старому активному снапшоту. Одна транзакция, висящая часами, останавливает уборку по всей базе. Отсюда table bloat: таблица на 10 ГБ данных занимает 60 ГБ, индексы распухают, всё сканируется медленнее, кэш забит мусором.
  2. Удержание блокировок. Все блокировки живут до конца транзакции. Если в начале был ALTER или FOR UPDATE, соседи будут ждать до самого COMMIT - а из-за честной FIFO-очереди за ними встанут и невинные SELECT.
  3. Проблемы с репликами. При hot_standby_feedback = on долгий запрос на реплике удерживает xmin на мастере и мешает VACUUM уже там. При выключенном фидбеке запрос на реплике убьют с canceling statement due to conflict with recovery. Выбор между двумя неприятностями, и об этом полезно сказать вслух.
  4. Transaction ID wraparound. Счётчик транзакций 32-битный; чтобы старые данные не «стали будущими», VACUUM замораживает старые кортежи. Самая старая транзакция задаёт горизонт заморозки, и если она живёт сутками, база приближается к аварийному режиму, когда PostgreSQL перестаёт принимать новые транзакции.
  5. Больше конфликтов. Чем шире окно, тем выше вероятность дедлока и ошибки сериализации.

Особый случай: idle in transaction

Чаще всего транзакцию затягивает не тяжёлый запрос, а BEGIN, после которого приложение ушло делать HTTP-запрос, писать в Kafka или просто забыло закоммитить из-за пропущенного defer tx.Rollback(). Транзакция ничего не выполняет, но вредит точно так же. Это и есть главный аргумент против «сетевых вызовов внутри транзакции».

-- Диагностика
SELECT pid, state, now() - xact_start AS tx_age, now() - state_change AS in_state,
       left(query, 100)
FROM   pg_stat_activity
WHERE  xact_start IS NOT NULL
ORDER  BY xact_start
LIMIT  20;

-- Профилактика. SET - на сессию; на роль - ALTER ROLE … SET, на сервер - ALTER SYSTEM
SET idle_in_transaction_session_timeout = '60s';
SET statement_timeout = '30s';
SET lock_timeout = '3s';
Как это звучит на собесе

«Транзакция должна открываться как можно позже и закрываться как можно раньше, и внутри неё не должно быть ничего, кроме работы с БД. На уровне инфраструктуры обязательно выставлены statement_timeout и idle_in_transaction_session_timeout, а в мониторинге есть алерт на возраст самой старой транзакции - на него первым делом смотрят при жалобе на распухшую базу или на массовые таймауты.»

Что стоит запомнить

  • ACID описывает свойства выполнения, но прикладной инвариант нужно выразить запросами и ограничениями.
  • Уровень изоляции задаёт, какие эффекты параллельных транзакций допустимы; название уровня само по себе не объясняет конкретную аномалию.
  • Сериализационный конфликт или дедлок требует повторить всю транзакцию, если операция безопасна для повтора.

Частые ошибки

  • Не делай внешние вызовы и долгую работу внутри транзакции: блокировки удерживаются дольше.
  • Не ретраить только последний UPDATE после serialization failure: решение транзакции нужно пересчитать целиком.
  • Не полагайся на проверку баланса в приложении без защиты от конкурентного изменения.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проверь инвариант перевода

Две транзакции одновременно выполняют этот код для одного счёта с балансом 100. Объясни возможный результат при READ COMMITTED.

SELECT balance FROM accounts WHERE id = 1;
-- приложение проверило balance >= 80
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
Ответ и разбор

Обе транзакции могут прочитать 100 и обе решить, что списание допустимо. Последовательные UPDATE одной строки будут ждать друг друга, но решение о достаточности средств уже принято по старому чтению; итог способен нарушить бизнес-ограничение. Перенеси проверку в атомарный UPDATE, например UPDATE accounts SET balance = balance - 80 WHERE id = 1 AND balance >= 80 RETURNING balance, и считай отсутствие строки отказом. Для перевода двух счетов обновления всё равно должны быть в одной транзакции.

Код-ревью

2. Разбери обработку ошибки транзакции

Функция владеет транзакцией перевода. Получатель с id=99 отсутствует, но UPDATE без совпавших строк не возвращает SQL-ошибку. Найди, как код может закоммитить списание без зачисления, и исправь проверку.

func transfer(ctx context.Context, db *pgxpool.Pool, from, to int64, amount int64) error {
    tx, err := db.Begin(ctx)
    if err != nil { return err }
    defer tx.Rollback(ctx)
    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)
}
Ответ и разбор

Функция сама открывает и коммитит транзакцию, но считает успешным UPDATE, затронувший ноль строк. Если from существует, а to=99 отсутствует, списание коммитится без зачисления. Проверь RowsAffected() обоих UPDATE и требуй ровно одну строку; при несовпадении верни доменную ошибку до Commit, а отложенный Rollback откатит списание. Не запускай слепой повтор после ошибки Commit: исход commit может быть неизвестен, поэтому повторяемому запросу нужен idempotency key/запись операции.

Применение

3. Разложи дедлок по шагам

Транзакция A блокирует строку account 1, затем просит account 2. Транзакция B блокирует account 2, затем просит account 1. Что произойдёт и как уменьшить вероятность повтора?

Ответ и разбор

Возникает цикл ожидания, и PostgreSQL прервёт одну транзакцию с ошибкой deadlock detected. Обновляй счета в едином порядке, например по возрастанию id, чтобы обе транзакции запрашивали блокировки одинаково. Всё равно обрабатывай ошибку и повторяй всю транзакцию с ограниченным числом попыток и задержкой, если операция идемпотентна.

Применение

4. Выбери защиту инварианта

В приложении действует правило: у пользователя может быть не более одного активного бронирования на один слот. Сравни проверку в Go перед INSERT и ограничение в БД.

Ответ и разбор

Проверка только в Go подвержена гонке: два запроса одновременно не найдут бронирование и оба вставят его. Вырази правило уникальным ограничением или частичным уникальным индексом в БД, затем преобразуй конфликт в понятную доменную ошибку. Проверка перед записью может остаться для удобного ответа, но ограничение должно быть окончательной защитой.