Зачем уровни изоляции вообще существуют
Стандарт SQL-92 спрашивает фундаментальный вопрос: что должно происходить, когда две транзакции работают параллельно с одними данными?
Идеальный ответ — «как будто они выполнялись по очереди» — называется serializability и реализуется самым строгим уровнем. Но он дорогой: либо нужны блокировки (читатель ждёт писателя), либо много retry. На практике большинство приложений готовы пожертвовать строгостью ради скорости, если знают, какие именно гарантии они теряют.
Поэтому стандарт ввёл градацию: четыре уровня, каждый строже предыдущего. Разработчик выбирает уровень в зависимости от того, какие аномалии для его задачи терпимы.
Четыре уровня стандарта SQL-92
Стандарт SQL-92 определил четыре уровня изоляции в терминах какие аномалии разрешены. По возрастанию строгости:
| Уровень | Dirty read | Non-repeatable | Phantom | Write skew |
|---|---|---|---|---|
| READ UNCOMMITTED | разрешён | разрешён | разрешён | разрешён |
| READ COMMITTED | запрещён | разрешён | разрешён | разрешён |
| REPEATABLE READ | запрещён | запрещён | разрешён | разрешён |
| SERIALIZABLE | запрещён | запрещён | запрещён | запрещён |
Это формальное определение из стандарта. В реальности у каждой СУБД свои тонкости. PostgreSQL сильнее, чем стандарт требует: его REPEATABLE READ запрещает не только non-repeatable, но и phantom (благодаря snapshot isolation), а SERIALIZABLE отлавливает write skew через специальный механизм SSI.
PostgreSQL даёт три различимых уровня изоляции. READ UNCOMMITTED == READ COMMITTED, потому что дешевле всё равно нельзя.
READ UNCOMMITTED — фантомный уровень
Стандарт SQL-92 определяет READ UNCOMMITTED как самый слабый: dirty read разрешён. То есть транзакция могла бы видеть незакоммиченные изменения других транзакций. Это позволяло бы делать «грязный» offset скан или приблизительные счётчики, ничего не блокируя.
PostgreSQL принимает синтаксис SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED, но фактически трактует его как READ COMMITTED. Почему так?
Причина — архитектура. PostgreSQL построен на MVCC: каждое изменение создаёт новую версию строки, помеченную id транзакции. При чтении версия видна или не видна в зависимости от snapshot’а. Чтобы реализовать «настоящий» READ UNCOMMITTED, пришлось бы поломать MVCC — научиться читать незакоммиченные версии. Это слишком дорого по сложности, и пользы мало: dirty read настолько опасен, что разумных применений ему почти нет.
Поэтому в PostgreSQL у нас фактически три уровня изоляции: READ COMMITTED, REPEATABLE READ, SERIALIZABLE. Все три гарантируют отсутствие dirty read. Это даёт PostgreSQL очень удобный нижний этаж: даже на самом слабом уровне «совсем плохих вещей» не происходит.
READ COMMITTED — default в PostgreSQL
READ COMMITTED — это уровень, на котором ты работаешь по умолчанию, если ничего не настраивал. Это разумный компромисс: грязных чтений нет, но всё остальное — может быть.
Внутри PostgreSQL READ COMMITTED работает так: каждый отдельный SELECT внутри транзакции получает свежий snapshot. То есть если транзакция делает SELECT, потом другой SELECT, между ними другая транзакция сделала COMMIT — второй SELECT увидит новые данные. Это и есть non-repeatable read.
READ COMMITTED — каждый SELECT свежий. Здесь только одна сессия, но если бы их было две, чужие COMMIT влияли бы на твои SELECT'ы:
Это устраивает большинство OLTP-приложений. Если ты делаешь короткие транзакции (создание заказа, обновление профиля) — non-repeatable read обычно не страшен: транзакция длится миллисекунды, шанс попасть в окно конкуренции минимальный.
Это не устраивает аналитику и отчёты: длинные SELECT, который читает миллион строк, в READ COMMITTED может встретить «движущуюся землю» — данные меняются под ним. Для таких случаев — REPEATABLE READ.
REPEATABLE READ — snapshot isolation
В REPEATABLE READ PostgreSQL делает
SELECT внутри транзакции видят базу такой, какой она была на момент старта — даже если параллельно её меняют и коммитят.
REPEATABLE READ — snapshot фиксируется при первой команде. В одной сессии работа с собственными изменениями обычная:
Что важно: PostgreSQL не блокирует другие транзакции на REPEATABLE READ. Они продолжают свои UPDATE и COMMIT — просто твоя транзакция их не видит. Это «снимок прошлого», и работа с ним не мешает «живой» базе.
Цена REPEATABLE READ: если ты сам делаешь UPDATE строки, которую кто-то параллельно изменил и закоммитил — ты получишь ошибку could not serialize access due to concurrent update (SQLSTATE 40001). Это значит: PostgreSQL не может гарантировать, что твоё обновление консистентно со snapshot’ом, и просит тебя начать транзакцию заново.
Приложение должно быть готово к таким ретраям. Это типичный паттерн: try → except SerializationFailure → retry.
Когда REPEATABLE READ — лучший выбор
Самый прямой кейс для RR — это отчёты. Представь, что ты считаешь дневной отчёт по продажам: выручка по категориям, топ-10 товаров, средний чек. Это десятки запросов на одни и те же таблицы — orders, order_items, products.
На READ COMMITTED между двумя SELECT’ами могут пройти чужие COMMIT, и одна цифра отчёта может не сходиться с другой («сумма всех категорий не равна общей выручке на 50 копеек — потому что между запросами появился ещё один заказ»).
На REPEATABLE READ READ ONLY — все SELECT’ы видят один и тот же снимок. Цифры сходятся точно. PostgreSQL не тратится на отслеживание twophase commit’ов (благодаря READ ONLY). Цена — длинная транзакция мешает VACUUM (но раз в час на минуту — терпимо).
Это стандартный паттерн для аналитических pipeline’ов внутри PostgreSQL (когда не хочется тянуть данные в OLAP).
SERIALIZABLE — самая строгая
SERIALIZABLE обещает, что результат параллельных транзакций будет эквивалентен какому-то последовательному расписанию. Это «как будто все транзакции выполнялись по очереди» — но физически они идут параллельно.
В PostgreSQL это реализовано через
На практике это значит: ты пишешь обычный код в SERIALIZABLE, и если возникнет конфликт (например, классический write skew с двумя кассирами) — одна из транзакций откатится с ошибкой 40001 ровно в момент COMMIT. Приложение должно её retry’нуть.
Явный SERIALIZABLE. В одиночной сессии работает как обычно — но в параллельных запросах может вернуть serialization failure:
SERIALIZABLE — единственный уровень, который защищает от write skew без явных блокировок. Цена — нужно ловить retry. На системах с высокой конкуренцией процент retry может стать заметным.
Производительность уровней
Чем строже уровень, тем дороже. Но «дороже» в PostgreSQL — не блокировки, а повышенная вероятность retry.
- READ COMMITTED — никаких retry, никаких блокировок чтения. Самый высокий throughput.
- REPEATABLE READ — может вернуть 40001 при lost update. На умеренной конкуренции — единицы процентов retry.
- SERIALIZABLE — может вернуть 40001 даже на «казалось бы» независимых транзакциях (через rw-dependency detection). На горячих таблицах процент retry может быть высоким.
Это важно для приложения: каждый retry — это полностью повторённая транзакция (новый snapshot, повторный SELECT, повторный UPDATE). Если retry-логика плохо написана, приложение может попасть в loop. Хорошая retry-логика: пробуем 3-5 раз с exponential backoff, потом сдаёмся и возвращаем ошибку пользователю.
В драйверах вроде psycopg или JDBC можно настроить автоматический retry на 40001 — но обычно это делают на уровне фреймворка (Django, Rails, Spring) или явно в коде.
Как задаётся уровень
Есть три способа:
- На уровне транзакции —
BEGIN ISOLATION LEVEL REPEATABLE READ;илиSET TRANSACTION ISOLATION LEVEL ...;послеBEGIN. Работает только в текущей транзакции. - На уровне сессии —
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL ...;. Действует на все следующие транзакции в этой сессии. - На уровне сервера —
default_transaction_isolationвpostgresql.conf. Меняет default для всех соединений.
Большинство приложений ставит уровень на уровне транзакции — потому что разные транзакции в одном приложении хотят разной строгости (отчёт хочет REPEATABLE READ, перевод денег — SERIALIZABLE, обычное чтение — READ COMMITTED).
Меняем уровень на одну транзакцию. Все три варианта корректны:
Особенно полезно READ ONLY для отчётов: PostgreSQL знает, что ты не будешь писать, и может оптимизировать (например, не тратиться на отслеживание twophase commit’ов).
MVCC и почему PostgreSQL не блокирует читателей
Всё, что мы обсудили выше, держится на одной идее —
xmin— id транзакции, которая её создала.xmax— id транзакции, которая её удалила/обновила (или 0, если ещё живая).
При чтении транзакция смотрит на свой snapshot и выбирает «правильную» версию: видна та, у которой xmin уже закоммитился до snapshot, а xmax — либо нет, либо ещё не закоммитился.
Это объясняет ключевое свойство PostgreSQL: читатели не блокируют писателей, писатели не блокируют читателей. На любом уровне изоляции SELECT никогда не ждёт UPDATE и наоборот — они просто работают с разными версиями одной строки. Блокировки появляются только когда два писателя пытаются изменить одну строку — и тогда второй ждёт первого.
Каждое изменение создаёт новую версию. Старые не удаляются сразу — пока хоть одна транзакция их видит, они живут.
Цена MVCC — место на диске и работа VACUUM. Старые версии висят до тех пор, пока хоть одна транзакция их видит. Длинная транзакция (например, забытый BEGIN в psql) может заблокировать VACUUM и «раздуть» таблицу (table bloat) — это классическая операционная боль PostgreSQL.
Как выбрать уровень
Прагматичный гайд:
- OLTP-приложение, короткие транзакции —
READ COMMITTED(default). Нон-репитабельные чтения почти не встречаются на коротких транзакциях. Полагайся на CHECK/UNIQUE для критичных инвариантов. - Перевод денег, бронирование, инвентарь — либо
READ COMMITTED+SELECT FOR UPDATE(явная блокировка нужной строки), либоSERIALIZABLE. Второй проще писать, первый — предсказуемее по latency. - Большой отчёт или аналитика —
REPEATABLE READ READ ONLY. Snapshot гарантирует консистентность, READ ONLY экономит ресурсы. - Везде, где нужны строгие инварианты между разными таблицами —
SERIALIZABLE. Это единственный уровень, защищающий от write skew без явных локов.
В крупных приложениях обычно микс. Например, в типичном e-commerce:
- Регистрация пользователя — READ COMMITTED (никакой конкуренции, защита уникальности email — через UNIQUE).
- Просмотр товаров и поиск — READ COMMITTED.
- Создание заказа со списанием товара — READ COMMITTED + SELECT FOR UPDATE на строку товара.
- Дневной отчёт продаж — REPEATABLE READ READ ONLY.
- Перевод денег между счетами в loyalty-программе — SERIALIZABLE.
Никакого «один уровень для всех» — каждая транзакция выбирает свой компромисс.
Не существует «универсально правильного» уровня. Каждый сценарий — свой компромисс между корректностью, скоростью и сложностью кода.
Что не делает уровень изоляции
Уровень изоляции — это не про блокировки на чтение. PostgreSQL не блокирует читателей и не блокирует писателей читателями ни на одном уровне. Это фундаментальное свойство MVCC: «readers don’t block writers, writers don’t block readers».
Если тебе нужно явно заблокировать строку (например, чтобы другая транзакция её не изменила, пока ты её обрабатываешь) — это делается через SELECT FOR UPDATE (урок 6). Уровень изоляции и блокировки — это два разных механизма, которые часто используют вместе.
Сводная таблица: что запрещает каждый уровень в PostgreSQL
| dirty read | non-repeatable | phantom | lost update | write skew | |
|---|---|---|---|---|---|
| READ COMMITTED | запрещено | разрешено | разрешено | разрешено | разрешено |
| REPEATABLE READ | запрещено | запрещено | запрещено (sic) | запрещено (через 40001) | разрешено |
| SERIALIZABLE | запрещено | запрещено | запрещено | запрещено | запрещено |
Обрати внимание: PostgreSQL REPEATABLE READ запрещает больше, чем требует стандарт SQL-92. Стандарт разрешает phantom на RR — PostgreSQL запрещает (благодаря snapshot isolation). Это часто удивляет тех, кто пришёл из других СУБД.
Также важно: «запрещено» на REPEATABLE READ для lost update и не-repeatable не означает «никогда не случится». Это означает: PostgreSQL ловит ситуацию и кидает 40001. Приложение должно retry. Если приложение не retry’нет — пользователь увидит ошибку.
Когда snapshot isolation недостаточно
Главная «дыра» snapshot isolation — write skew, который мы разобрали в прошлом уроке. SI не отслеживает зависимости между транзакциями, она только защищает от lost update на одной строке.
Конкретный кейс: «два врача берут выходной». На RR обе транзакции читают «есть второй врач на смене», обе пишут «беру выходной» (разные строки). На SERIALIZABLE PostgreSQL замечает rw-зависимость — A прочитала row, которую B изменила, — и при наличии цикла зависимостей откатывает одну.
Поэтому если вашему сценарию важно, чтобы инвариант «между разными строками» сохранялся — RR недостаточно. Нужен либо SERIALIZABLE, либо явная блокировка через SELECT FOR UPDATE (см. урок 6).
Чек-лист
- 4 уровня изоляции стандарта SQL-92, в PostgreSQL различимы только 3 (READ UNCOMMITTED == READ COMMITTED).
- READ COMMITTED (default) — каждый SELECT свежий, dirty read невозможен. Аномалии разрешены: non-repeatable, phantom, write skew.
- REPEATABLE READ — snapshot isolation: вся транзакция видит снимок начала. PostgreSQL ловит lost update и phantom как часть RR.
- SERIALIZABLE — SSI: результат эквивалентен последовательному порядку. Защищает от write skew без явных локов.
- Уровень задаётся через
BEGIN ISOLATION LEVEL ...(на транзакцию),SET SESSION ...(на сессию),default_transaction_isolation(на сервер). - На REPEATABLE READ и SERIALIZABLE приложение должно уметь retry’нуть транзакцию при serialization failure (SQLSTATE 40001).
- Уровень изоляции не блокирует читателей — для явных блокировок строк есть
SELECT FOR UPDATE(урок 6).