Learning Platform
Урок 13.05 · 18 мин
Средний
Isolation levelsREAD COMMITTEDREPEATABLE READSERIALIZABLEMVCCSnapshot isolation

Зачем уровни изоляции вообще существуют

Стандарт SQL-92 спрашивает фундаментальный вопрос: что должно происходить, когда две транзакции работают параллельно с одними данными?

Идеальный ответ — «как будто они выполнялись по очереди» — называется serializability и реализуется самым строгим уровнем. Но он дорогой: либо нужны блокировки (читатель ждёт писателя), либо много retry. На практике большинство приложений готовы пожертвовать строгостью ради скорости, если знают, какие именно гарантии они теряют.

Поэтому стандарт ввёл градацию: четыре уровня, каждый строже предыдущего. Разработчик выбирает уровень в зависимости от того, какие аномалии для его задачи терпимы.

Четыре уровня стандарта SQL-92

Стандарт SQL-92 определил четыре уровня изоляции в терминах какие аномалии разрешены. По возрастанию строгости:

УровеньDirty readNon-repeatablePhantomWrite skew
READ UNCOMMITTEDразрешёнразрешёнразрешёнразрешён
READ COMMITTEDзапрещёнразрешёнразрешёнразрешён
REPEATABLE READзапрещёнзапрещёнразрешёнразрешён
SERIALIZABLEзапрещёнзапрещёнзапрещёнзапрещён

Это формальное определение из стандарта. В реальности у каждой СУБД свои тонкости. PostgreSQL сильнее, чем стандарт требует: его REPEATABLE READ запрещает не только non-repeatable, но и phantom (благодаря snapshot isolation), а SERIALIZABLE отлавливает write skew через специальный механизм SSI.

Уровни изоляции в PostgreSQL: что реально гарантируется

PostgreSQL даёт три различимых уровня изоляции. READ UNCOMMITTED == READ COMMITTED, потому что дешевле всё равно нельзя.

READ UNCOMMITTED== READ COMMITTED в PostgreSQLPostgreSQL никогда не показывает грязные данные. Уровень принимается синтаксически, но трактуется как RC.
READ COMMITTED (default)dirty read запрещён, остальное разрешено
REPEATABLE READdirty + non-repeatable + phantom запрещены (через snapshot)PostgreSQL даёт здесь snapshot isolation — каждая транзакция видит снимок данных на момент своего старта. Это строже стандарта.
SERIALIZABLEвсе четыре аномалии запрещены (через SSI)Serializable Snapshot Isolation: на старте — snapshot, на коммите — проверка конфликтов. Если найден — одна из транзакций откатывается с ошибкой 40001.

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'ы:

PostgreSQL

Это устраивает большинство OLTP-приложений. Если ты делаешь короткие транзакции (создание заказа, обновление профиля) — non-repeatable read обычно не страшен: транзакция длится миллисекунды, шанс попасть в окно конкуренции минимальный.

Это не устраивает аналитику и отчёты: длинные SELECT, который читает миллион строк, в READ COMMITTED может встретить «движущуюся землю» — данные меняются под ним. Для таких случаев — REPEATABLE READ.

REPEATABLE READ — snapshot isolation

В REPEATABLE READ PostgreSQL делает

снимок (snapshot) в начале транзакции
и работает с ним до конца. Все SELECT внутри транзакции видят базу такой, какой она была на момент старта — даже если параллельно её меняют и коммитят.

REPEATABLE READ — snapshot фиксируется при первой команде. В одной сессии работа с собственными изменениями обычная:

PostgreSQL

Что важно: 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 это реализовано через

SSI (Serializable Snapshot Isolation)
— расширение snapshot isolation, которое отслеживает зависимости между транзакциями и откатывает «небезопасные» комбинации.

На практике это значит: ты пишешь обычный код в SERIALIZABLE, и если возникнет конфликт (например, классический write skew с двумя кассирами) — одна из транзакций откатится с ошибкой 40001 ровно в момент COMMIT. Приложение должно её retry’нуть.

Явный SERIALIZABLE. В одиночной сессии работает как обычно — но в параллельных запросах может вернуть serialization failure:

PostgreSQL

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) или явно в коде.

Как задаётся уровень

Есть три способа:

  1. На уровне транзакцииBEGIN ISOLATION LEVEL REPEATABLE READ; или SET TRANSACTION ISOLATION LEVEL ...; после BEGIN. Работает только в текущей транзакции.
  2. На уровне сессииSET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL ...;. Действует на все следующие транзакции в этой сессии.
  3. На уровне сервераdefault_transaction_isolation в postgresql.conf. Меняет default для всех соединений.

Большинство приложений ставит уровень на уровне транзакции — потому что разные транзакции в одном приложении хотят разной строгости (отчёт хочет REPEATABLE READ, перевод денег — SERIALIZABLE, обычное чтение — READ COMMITTED).

Меняем уровень на одну транзакцию. Все три варианта корректны:

PostgreSQL

Особенно полезно READ ONLY для отчётов: PostgreSQL знает, что ты не будешь писать, и может оптимизировать (например, не тратиться на отслеживание twophase commit’ов).

MVCC и почему PostgreSQL не блокирует читателей

Всё, что мы обсудили выше, держится на одной идее —

multiversion concurrency control (MVCC)
. PostgreSQL никогда не перезаписывает строку «на месте». Каждое изменение создаёт новую версию строки рядом со старой. Каждая версия помечается:

  • xmin — id транзакции, которая её создала.
  • xmax — id транзакции, которая её удалила/обновила (или 0, если ещё живая).

При чтении транзакция смотрит на свой snapshot и выбирает «правильную» версию: видна та, у которой xmin уже закоммитился до snapshot, а xmax — либо нет, либо ещё не закоммитился.

Это объясняет ключевое свойство PostgreSQL: читатели не блокируют писателей, писатели не блокируют читателей. На любом уровне изоляции SELECT никогда не ждёт UPDATE и наоборот — они просто работают с разными версиями одной строки. Блокировки появляются только когда два писателя пытаются изменить одну строку — и тогда второй ждёт первого.

MVCC: одна строка — много версий

Каждое изменение создаёт новую версию. Старые не удаляются сразу — пока хоть одна транзакция их видит, они живут.

версия 1 (xmin=100, xmax=200)balance = 50, создана t=100, удалена t=200Видна snapshot'ам, начавшим до t=200
версия 2 (xmin=200, xmax=350)balance = 60, создана t=200, удалена t=350Видна snapshot'ам в окне [200, 350)
версия 3 (xmin=350, xmax=0)balance = 80, актуальная, ещё живаяВидна всем, кто начал после t=350
VACUUM позжеудалит версии 1 и 2, когда snapshot'ов, их видящих, не останется

Цена 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.

Никакого «один уровень для всех» — каждая транзакция выбирает свой компромисс.

Выбор уровня изоляции — прагматичный гайд

Не существует «универсально правильного» уровня. Каждый сценарий — свой компромисс между корректностью, скоростью и сложностью кода.

Короткая транзакция, мало конкуренцииREAD COMMITTED
Длинный отчёт, нужна стабильная картинкаREPEATABLE READ READ ONLY
Финансы, инвентарь — нужна защита от write skewSERIALIZABLE (или RC + SELECT FOR UPDATE)
OLTP с высокой конкуренциейRC + явные локи (FOR UPDATE), а не SERIALIZABLE retryНа очень нагруженных таблицах SERIALIZABLE даёт много retry. Явные локи дают предсказуемую блокировку.

Что не делает уровень изоляции

Уровень изоляции — это не про блокировки на чтение. PostgreSQL не блокирует читателей и не блокирует писателей читателями ни на одном уровне. Это фундаментальное свойство MVCC: «readers don’t block writers, writers don’t block readers».

Если тебе нужно явно заблокировать строку (например, чтобы другая транзакция её не изменила, пока ты её обрабатываешь) — это делается через SELECT FOR UPDATE (урок 6). Уровень изоляции и блокировки — это два разных механизма, которые часто используют вместе.

Проверка знанийKnowledge check
Транзакция A на REPEATABLE READ читает баланс счёта, видит 100. В этот момент транзакция B (тоже RR) увеличивает баланс до 120 и коммитит. A потом делает UPDATE balance = balance + 50 WHERE id = ?. Что произойдёт?
ОтветAnswer
A получит ошибку: could not serialize access due to concurrent update (SQLSTATE 40001). Причина: snapshot A видел balance=100, но реальная строка в базе уже balance=120 (после COMMIT B). Чтобы UPDATE был консистентным со snapshot'ом A, нужно бы делать его на старом значении — но это «потеряет» обновление B. PostgreSQL не может выбрать между этими вариантами и откатывает A с ошибкой. Приложение должно перехватить эту ошибку и retry'нуть транзакцию A целиком. На retry новый snapshot увидит balance=120, и логика A может либо отказаться (если уже слишком много), либо применить +50 поверх свежего значения (170). На READ COMMITTED такой ошибки бы не было: A прочитал бы балланс свежим SELECT'ом в момент UPDATE, увидел 120, и применил +50. Но эта семантика опаснее: между SELECT и UPDATE баланс мог измениться ещё раз — это lost update.

Сводная таблица: что запрещает каждый уровень в PostgreSQL

dirty readnon-repeatablephantomlost updatewrite 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).

Как MVCC обеспечивает REPEATABLE READ без блокировок Почему долгие транзакции мешают vacuum и раздувают таблицы

Чек-лист

  • 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).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какой уровень изоляции работает в PostgreSQL по умолчанию?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6