Механическая ошибка часто выглядит как «в транзакции же всё было». Разработчик открывает BEGIN, выполняет два SELECT, а потом один UPDATE. Другой запрос делает то же самое. В логах нет грязного чтения, поэтому решение кажется безопасным. Но оба запроса могли увидеть допустимое состояние в разные моменты и записать несовместимый общий итог. Цена — скрытое расхождение между тем, что проверяло приложение, и тем, что реально стало committed. Такая ошибка не лечится названием метода transactional().
Разберём механизм на PostgreSQL 13, который был актуален в январе 2021. Здесь важны три разных предмета: snapshot определяет, какие committed rows видит statement или transaction; row-level lock ограничивает конфликтующие writers и lockers на конкретных возвращённых rows; Serializable отслеживает опасные read/write зависимости и может отменить одну transaction. Они пересекаются, но не равны. Когда эти слова смешивают, появляются либо лишние lock на таблицу, либо retry, который повторяет только последнюю команду и ломает исходный смысл операции.
Три уровня PostgreSQL 13 и их реальные границы
В PostgreSQL 13 по умолчанию работает Read Committed. Обычный SELECT видит rows, которые были committed до начала самого statement; два следующих SELECT в одной transaction могут увидеть разные committed состояния. Repeatable Read фиксирует snapshot при первом query или data-modification statement и даёт стабильную картину transaction. Serializable строится на этой же основе, но не позволяет успешно commit набору transaction, для которого нельзя подобрать последовательный порядок выполнения. За это приходится быть готовым к rollback и повтору.
| Средство PostgreSQL 13 | Что оно даёт | Что остаётся на приложении | Когда остановиться и проверить |
|---|---|---|---|
Read Committed | новый committed snapshot для каждого statement; это default | одна граница для нескольких reads/writes и cross-row invariant | если решение зависит от нескольких statements или rows |
Repeatable Read | стабильный transaction snapshot; в PostgreSQL нет phantom reads на этом уровне | обработка serialization failure и защита business rule от anomaly | если stable read ещё не доказывает корректный общий результат |
Serializable | успешные concurrent commits эквивалентны некоторому serial order | полный retry на 40001, контроль side effects и scope операции | если transaction нельзя безопасно повторить целиком |
SELECT ... FOR UPDATE | конфликтующие writers/lockers ждут на returned rows до конца transaction | полный row set, одинаковый order и discipline всех writers | если predicate шире выбранных rows или routes используют другой protocol |
| Constraint | движок отвергает выражаемое нарушение данных | моделирование только того правила, которое constraint действительно описывает | если правило относится к другим rows, а CHECK его не поддерживает |
«Лучшего» уровня нет: одна row может требовать атомарный update, несколько rows — lock protocol или Serializable, выражаемое правило — constraint. Цена: wait, retry или изменение модели.
Snapshot: момент, который нельзя увидеть по имени метода
Пусть T1 и T2 начинают почти одновременно. В Read Committed T1 делает SELECT count(*) и видит два дежурных. Пока приложение готовит следующую команду, T2 меняет Бориса и commit. Второй SELECT T1 уже может увидеть одного. Это не dirty read и не ошибка PostgreSQL: новый statement получает новую картину committed данных. Если код складывает результаты нескольких queries, он обязан назвать, допустима ли такая смена картины. Если недопустима, рамка операции выбрана неверно.
В Repeatable Read T1 сохраняет snapshot первого запроса. Это удобно для согласованного чтения, но не превращает любой cross-row check в serial execution. Документация PostgreSQL 13 говорит, что applications на этом уровне должны быть готовы к serialization failures; stable snapshot не обещает, что набор успешно committed operations можно объяснить по одному. Особенно опасно сделать вывод «я увидел всё правильно, значит могу безопасно записать». Сначала отделяем видимость от права менять общий invariant.
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- До первого SELECT/UPDATE задаём границу видимости текущей transaction.
SELECT doctor, enabled FROM on_call WHERE shift = :shift ORDER BY doctor;
-- Здесь ещё нет доказательства, что другой writer соблюдает тот же invariant.
COMMIT;
-- SET TRANSACTION после первого query PostgreSQL 13 уже не примет.
Isolation задают до первого query. После «безобидного» SELECT граница уже выбрана; это входной параметр operation, не настройка в середине.
Row-level lock: scope важнее названия режима
SELECT ... FOR UPDATE в PostgreSQL 13 ставит lock на rows, возвращённые запросом. Другой UPDATE, DELETE или lock-запрос на тот же row будет ждать до завершения удерживающей transaction. Но сервер не читает ваш бизнес-инвариант: он видит только rows, которые попали в result. Если invariant зависит от Анны и Бориса, а query зафиксировал лишь строку текущего врача, Борис остаётся вне доказательства. Если set строится через неустойчивый predicate, concurrent insert может дать новую ситуацию, которую исходный lock не описывал.
BEGIN;
SELECT doctor, enabled
FROM on_call
WHERE shift = :shift AND role = 'primary'
ORDER BY doctor
FOR UPDATE;
-- Считаем invariant только по тому set, который именно этот query определил.
UPDATE on_call SET enabled = false WHERE doctor = :current_doctor;
COMMIT;
-- Это пример protocol, не команда, исполненная при подготовке статьи.
Все writers инварианта должны брать один set в одном order. PostgreSQL обнаружит deadlock и отменит одну transaction, но это не замена protocol. Стабильный ORDER BY и короткая transaction нужно подтвердить реальным test.
SELECT FOR UPDATE действует только до commit/rollback. После этого ожидающий writer продолжит работу, поэтому разбор обязан покрыть actual update, commit и paths, которые могут обойти выбранную границу.
Serializable: сильная гарантия для успешного commit, но с ценой retry
В PostgreSQL 13 Serializable обеспечивает: успешно committed concurrent transactions имеют эффект, эквивалентный некоторому запуску по одной. Это ровно то, что нужно для части сложных business rules, когда нельзя заранее назвать маленький set строк для lock. Но гарантия действует не как щит, через который всегда проходят оба запроса. При опасном read/write pattern PostgreSQL отменяет одну transaction с serialization failure. Код должен уметь вернуть работу к началу, взять новый snapshot, повторно выполнить checks и только после удачного commit считать данные действительными.
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- После этого идут reads, проверка правила и write одной business operation.
SELECT doctor, enabled FROM on_call WHERE shift = :shift;
UPDATE on_call SET enabled = false WHERE doctor = :current_doctor;
COMMIT;
-- При SQLSTATE 40001 повторяют весь BEGIN..COMMIT, а не только UPDATE.
Full retry означает именно BEGIN → reads → decision → writes → COMMIT ещё раз. Повторять один UPDATE нельзя: он использует вывод, который был сделан на старой картине. Нельзя и бездумно retry-ить любой exception: deadlock, uniqueness error, network failure и нарушение validation имеют разные причины. Для Serializable PostgreSQL 13 документирует SQLSTATE 40001; это полезный конкретный сигнал для policy retry. Сам policy — число попыток, пауза, логирование причины и поведение при исчерпании — зависит от операции и остаётся проектным решением.
Учебная fixture связывает три понятия, не изображая движок
В модуле один фиксированный object-state: Анна и Борис включены, version равен нулю. Наивный schedule даёт T1 и T2 одинаковый snapshot, обе writes проходят и invariant нарушается. Boundary schedule добавляет условную очередь: T2 ждёт, пока T1 закончит, затем видит новое состояние. Retry schedule использует version только как учебный маркер stale decision. Из этой модели нельзя вывести, какие locks создаст PostgreSQL, когда сервер выберет serialization failure или сколько продлится ожидание. Зато она не даёт тексту спрятать сам конфликт за общим словом «конкурентность».
# Только in-memory schedule из revision-модуля; PostgreSQL не запускается.
node scripts/upgrade-2021-01.mjs --verify-fixture
# Ожидаемые свойства:
# naiveScheduleViolatesInvariant: true
# secondOperationIsBlockedInTeachingSchedule: true
# teachingBoundaryPreservesInvariant: true
# restartModelRetriesWholeOperationAndRejects: true
Красная fixture означает противоречие schedule; зелёная подтверждает только модель. Нужен integration test с двумя sessions, который эта партия не запускала.
Нумерованный маршрут выбора механизма
- Назовите invariant после commit и различите его с валидацией одной строки. Если он cross-row, выпишите full predicate.
- Определите, нужны ли стабильные reads внутри операции или достаточно одного атомарного statement. Не открывайте broad transaction без причины.
- Сверьте версию PostgreSQL и момент задания isolation.
SET TRANSACTIONдолжен быть до первого query текущей transaction. - Если используете
FOR UPDATE, зафиксируйте returned rows, sort order и все writers, которые обязаны идти тем же маршрутом. - Если выбираете Serializable, сделайте whole-operation retry только для подтверждённого serialization failure и отделите внешние side effects от retryable части.
- Создайте deterministic fixture для объяснения invariant, затем integration test, который проверяет реальный SQL и версию сервера.
- После проверки запишите ограничение рядом с кодом: какая transaction boundary покрыта и какой новый predicate потребует пересмотра protocol.
Что не следует обещать этой схемой
Нельзя сделать из этой статьи claim о latency, throughput или зрелой платформе данных. PostgreSQL 13 описывает гарантии и конфликты, но стоимость конкретного path зависит от indexes, plan, размера rows, времени удержания transaction и состава concurrent work. Нельзя также объявить Repeatable Read «почти Serializable» и забыть о serialization anomaly: документация отделяет эти режимы. Верный следующий шаг скромнее — проверить одну business operation с одним fixed schedule и сохранить результат как часть её контракта.
Механика становится понятной, когда каждый термин привязан к наблюдаемому факту. Snapshot отвечает, что операция может видеть. Row-level lock отвечает, какие returned rows конфликтующие writers временно не меняют. Serializable отвечает, какие committed outcomes разрешены, и требует retry после отмены. Инвариант всё равно формулирует приложение. Если эти четыре слоя не смешивать, код-ревью обсуждает не вкусы к уровню изоляции, а конкретную границу и способ её проверить.
Все имена дежурных, schedule, версии, результаты и SQL-фрагменты ниже учебные. Revision-модуль работает только в памяти: он не открывает PostgreSQL, не выполняет SQL, не создаёт настоящее lock, не измеряет wait и не описывает реальную нагрузку.
Проверяемые источники
- PostgreSQL 13.1 released, 12 November 2020 — к январю 2021 ветка 13 уже имела минорный релиз 13.1; статьи фиксируют именно документацию PostgreSQL 13, а не современное поведение другой версии
- PostgreSQL 13: Transaction Isolation — описывает Read Committed как default, snapshot-границы, PostgreSQL Repeatable Read, Serializable и необходимость повторять отменённую транзакцию
- PostgreSQL 13: Explicit Locking — описывает row-level lock modes, конфликтующие операции, освобождение lock при завершении transaction и риск deadlock при разном порядке
- PostgreSQL 13: Data Consistency Checks at the Application Level — разделяет Serializable-подход и explicit blocking locks; отдельно предупреждает, что SELECT FOR UPDATE не сохраняет строку после окончания transaction сам по себе
- PostgreSQL 13: SET TRANSACTION — задаёт синтаксис isolation level и ограничение: уровень нельзя менять после первого query или data-modification statement текущей transaction
- PostgreSQL 13: Constraints — объясняет, что CHECK не предназначен для постоянного контроля других строк таблицы; cross-row правило требует иной формулировки и защиты