Прямой ответ
Транзакция — группа операций «всё или ничего» (атомарность), а уровень изоляции определяет, что параллельные транзакции видят друг у друга; дефолтный в Postgres READ COMMITTED покрывает большинство задач, и переходить на REPEATABLE READ (снапшот на всю транзакцию — защита от неповторяющегося чтения) стоит только когда транзакция читает данные несколько раз и требует их неизменности (пересчёт баланса по нескольким таблицам). Важно понимать: изоляция не решает гонки записи — «прочитал счётчик, увеличил, записал» двумя параллельными запросами теряет инкремент при любом уровне; инструменты против гонок другие: атомарный SQL (UPDATE ... SET n = n + 1 вместо read-modify-write в коде), SELECT FOR UPDATE (строка блокируется до конца транзакции — конкурент подождёт и прочитает свежее), уникальные ограничения (гонка вставок — идемпотентность) и advisory locks для «одной задачи на кластер». И дисциплина, важнее которой нет: транзакции должны быть короткими — блокировки и старые снапшоты держатся до COMMIT, а транзакция, открытая на время размышлений приложения/HTTP-запроса к внешнему API, — источник блокировок, раздувания таблицы и «необъяснимых» таймаутов у остальных.
Уровни изоляции: что реально даёт каждый
Стандартная четвёрка в приложении к Postgres: READ UNCOMMITTED — в Postgres фактически равен READ COMMITTED (грязное чтение не допускается вовсе — приятное отличие от других СУБД); READ COMMITTED (дефолт) — каждый оператор видит снапшот на момент своего начала: в пределах одной транзакции два SELECT одной строки могут дать разные значения (конкурент закоммитил между ними — «неповторяющееся чтение» разрешено); REPEATABLE READ — снапшот на первую команду транзакции: все чтения видят одно состояние, параллельная модификация целевой строки вызывает ошибку сериализации (could not serialize access — транзакцию надо повторить целиком; в Postgres это snapshot isolation, формально строже стандарта — фантомов тоже нет); SERIALIZABLE — полный SSIO с детектором конфликтов: корректность ценой частых сериализационных откатов — ниша для критичных вычислений без ручных блокировок. Практика: 95% кода живёт на READ COMMITTED; REPEATABLE READ — для транзакций-вычислений (бухгалтерия, отчёты на согласованном срезе) с готовым ретраем на сериализационную ошибку; SERIALIZABLE — когда доказуемая корректность важнее throughput.
Гонки записи: паттерны и их закрытие
Классика «потерянный инкремент»: два запроса читают stock=5, оба пишут stock=4 — одна продажа потеряна. Закрытия по возрастанию инвазивности: атомарный SQL — UPDATE products SET stock = stock - 1 WHERE id=%s AND stock > 0 (проверка в том же предложении; rowcount=0 — не хватило; БД сама сериализует строки); SELECT FOR UPDATE — SELECT ... FOR UPDATE блокирует строку: вторая транзакция ждёт COMMIT первой и читает уже обновлённое значение — паттерн «прочитать-решить-записать» под явной блокировкой (в SQLAlchemy — session.execute(select(...).with_for_update()) — сессии); оптимистичная блокировка — версия в строке: UPDATE ... SET version=version+1 WHERE id=%s AND version=%s (rowcount=0 — конфликт, повторить бизнес-логику; хорошо для редких конфликтов и долгих форм); уникальный индекс — гонка параллельных вставок одного заказа (двойной клик, ретрай) ловится констрейнтом, а не проверкой «существует ли» (между SELECT и INSERT всегда влезет конкурент). Отдельный случай — «одна фоновая задача на весь кластер»: advisory lock (pg_try_advisory_lock(hash) — кооперативная блокировка по числу: взял — работаешь, не взял — пропустил) проще и надёжнее самописных флагов в таблице.
Блокировки: кто кого держит и как это видеть
Блокировки в Postgres в основном строковые (MVCC — читатели не блокируют писателей и наоборот — главное достоинство движка); проблемы начинаются от DDL и явных FOR UPDATE/LOCK. Рабочие запросы диагностики: SELECT * FROM pg_locks WHERE NOT granted — кто ждёт; pg_blocking_pids(pid) — цепочка «кто держит»; связка с pg_stat_activity (запрос и длительность блокирующего — «транзакция висит 10 минут» обычно видна сразу). Типовые инциденты: DDL против данных (ALTER TABLE требует ACCESS EXCLUSIVE — одна долгая транзакция держит очередь на всю таблицу: DDL с lock_timeout='3s' и повтором вместо ожидания вечно); мертвые блокировки (две транзакции взяли строки в разном порядке — deadlock detector убивает одну через секунду — лечится единым порядком блокирования в коде); idle in transaction — транзакция открыта, приложение «думает» (ждёт внешний API, завис воркер) — блокировки держатся, вакуум не может чистить мёртвые строки (таблица раздувается): idle_in_transaction_session_timeout = '60s' в конфиге + автокоммит по умолчанию и явные транзакции только там, где нужны (пулинг: transaction-режим PgBouncer и вовсе не терпит открытых транзакций между запросами).
Практический сценарий: списание остатков без потерь
Задача: два сервиса одновременно списывают товар; в логах — расхождение учёта на единицы в день. Разбор: код читал остаток ORM-объектом, вычислял в Python и сохранял новое значение — классический read-modify-write под READ COMMITTED. Исправление по шагам: (1) списание одним атомарным UPDATE с проверкой (stock >= qty) — гонка инкрементов закрыта; (2) там, где решение сложное (промокоды, частичное списание нескольких позиций) — транзакция с SELECT ... FOR UPDATE по строкам товара в порядке id (единый порядок — профилактика deadlock: оба сервиса блокируют позиции в одинаковой последовательности); (3) проверка на стейдже конкурентным тестом: 50 потоков списывают один товар — суммарно списано ровно столько, сколько было (без теста «вроде починили» не считается — тестирование с реальным Postgres ловит то, что юниты с моками не видят); (4) мониторинг: метрика времени транзакций и алерт на idle in transaction > 30 с (метрики); (5) граница транзакции сдвинута: подготовка данных и вызов платёжного API — вне транзакции (внешний HTTP внутри транзакции держал блокировки секундами — теперь транзакция живёт миллисекунды). Результат: расхождения ушли, p99 времени блокировок — 12 мс; уровень изоляции так и остался READ COMMITTED — правильные паттерны записи оказались важнее экзотики изоляции.
FAQ
Почему Postgres не блокирует читателей при записи (MVCC)? Каждая строка хранит версии: читатели видят снапшот-версию, писатели создают новую — читатели и писатели не пересекаются; цена — мёртвые версии, которые убирает autovacuum (долгие транзакции мешают ему — отсюда раздувание при «вечно открытом» соединении).
Что делать при ошибке сериализации в REPEATABLE READ?
Повторить транзакцию целиком (не отдельные операторы): OperationalError 40001 — ожидаемое событие, ретрай с backoff закладывается в код сразу; частые сериализационные откаты — сигнал, что транзакции слишком широкие (сузить) или уровень выше необходимого.
Автокоммит в SQLAlchemy — транзакции вообще нужны? Каждый SQL-оператор в Postgres исполняется в транзакции (автокоммит = транзакция на оператор); явная транзакция нужна, когда атомарность требуется группе операций (перевод между счетами); одиночный UPDATE атомарен сам по себе — оборачивать его не нужно.
Как безопасно выполнить DDL на живой таблице?
lock_timeout малый + повтор в окне низкой нагрузки; расширяющие изменения (новая nullable-колонка, индекс CONCURRENTLY — индексы) почти не блокируют; опасные (смена типа, NOT NULL на большой таблице) — в несколько шагов (expand/contract — миграции); проверка текущего — pg_stat_activity на долгие транзакции до старта.
Источники: документация PostgreSQL (transaction isolation, explicit locking), SQLAlchemy — ссылки в шапке.