Уровни изоляции и блокировки в PostgreSQL

PostgreSQL реализует уровни изоляции через снимки MVCC, а не через блокировки чтения — и это меняет то, как вы пишете конкурентный код именно под него.

В PostgreSQL уровень изоляции определяет, какой снимок версий видит транзакция, а блокировки нужны лишь для согласования пишущих. Поэтому здесь всего три различимых уровня (Read Committed, Repeatable Read, Serializable), а Read Uncommitted ведёт себя как Read Committed.

Общую теорию уровней изоляции и аномалий мы уже разбирали. Этот урок — про реализацию в PostgreSQL: чем здесь Repeatable Read строже стандарта, что такое SSI на Serializable, какие явные блокировки бывают и зачем нужны advisory locks. Всё это опирается на xmin/xmax и снимки из первого урока раздела.

Зачем это на практике

Дефолт PostgreSQL — Read Committed, и большинство приложений на нём и работают, часто не зная об этом. Но как только появляется логика «прочитал → решил → записал» под конкурентной нагрузкой, выбор уровня и блокировок становится вопросом корректности денег и остатков. Понимание, что PostgreSQL даёт согласованность через снимки, а Serializable может откатить вашу транзакцию, позволяет писать конкурентный код, который не теряет обновления и не падает непредсказуемо.

Три уровня изоляции в PostgreSQL

Уровень задаётся при старте транзакции. Поведение каждого — прямое следствие того, какой снимок берётся.

УровеньСнимокОсобенность в PostgreSQL
Read Committed (по умолчанию)новый снимок на каждый запросвидит чужие коммиты между запросами; неповторяемое чтение и фантомы возможны
Repeatable Readодин снимок на всю транзакциюне только повторяемое чтение, но и нет фантомов (строже стандарта); конфликт записи даёт ошибку сериализации
Serializableснимок на транзакцию + SSIполная сериализуемость через отслеживание зависимостей; может откатить транзакцию
BEGIN ISOLATION LEVEL REPEATABLE READ;
  SELECT ...;   -- весь блок видит один согласованный снимок
COMMIT;

-- или на уровне команды установки:
BEGIN;
  SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  ...
COMMIT;

Read Committed: дефолт и его ловушка

На Read Committed каждый оператор берёт свежий снимок, поэтому два SELECT в одной транзакции могут вернуть разное. Тонкость PostgreSQL: когда UPDATE натыкается на строку, заблокированную и изменённую другой транзакцией, после её коммита он перечитывает новую версию и применяется к ней (re-check). Это спасает от части гонок, но не от классического «потерянного обновления» в паттерне «прочитал значение в приложение → посчитал → записал».

Repeatable Read: снимок на всю транзакцию

Здесь снимок фиксируется в начале и держится до конца — вся транзакция видит мир «замороженным» на свой старт. В PostgreSQL это исключает и неповторяемое чтение, и фантомы. Расплата: если ваша транзакция попытается изменить строку, которую после вашего снимка уже изменил и закоммитил кто-то другой, вы получите ошибку:

ERROR:  could not serialize access due to concurrent update

Это нормальный исход, а не сбой: приложение должно поймать ошибку и повторить транзакцию на свежем снимке.

Serializable и SSI

Serializable в PostgreSQL построен на SSI (Serializable Snapshot Isolation): сервер не ставит блокировки чтения, а отслеживает опасные зависимости между транзакциями и, обнаружив структуру, способную нарушить эквивалентность последовательному выполнению, откатывает одну из транзакций.

ERROR:  could not serialize access due to read/write dependencies among transactions
HINT:   The transaction might succeed if retried.

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

Явные блокировки

Иногда снимка мало и нужно явно сказать «эту строку беру под изменение». Блокировки строк ставятся вариантами SELECT ... FOR:

КонструкцияНазначение
FOR UPDATEберу строку на изменение/удаление; остальные писатели ждут
FOR NO KEY UPDATEслабее: не мешает внешним ключам ссылаться на строку
FOR SHAREразделяемая: не дать изменить строку, но позволить другим тоже читать «под защитой»
FOR ... NOWAITне ждать замок: если занят — сразу ошибка
FOR ... SKIP LOCKEDпропустить занятые строки — основа очередей задач
-- паттерн очереди: каждый воркер забирает свою строку, не толкаясь
BEGIN;
  SELECT id, payload FROM jobs
  WHERE status = 'new'
  ORDER BY id
  FOR UPDATE SKIP LOCKED      -- занятые другими воркерами строки пропускаем
  LIMIT 1;
  -- ... обработка, затем пометить done ...
COMMIT;

Таблицу целиком блокирует LOCK TABLE (нужен редко) и неявно — DDL. Уровни табличных замков в PostgreSQL различаются по совместимости; например, обычный ALTER TABLE берёт ACCESS EXCLUSIVE и на время блокирует даже читателей, поэтому миграции на больших таблицах планируют отдельно.

Advisory locks

Иногда нужно синхронизировать логику приложения, а не строки: «пусть отчёт за этот месяц считает только один процесс». Для этого есть advisory locks — блокировки по произвольному числовому ключу, смысл которого определяете вы; PostgreSQL их не связывает ни с какими данными.

-- захватить на время сессии (ключ — любое 64-битное число, например хеш операции)
SELECT pg_advisory_lock(42);
--  ... критическая секция: гарантированно один исполнитель ...
SELECT pg_advisory_unlock(42);

-- неблокирующий вариант: TRUE — захватил, FALSE — занято
SELECT pg_try_advisory_lock(42);

-- автоматически снимется в конце транзакции
SELECT pg_advisory_xact_lock(42);

Это удобный распределённый мьютекс «из коробки» для джоб, шедулеров и единичных задач обслуживания — не нужна отдельная инфраструктура вроде Redis.

Как это работает под капотом

Обнаружение взаимоблокировок встроено. Когда транзакция встаёт в ожидание занятого замка, она не висит вечно: по тайм-ауту deadlock_timeout (по умолчанию 1 с) PostgreSQL строит граф ожидания «кто кого ждёт» и ищет цикл. Найдя — выбирает жертву и откатывает её с ошибкой deadlock detected, разрывая тупик; вторая транзакция продолжает. Важно: тайм-аут — это задержка перед проверкой, а не «сколько ждать замок» — обычное ожидание свободного замка длится столько, сколько нужно. Все ожидания и держателей замков видно в pg_locks в связке с pg_stat_activity — это первое место, куда смотрят при «непонятных» зависаниях.

Частые ошибки

  • Считать дефолтный Read Committed «достаточным» для денег. Паттерн «прочитал баланс в приложение → списал» на нём допускает потерянное обновление; нужен FOR UPDATE или Repeatable/Serializable с повтором.
  • Использовать Repeatable Read или Serializable без логики повтора. Ошибки сериализации — штатный исход; без retry приложение начнёт падать под нагрузкой.
  • Тяжёлый ALTER TABLE в час пик. ACCESS EXCLUSIVE блокирует даже SELECT — миграции крупных таблиц делают аккуратно и в окно.
  • Путать advisory locks с блокировками строк. Advisory lock ничего не запирает в данных — он работает, только если все участники договорились брать тот же ключ.
  • Очередь без SKIP LOCKED. Воркеры выстраиваются в очередь на одной строке вместо параллельной разборки задач.

Итоги

  • PostgreSQL реализует изоляцию через снимки MVCC: Read Committed берёт снимок на каждый запрос, Repeatable Read и Serializable — один на транзакцию.
  • Repeatable Read в PostgreSQL строже стандарта (нет фантомов); Serializable использует SSI и может откатить транзакцию — обе требуют повтора при ошибке сериализации.
  • Явные блокировки строк: FOR UPDATE/FOR SHARE, модификаторы NOWAIT и SKIP LOCKED (основа очередей задач).
  • Advisory locks — блокировки по произвольному ключу для синхронизации логики приложения, готовый распределённый мьютекс.
  • Deadlock обнаруживается автоматически по deadlock_timeout; держателей и ожидающих видно в pg_locks и pg_stat_activity.
Проверьте себя
1. Какой уровень изоляции используется в PostgreSQL по умолчанию и как он берёт снимок?
ASerializable — один снимок на всю транзакцию
BRead Committed — новый снимок на каждый запрос внутри транзакции
CRead Uncommitted — видит незакоммиченные изменения
DRepeatable Read — один снимок на всю транзакцию
2. Чем Repeatable Read в PostgreSQL строже требований стандарта SQL?
AОн разрешает грязное чтение
BОн не допускает не только неповторяемого чтения, но и фантомов
CОн блокирует всю таблицу на запись
DОн работает быстрее Read Committed
3. Для чего предназначен SELECT ... FOR UPDATE SKIP LOCKED?
AЧтобы заблокировать всю таблицу целиком
BЧтобы при выборке пропускать строки, уже заблокированные другими транзакциями — удобно для очередей задач
CЧтобы читать незакоммиченные данные
DЧтобы отключить блокировки полностью
4. Что такое advisory lock в PostgreSQL?
AБлокировка, которую сервер автоматически ставит на изменяемые строки
BБлокировка по произвольному числовому ключу, смысл которого задаёт приложение; с данными она не связана
CСпециальный режим VACUUM
DБлокировка на уровне всей базы данных при бэкапе