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 read | Non-repeat | Phantom | Serialization |
|---|---|---|---|---|
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:
- Сложные инварианты, которые невозможно выразить через
CHECKconstraint или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 Алгоритм выбора:
- Это простой read-modify-write одной строки? →
READ COMMITTED+SELECT FOR UPDATE. - Read-only отчёт по нескольким таблицам, нужна консистентность срез? →
REPEATABLE READ. - Длинный bulk-перенос данных? →
REPEATABLE READ(read-only часть) илиREAD COMMITTEDс явной батчингом. - Сложный инвариант на нескольких строках/таблицах, который нельзя выразить через FOR UPDATE? →
SERIALIZABLE+ retry. - Запись денег / счетов? →
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.