← назад к разделу

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

Что может пойти не так при параллельных транзакциях

Представьте: два пользователя одновременно меняют одни и те же данные. Без изоляции возникают классические аномалии:

Грязное чтение — одна транзакция видит незакоммиченные данные другой. Если та откатится, первая прочитала «воздух». В PostgreSQL это невозможно в принципе — MVCC защищает от грязного чтения всегда.

Неповторяемое чтение — одна и та же строка в рамках транзакции прочитана дважды, но значения разные: между чтениями кто-то успел её поменять и закоммитить.

Фантомное чтение — запрос с одним и тем же условием в рамках транзакции возвращает разное число строк: между вызовами кто-то вставил или удалил строки.

Write skew (аномалия записи) — самый тонкий случай. Каждая транзакция по отдельности читает данные, проверяет инвариант и делает запись. Всё выглядит правильно. Но вместе они нарушают инвариант, который проверяли. Классический пример — «хотя бы один врач на смене» (разберём ниже).

Три уровня в PostgreSQL

PostgreSQL поддерживает три реальных уровня. Формально в стандарте SQL есть ещё READ UNCOMMITTED, но в PostgreSQL он работает так же, как READ COMMITTED — грязное чтение просто не реализовано.

УровеньГрязное чтениеНеповт. чтениеФантомыWrite skew
READ COMMITTED (по умолчанию)нетдадада
REPEATABLE READнетнетнетда
SERIALIZABLEнетнетнетнет

Важно: PostgreSQL реализует REPEATABLE READ через snapshot isolation, что строже стандарта SQL — фантомные чтения тоже исключены, хотя стандарт это не требует.

READ COMMITTED — уровень по умолчанию

Каждый SELECT в транзакции видит данные, закоммиченные на момент запуска этого конкретного запроса. Не на момент начала транзакции, а именно запроса.

-- TX1 начала транзакцию
BEGIN;
SELECT price FROM product WHERE id = 1;   -- 100

-- в это время TX2 изменила цену и закоммитила
-- UPDATE product SET price = 120 WHERE id = 1; COMMIT;

SELECT price FROM product WHERE id = 1;   -- 120! (non-repeatable read)
COMMIT;

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

Когда RC достаточно:

  • обычный CRUD;
  • API-эндпоинты, где строку читают однажды;
  • операции «прочитать — изменить — записать» одной строки с SELECT FOR UPDATE.

Когда SELECT FOR UPDATE обязателен при RC: если логика «прочитал → проверил → записал» должна быть атомарной, FOR UPDATE блокирует строку до конца транзакции. Без него две параллельные транзакции могут прочитать одно значение и оба записать поверх.

REPEATABLE READ — снимок данных

При REPEATABLE READ PostgreSQL делает снимок (snapshot) данных на момент первого запроса в транзакции. Все последующие чтения в той же транзакции видят этот снимок — как будто данные «заморожены».

BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT count(*) FROM orders WHERE status = 'NEW';   -- 100

-- другая транзакция вставила 5 новых заказов и закоммитила

SELECT count(*) FROM orders WHERE status = 'NEW';   -- всё ещё 100
COMMIT;

Когда это нужно:

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

Ошибка 40001 при REPEATABLE READ

Здесь начинается подводный камень. Если TX1 читает строку, а TX2 успевает её изменить и закоммитить — и потом TX1 пытается изменить ту же строку, PostgreSQL не может «смешать» изменения. Он откатывает TX1 с ошибкой:

ERROR: could not serialize access due to concurrent update
SQLSTATE: 40001
-- TX1 (REPEATABLE READ): прочитала цену = 100
-- TX2 (READ COMMITTED): поменяла цену на 120, закоммитила
-- TX1 пытается поменять цену на 110 — получает 40001

Приложение обязано поймать эту ошибку и повторить транзакцию. Без логики повтора (retry) REPEATABLE READ нельзя использовать в продакшене.

SERIALIZABLE — полная изоляция

SERIALIZABLE гарантирует, что результат параллельных транзакций будет таким же, как если бы они выполнялись строго по одной. PostgreSQL использует алгоритм SSI (Serializable Snapshot Isolation) — он отслеживает зависимости между транзакциями через предикатные локи.

Пример write skew

Инвариант: на смене всегда должен быть хотя бы один врач. На дежурстве двое.

-- TX1 (REPEATABLE READ): смотрит количество дежурных
SELECT count(*) FROM doctors WHERE on_call = true;   -- 2
-- «ок, можно уйти, останется 1»
UPDATE doctors SET on_call = false WHERE id = 1;

-- TX2 (REPEATABLE READ) делает то же самое параллельно:
SELECT count(*) FROM doctors WHERE on_call = true;   -- тоже 2
UPDATE doctors SET on_call = false WHERE id = 2;
COMMIT;

-- TX1 коммитит — инвариант нарушен: 0 врачей на смене

REPEATABLE READ не помогает: каждая транзакция видела корректный снимок и писала в разные строки. Только SERIALIZABLE поймает такой конфликт — одна из транзакций получит 40001 и откатится.

Когда SERIALIZABLE оправдан:

  • сложные инварианты на нескольких строках, которые нельзя выразить через CHECK-ограничение или SELECT FOR UPDATE;
  • финансовые расчёты с множественными условиями.

Цена вопроса: предикатные локи создают нагрузку, процент откатов растёт под нагрузкой. На большинстве OLTP-приложений SERIALIZABLE избыточен. Зачастую дешевле оставить RC и заменить сложный инвариант на SELECT FOR UPDATE с явной проверкой в коде или на CHECK-ограничение.

Как задать уровень в коде

Уровень изоляции задаётся на уровне транзакции, не на уровне соединения.

Java / Spring:

@Transactional(isolation = Isolation.SERIALIZABLE)
public void releaseDoctorFromShift(long doctorId) {
    int onCallCount = doctorRepository.countByOnCallTrue();
    if (onCallCount <= 1) {
        throw new LastDoctorOnShiftException();
    }
    doctorRepository.setOnCallFalse(doctorId);
}

Go (pgx):

opts := pgx.TxOptions{IsoLevel: pgx.Serializable}
err := pgx.BeginTxFunc(ctx, pool, opts, func(tx pgx.Tx) error {
    // логика транзакции
    return nil
})

Node.js (pg):

await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
// запросы
await client.query('COMMIT');

Python (psycopg3):

async with pool.connection() as conn:
    await conn.set_isolation_level(psycopg.IsolationLevel.SERIALIZABLE)
    async with conn.transaction():
        # логика транзакции
        pass

READ COMMITTED — это дефолт PostgreSQL, явно его указывать не нужно.

Retry на ошибку 40001

При REPEATABLE READ и SERIALIZABLE приложение должно повторять транзакцию при ошибке 40001. Без этого система ненадёжна.

Java (spring-retry):

@Retryable(
    retryFor = CannotSerializeTransactionException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 50, multiplier = 2)
)
@Transactional(isolation = Isolation.SERIALIZABLE)
public void doWork() { ... }

Go:

for attempt := 0; attempt < 3; attempt++ {
    err := pgx.BeginTxFunc(ctx, pool, opts, func(tx pgx.Tx) error {
        // логика
        return nil
    })
    if err == nil {
        return nil
    }
    if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "40001" {
        time.Sleep(time.Duration(50*(attempt+1)) * time.Millisecond)
        continue
    }
    return err
}

Стратегия простая: поймал 40001 → подождал немного → попробовал снова. Обычно хватает 3 попыток с нарастающей паузой.

Как выбрать уровень

Практический алгоритм:

  1. Простой CRUD или read-modify-write одной строкиREAD COMMITTED + SELECT FOR UPDATE там, где нужна атомарность.
  2. Длинный отчёт по нескольким таблицам с согласованным срезомREPEATABLE READ.
  3. Сложный инвариант на нескольких строках, нельзя выразить через FOR UPDATESERIALIZABLE + retry.
  4. Финансовые операцииREAD COMMITTED + SELECT FOR UPDATE с упорядоченными локами (обычно дешевле и надёжнее SERIALIZABLE).

При сомнениях — оставайтесь на READ COMMITTED. Поднимать уровень изоляции без понимания конкретной аномалии — лишняя нагрузка без гарантий безопасности.

Тайм-аут на зависшие транзакции

Открытая транзакция удерживает ресурсы и мешает работе autovacuum. В продакшене всегда настраивают:

idle_in_transaction_session_timeout = 30000   -- 30 секунд

Это значение убивает транзакцию, которая ничего не делает дольше 30 секунд. Длинная незакрытая транзакция почти всегда означает ошибку в коде — лучше её прервать, чем ждать.

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

Ставить SERIALIZABLE на все операции — нет смысла. Большинству операций хватает READ COMMITTED, а SERIALIZABLE добавляет накладные расходы. Выбирайте минимальный достаточный уровень.

Использовать REPEATABLE READ без retry — транзакция может завершиться с 40001 в любой момент. Без обработки этой ошибки пользователь увидит необъяснимый сбой.

Поднимать уровень изоляции там, где нужен CHECK-constraint — если инвариант можно выразить на уровне схемы, так и делайте. База сама его соблюдёт без overhead от предикатных локов.

Явно указывать READ COMMITTED в коде — это умолчание, дублировать его незачем.

Коротко

  • PostgreSQL поддерживает три рабочих уровня: READ COMMITTED, REPEATABLE READ, SERIALIZABLE. READ UNCOMMITTED работает как RC.
  • MVCC в PostgreSQL исключает грязное чтение на всех уровнях — это гарантия самой базы.
  • READ COMMITTED — дефолт и норма для OLTP. Non-repeatable и phantom-чтения в большинстве случаев не проблема.
  • REPEATABLE READ фиксирует снимок данных на начало транзакции, фантомы исключены. Нужен для согласованных отчётов и длинных операций.
  • SERIALIZABLE — единственный уровень, защищающий от write skew. Нужен редко, требует retry.
  • Ошибка 40001 при REPEATABLE READ и SERIALIZABLE — штатная ситуация. Приложение должно повторить транзакцию.
  • idle_in_transaction_session_timeout = 30s в продакшене — обязательно.
  • Сомневаетесь в уровне — оставайтесь на READ COMMITTED.

Что почитать дальше

  • Блокировки в PostgreSQL — SELECT FOR UPDATE и другие виды локов.
  • Spring @Transactional — как уровень изоляции задаётся через Spring.
  • Connection pool — маршрутизация read-only транзакций на реплику.