PostgreSQL поддерживает три уровня изоляции (стандарт SQL описывает четыре, READ UNCOMMITTED PG не реализует — для совместимости работает как READ COMMITTED). На уровне @Transactional Spring это значит, что вы выбираете компромисс между корректностью и производительностью.

Дефолт PG — READ COMMITTED, и в 95% случаев это правильный выбор. Эта статья — про оставшиеся 5%, когда нужен другой уровень.

Правила пронумерованы кодами PG-IS-NNN — на них ссылается скилл ucp-pg-runtime-review.

1. Что такое аномалии

Стандарт SQL описывает четыре аномалии:

АномалияЧтоПример
Dirty readВидим uncommitted изменение чужой транзакцииTX1 пишет, TX2 видит до commit, TX1 откатилась
Non-repeatable readПеречитал ту же строку — значение изменилосьTX1 читает price=100, TX2 update price=120 commit, TX1 перечитал — 120
Phantom readПеречитал тот же WHERE — пришли новые строкиTX1 SELECT count(*) WHERE status='NEW' = 5, TX2 insert NEW commit, TX1 повторно — 6
Serialization anomalyПараллельные TX в сумме нарушают инвариант, который каждая по отдельности соблюдаетКласс. пример: write skew

PG-IS-001 Уровни изоляции — это НЕ «больше = лучше». Каждый уровень — компромисс: выше изоляция → меньше аномалий → больше блокировок и rollback'ов. Выбирай минимальный, который покрывает аномалии в твоём сценарии.

2. Три уровня PostgreSQL

УровеньDirty readNon-repeatPhantomSerialization
READ UNCOMMITTED (PG = RC)предотвращаетразрешаетразрешаетразрешает
READ COMMITTED (default)предотвращаетразрешаетразрешаетразрешает
REPEATABLE READ (PG = snapshot)предотвращаетпредотвращаетпредотвращаетразрешает
SERIALIZABLEпредотвращаетпредотвращаетпредотвращаетпредотвращает

PG-IS-002 PostgreSQL READ COMMITTED строже, чем стандарт. Через MVCC dirty read невозможен в принципе.

PG-IS-003 PostgreSQL REPEATABLE READ строже, чем стандарт — реально это snapshot isolation. Phantom read предотвращён (стандарт это не требует на этом уровне).

PG-IS-004 Только PG SERIALIZABLE (через SSI — Serializable Snapshot Isolation) предотвращает все аномалии, включая write skew.

3. READ COMMITTED — дефолт и почему

PG-IS-010 READ COMMITTED (RC) — дефолт PostgreSQL. Внутри одной транзакции каждый SELECT видит самое последнее committed состояние на момент СВОЕГО запуска (не на момент BEGIN).

TX1: BEGIN;
TX1: SELECT price FROM product WHERE id=1;   -- 100

TX2: BEGIN;
TX2: UPDATE product SET price = 120 WHERE id=1;
TX2: COMMIT;

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

PG-IS-011 Non-repeatable и phantom — разрешены. В TX1 ту же строку прочитали и получили разные значения. Кажется страшным, но в OLTP это редко проблема: один логический «запрос» обычно читает каждую строку один раз и не сравнивает потом.

PG-IS-012 READ COMMITTED минимизирует блокировки. Транзакции не блокируют друг друга на чтении, что даёт максимальный throughput.

Когда хватает RC (большинство кейсов):

  • Простые CRUD (создал заказ → ответил клиенту).
  • API-эндпоинты, которые читают и сразу отвечают.
  • Запросы с SELECT FOR UPDATE для read-modify-write — лок защищает от race condition.

4. REPEATABLE READ — snapshot на всю транзакцию

PG-IS-020 REPEATABLE READ (RR) фиксирует snapshot на момент первого запроса в транзакции. Все последующие SELECT видят то же состояние, сколько бы ни длилась транзакция.

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- или: BEGIN ISOLATION LEVEL REPEATABLE READ;

BEGIN;
SELECT count(*) FROM orders WHERE status='NEW';  -- 100
-- (другая транзакция вставила новые orders и закоммитила)
SELECT count(*) FROM orders WHERE status='NEW';  -- всё ещё 100 (phantom предотвращён)
COMMIT;

PG-IS-021 Когда RR оправдан:

  • Длинный отчёт, который перечитывает таблицы в нескольких запросах и должен видеть консистентный срез.
  • pg_dump для backup (использует RR).
  • Перенос данных по сложной логике, где половина source может измениться, пока пишешь destination.

PG-IS-022 На RR UPDATE той же строки, что изменилась после snapshot, упадёт с 40001 serialization_failure. Java должен делать retry. Это плата за snapshot.

TX1 (RR): BEGIN;
TX1:      SELECT price FROM product WHERE id=1;   -- 100
TX2 (RC): UPDATE product SET price=120 WHERE id=1; COMMIT;
TX1:      UPDATE product SET price=110 WHERE id=1;
          ERROR: 40001 — could not serialize access due to concurrent update

PG-IS-023 На bulk-операциях RR может стать SLA-проблемой. Любая параллельная запись по тем же строкам = 40001 = retry. Не используй RR в hot path просто «на всякий случай».

5. SERIALIZABLE — full isolation через SSI

PG-IS-030 SERIALIZABLE гарантирует: любой результат параллельных транзакций — такой, какой получился бы, если бы они выполнялись последовательно. Это сильнее, чем RR.

PG-IS-031 Классический пример write skew, который RR пропускает, а SERIALIZABLE ловит:

Допустим, инвариант: «всегда хотя бы один врач на смене». В таблице 2 врача on-call.

TX1 (RR): BEGIN;
TX1: SELECT count(*) FROM doctors WHERE on_call=true;  -- 2
TX1: -- логика «можно уйти, остаётся хотя бы 1»
TX1: UPDATE doctors SET on_call=false WHERE id=1;

TX2 (RR): BEGIN;
TX2: SELECT count(*) FROM doctors WHERE on_call=true;  -- 2
TX2: UPDATE doctors SET on_call=false WHERE id=2;
TX2: COMMIT;

TX1: COMMIT;
-- инвариант нарушен: 0 врачей на смене

Под SERIALIZABLE одна из TX упадёт с 40001.

PG-IS-032 Когда нужен SERIALIZABLE:

  • Сложные инварианты, которые невозможно выразить через CHECK constraint или SELECT FOR UPDATE (например, инвариант на agregate уровне нескольких строк).
  • Финансовые расчёты с множественными правилами.
  • Где «правильность важнее throughput».

PG-IS-033 SERIALIZABLE дороже:

  • PG отслеживает зависимости между транзакциями (predicate locks).
  • Race-condition → откат с 40001 → retry на стороне приложения.
  • Под нагрузкой % rollback'ов растёт.

PG-IS-034 На большинстве OLTP — НЕ оправдан. Дешевле выразить инвариант через SELECT FOR UPDATE + ручной CHECK на тех же строках, оставив READ COMMITTED.

6. Spring — как задавать уровень

PG-IS-040 @Transactional(isolation = Isolation.REPEATABLE_READ) или Isolation.SERIALIZABLE на методе.

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

PG-IS-041 Isolation.READ_COMMITTED — дефолт. Не указывай явно. Пусть в коде явно стоит только то, что отличается от стандарта.

PG-IS-042 На SERIALIZABLE / REPEATABLE READ нужен retry на CannotSerializeTransactionException (PG код 40001):

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

3 retry с back-off обычно решают, потому что 40001 — почти всегда race, а не системная проблема.

7. Когда какой выбирать — практический алгоритм

PG-IS-050 Алгоритм выбора:

  1. Это простой read-modify-write одной строки?READ COMMITTED + SELECT FOR UPDATE.
  2. Read-only отчёт по нескольким таблицам, нужна консистентность срез?REPEATABLE READ.
  3. Длинный bulk-перенос данных?REPEATABLE READ (read-only часть) или READ COMMITTED с явной батчингом.
  4. Сложный инвариант на нескольких строках/таблицах, который нельзя выразить через FOR UPDATE?SERIALIZABLE + retry.
  5. Запись денег / счетов?READ COMMITTED + SELECT FOR UPDATE (упорядочить локи по id, см. Locks §9).

PG-IS-051 Когда сомнения — оставь дефолт. RC + правильные locks решает 95% задач. Поднятие уровня без понимания, какую конкретно аномалию ты предотвращаешь, — карго-культ.

8. Read-only транзакции

PG-IS-060 @Transactional(readOnly = true) говорит JDBC и Spring, что транзакция не пишет. Эффект:

  • HikariCP может направить на read-replica (если настроено routing — см. Connection pool §6).
  • jOOQ/JPA пропускает flush.
  • На уровне PG — никакого effect автоматически. PG не знает.

PG-IS-061 Чтобы PG знал — SET TRANSACTION READ ONLY в начале сессии. Spring это делает только если включено spring.jpa.properties.hibernate.connection.provider_disables_autocommit=true или вручную. Прирост перформанса небольшой — PG чуть оптимизирует MVCC.

9. idle_in_transaction_session_timeout

PG-IS-070 Серверная настройка idle_in_transaction_session_timeout автоматически убивает транзакции, которые открыты, но idle > N. Защита от забытых транзакций.

SET idle_in_transaction_session_timeout = '30s';   -- в сессии
-- или в postgresql.conf:
idle_in_transaction_session_timeout = 30000  -- ms

На проде ставь 30–60 сек. Транзакция, которая идёт дольше — почти всегда баг (см. WAL §8).

10. Антипаттерны

PG-IS-080 @Transactional(isolation = Isolation.SERIALIZABLE) на каждом методе «на всякий случай» — % rollback'ов скакнёт, throughput упадёт.

PG-IS-081 Isolation.REPEATABLE_READ на коротком read-modify-write вместо SELECT FOR UPDATE — RR не подходит для этого, нужен лок.

PG-IS-082 Поднимать уровень изоляции, когда корень проблемы — отсутствие constraint'а в схеме. Если можешь выразить инвариант через CHECK или EXCLUDE — это лучше, чем SERIALIZABLE.

PG-IS-083 SERIALIZABLE без retry на CannotSerializeTransactionException — будут «случайные» ошибки в проде.

PG-IS-084 Долгие RR/SERIALIZABLE транзакции — каждая держит snapshot, мешает autovacuum (см. VACUUM §6).


Чек-лист на ревью

  • [ ] Дефолт READ COMMITTED для большинства методов — никаких явных Isolation.
  • [ ] Isolation.REPEATABLE_READ — только для read-only отчётов с консистентным срезом.
  • [ ] Isolation.SERIALIZABLE — только когда инвариант не выражается через FOR UPDATE/CHECK; обязателен retry.
  • [ ] Все RR/SERIALIZABLE-методы — с @Retryable на CannotSerializeTransactionException.
  • [ ] @Transactional(readOnly = true) для запросов — для маршрутизации на реплику.
  • [ ] Серверный idle_in_transaction_session_timeout = 30–60s.
  • [ ] Длинные транзакции (> 5 сек) — устранены, не покрыты повышенным уровнем изоляции.

Связанные

  • Блокировки — SELECT FOR UPDATE как альтернатива поднятию уровня.
  • WAL — долгие RR/SERIALIZABLE-snapshots мешают autovacuum.
  • Connection pool — read-replica routing через readOnly.