Уровни изоляции и блокировки в 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.