{ "index": 252, "slug": "editorial-2021-01-practice-transactions", "title": "Граница транзакции PostgreSQL: как сохранить правило между строками", "excerpt": "Два запроса могут успешно изменить разные строки и вместе нарушить одно бизнес-правило. Разбираем write skew, область блокировки и полный retry на учебном примере PostgreSQL.", "contentHtml": "
Два запроса вернули 200 OK, но смена осталась без дежурного. Первый запрос выключил Анну, второй — Бориса. Каждый изменил свою строку. Ошибки базы нет. Ошибка появилась в общем правиле: после любой операции должен остаться хотя бы один активный дежурный. Цена такого сбоя — не только неверная строка. Следующий процесс увидит допустимое на уровне типов состояние и примет решение на ложных данных.
Причина скрывается между строками. Каждый запрос сначала читает число активных дежурных, потом меняет одну строку. При двух активных оба запроса принимают решение «можно». Если они не видят изменение друг друга, оба commit проходят. Транзакция сама по себе не защищает predicate, который приложение проверило до записи.
\nТезис статьи простой: граница транзакции должна охватывать чтение, проверку и запись, а механизм должен покрывать все строки, от которых зависит invariant. Для известного набора строк подойдёт единый порядок SELECT ... FOR UPDATE. Для более сложного read/write-пути может подойти Serializable с повтором всей операции после 40001. Ни один вариант не доказывает корректность, пока остальные writers не соблюдают тот же протокол.
Пусть таблица хранит дежурства одной смены.
\nCREATE TABLE on_call (\n shift text NOT NULL,\n doctor text NOT NULL,\n enabled boolean NOT NULL,\n PRIMARY KEY (shift, doctor)\n);\n\n-- Для каждой смены после успешного commit:\n-- число строк с enabled = true должно быть не меньше одного.\nТранзакции T1 и T2 начинают работу почти одновременно. Обе читают activeCount = 2. T1 выключает Анну. T2 выключает Бориса. Записи не конфликтуют: это разные primary key. Но решения конфликтуют по смыслу. После двух commit значение равно нулю.
Это write skew. Система не потеряла обновление одной колонки. Она приняла два независимых изменения, которые несовместимы вместе. Дополнительный SELECT count(*) перед commit не исправляет путь. В режиме Read Committed следующий statement может получить новый snapshot, но сам факт чтения не блокирует другого writer и не отменяет уже принятое решение.
В PostgreSQL обычный Read Committed создаёт видимость для каждого statement. Два запроса в одной транзакции могут увидеть разные committed состояния. Repeatable Read удерживает snapshot после первого запроса, но стабильное чтение не превращает cross-row проверку в последовательное выполнение. Serializable добавляет более сильное требование к успешным результатам: они должны быть объяснимы некоторым последовательным порядком. За это одна из конфликтующих транзакций может быть отменена.
Уровень изоляции задают до первого запроса текущей транзакции. Если приложение сначала выполнило «безобидный» SELECT, а затем пытается изменить уровень, оно уже выбрало границу видимости. Поэтому настройка и контракт retry должны находиться рядом с началом операции.
BEGIN;\nSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = :shift\nORDER BY doctor;\n\n-- Проверяем invariant и выполняем write в этой же транзакции.\nUPDATE on_call\nSET enabled = false\nWHERE shift = :shift AND doctor = :current_doctor;\n\nCOMMIT;\nЭтот фрагмент показывает форму операции. Он не является выполненным запросом и не подтверждает поведение конкретной схемы. Если PostgreSQL отменит транзакцию с SQLSTATE 40001, приложение должно повторить BEGIN → reads → check → writes → COMMIT целиком. Повтор одного UPDATE использует старое решение и обходит защиту.
Когда набор строк известен и невелик, можно сначала заблокировать его в одном порядке, затем проверить правило и выполнить изменение. FOR UPDATE блокирует возвращённые строки для конфликтующих writers до завершения транзакции. Обычный SELECT такой блокировки не ставит.
BEGIN;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = :shift\nORDER BY doctor\nFOR UPDATE;\n\n-- Проверка видит именно заблокированный набор строк.\n-- Если activeCount <= 1, операцию отклоняем.\nUPDATE on_call\nSET enabled = false\nWHERE shift = :shift AND doctor = :current_doctor;\n\nCOMMIT;\nБлокировка защищает результат запроса, а не слово «смена». Если проверка учитывает строки для ролей primary и backup, а lock-запрос выбирает только primary, доказательство неполно. Если другой endpoint меняет те же строки без FOR UPDATE, он обходит протокол. Все writers должны использовать совместимую область и одинаковый порядок. Иначе возможны неполная защита или deadlock.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Два успеха, правило ложно | Два read/write-пути приняли решение по одному старому условию | Воспроизвести порядок T1 read, T2 read, T1 commit, T2 commit | Собрать check и write в общий протокол, затем проверить реальный SQL двумя sessions |
FOR UPDATE не помог | Заблокированный набор меньше набора invariant | Сравнить predicate проверки и predicate lock-запроса | Расширить scope или изменить модель правила |
| Запрос ждёт или получает deadlock | Разные writers берут несколько строк в разном порядке | Выписать порядок захвата для каждого пути и сопоставить с lock-диагностикой | Ввести единый порядок, сократить транзакцию, повторять только отменённую работу |
40001 | Serializable обнаружил несовместимую зависимость | Проверить код ошибки, границу транзакции и отсутствие внешнего эффекта до commit | Повторить всю операцию с новым snapshot или вернуть контролируемый отказ |
| Retry создал дубликат во внешней системе | Повтор пересёк границу базы и внешнего side effect | Найти момент публикации эффекта относительно commit | Отделить retryable часть от публикации и задать отдельный idempotency contract |
FOR UPDATE, зафиксируйте scope и ORDER BY для всех writers. Если выбрали Serializable, повторяйте только подтверждённый 40001.Учебный пример не измеряет latency, throughput, длительность ожидания или число блокировок. Он не заменяет integration test, план запроса и проверку всех writers. Стоимость row locks зависит от размера набора, индексов, длительности транзакции и конкурентной нагрузки. Стоимость Serializable зависит от частоты отмен и возможности безопасно повторить работу. Нельзя переносить результат примера на схему, где появились новые predicate, роли или внешние side effects.
\nГотовность проверяема. Для поддерживаемой версии базы два конкурентных запуска не оставляют запрещённое состояние; при конфликте второй путь либо ждёт и повторно проверяет invariant, либо получает документированный 40001 и безопасно повторяет всю операцию. Ни один внешний эффект не публикуется до успешного commit без отдельного idempotency-механизма. Если это не подтверждено тестом с реальным SQL, граница транзакции остаётся гипотезой.