Опирается на правила: PG-IS-001PG-IS-082 из PostgreSQL Style Guide → раздел Уровни изоляции.

Важно знать

  • PG поддерживает 3 уровня: READ COMMITTED (default), REPEATABLE READ (snapshot), SERIALIZABLE (SSI).
  • READ UNCOMMITTED не реализован — работает как RC.
  • PG RC строже стандарта — dirty read невозможен через MVCC.
  • PG RR строже стандарта — snapshot isolation, phantom предотвращён.
  • Только SERIALIZABLE ловит write skew (классика: «хотя бы один врач на смене»).
  • Дефолт RC в 95% случаев — non-repeatable и phantom в OLTP редко проблема.
  • 40001 serialization_failure на RR/SERIALIZABLE — нужен @Retryable.
  • @Transactional(readOnly = true) — для read-replica routing.
  • idle_in_transaction_session_timeout: 30s в проде.

PostgreSQL даёт три уровня изоляции. Это не «больше = лучше» — каждый компромисс. UCP формулирует алгоритм выбора.

Аномалии

АномалияЧто
Dirty readВидим uncommitted чужой TX
Non-repeatable readПеречитал строку — значение изменилось
Phantom readПеречитал WHERE — пришли новые строки
Serialization anomalyПараллельные TX нарушают инвариант, который каждая соблюдает (write skew)

Три уровня PG

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

PG-IS-001: уровни — компромисс. Выбирай минимальный под аномалии в твоём сценарии.

READ COMMITTED — дефолт

PG-IS-010..012: каждый SELECT видит самое последнее committed состояние на момент своего запуска.

TX1: BEGIN;
TX1: SELECT price FROM product WHERE id=1;   -- 100
TX2: UPDATE product SET price = 120 WHERE id=1; COMMIT;
TX1: SELECT price FROM product WHERE id=1;   -- 120 (non-repeatable!)

Non-repeatable и phantom разрешены. В OLTP редко проблема — обычно строку читают один раз.

Когда хватает RC:

  • Простой CRUD.
  • API-endpoints с одним чтением.
  • Read-modify-write с SELECT FOR UPDATE.

REPEATABLE READ — snapshot

PG-IS-020..023: snapshot на момент первого запроса в транзакции.

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

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

  • Длинный отчёт по нескольким таблицам с консистентным срезом.
  • pg_dump (использует RR).
  • Сложный bulk-перенос данных.

40001 на UPDATE изменённой после snapshot строки:

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

Java должен делать retry. На bulk-операциях RR может стать SLA-проблемой.

SERIALIZABLE — full isolation

PG-IS-030..034: результат параллельных TX = последовательное выполнение.

Write skew пример

Инвариант: «всегда хотя бы один врач на смене». 2 врача on-call.

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

TX2 (RR): 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.

Когда нужен:

  • Сложные инварианты, которые нельзя выразить через CHECK или FOR UPDATE.
  • Финансовые расчёты с множественными правилами.

Дорого:

  • Predicate locks для отслеживания зависимостей.
  • % rollback-ов растёт под нагрузкой.

PG-IS-034: на большинстве OLTP — не оправдан. Дешевле — SELECT FOR UPDATE + ручной CHECK, оставив RC.

Spring

PG-IS-040..042:

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

READ_COMMITTED — дефолт, не указывай явно.

Retry для RR/SERIALIZABLE:

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

Алгоритм выбора

PG-IS-050..051:

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

Сомнения → дефолт RC. Поднятие уровня без понимания конкретной аномалии — карго-культ.

Read-only

PG-IS-060..061:

@Transactional(readOnly = true)
public OrderSummary handle(GetOrderQuery query) { ... }

Эффект:

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

Для эффекта на PG: SET TRANSACTION READ ONLY или provider_disables_autocommit=true.

idle_in_transaction_session_timeout

PG-IS-070:

-- на проде
idle_in_transaction_session_timeout = 30000   -- 30s

Автоматически убивает забытые транзакции. Длинная TX почти всегда баг.

Что запрещено

АнтипаттернПравилоЧто взамен
SERIALIZABLE на каждом методеPG-IS-080минимальный нужный
RR на коротком read-modify-writePG-IS-081RC + SELECT FOR UPDATE
Поднимать isolation вместо CHECK constraintPG-IS-082declarative constraint
Isolation.READ_COMMITTED явно (= default)PG-IS-041не указывать
RR/SERIALIZABLE без @RetryablePG-IS-042CannotSerializeTransactionException
idle_in_transaction_session_timeout отключёнPG-IS-07030s минимум

Куда дальше

  • PG → Уровни изоляции — нормативные формулировки.
  • Блокировки и jOOQ — FOR UPDATE дополнение к RC.
  • Spring @Transactional — нюансы.
  • HikariCP — read-replica routing.
  • WAL — длинные TX и WAL.
  • Distributed → idempotency — concurrent write protection.