Когда несколько транзакций работают с базой одновременно, они могут мешать друг другу. Уровень изоляции определяет, насколько строго транзакция «огорожена» от чужих изменений. Разберём с нуля: какие проблемы вообще бывают, какой уровень что решает, и как не попасть на неочевидные ошибки.
Что может пойти не так при параллельных транзакциях
Представьте: два пользователя одновременно меняют одни и те же данные. Без изоляции возникают классические аномалии:
Грязное чтение — одна транзакция видит незакоммиченные данные другой. Если та откатится, первая прочитала «воздух». В 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 попыток с нарастающей паузой.
Как выбрать уровень
Практический алгоритм:
- Простой CRUD или read-modify-write одной строки →
READ COMMITTED+SELECT FOR UPDATEтам, где нужна атомарность. - Длинный отчёт по нескольким таблицам с согласованным срезом →
REPEATABLE READ. - Сложный инвариант на нескольких строках, нельзя выразить через
FOR UPDATE→SERIALIZABLE+ retry. - Финансовые операции →
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 транзакций на реплику.