2 lines
22 KiB
JSON
2 lines
22 KiB
JSON
{"index":250,"slug":"editorial-2021-01-field-transactions","title":"Граница транзакции PostgreSQL: как сохранить cross-row инвариант","excerpt":"Два успешных запроса могут вместе оставить ночную смену без дежурного. Разбираем scope проверки, row-level locks, deadlock и полный retry для SERIALIZABLE.","contentHtml":"<p>Два запроса могут вернуть HTTP 200, а смена всё равно останется без дежурного. Представим две активные строки в таблице <code>on_call</code>: Анна и Борис. Каждый сотрудник может снять себя с ночного дежурства. Правило системы — после успешного commit должен остаться хотя бы один активный человек. T1 читает две активные строки и выключает Анну. T2 почти одновременно читает те же две строки и выключает Бориса. Они меняют разные записи, поэтому локальная проверка каждой операции проходит. Вместе они оставляют ноль активных сотрудников. Цена ошибки — не только неверная строка: следующий процесс увидит допустимый на уровне схемы, но запрещённый бизнесом итог и начнёт работать без обязательного участника.</p><p><code>BEGIN</code> сам по себе не защищает правило между строками. Нужно определить полный scope инварианта, поместить чтение, проверку и запись в одну короткую transaction и выбрать механизм, который соблюдают все writers. Для фиксированного набора строк подойдёт единый порядок <code>SELECT ... FOR UPDATE</code>. Для более сложной зависимости подойдёт <code>SERIALIZABLE</code>, но тогда приложение обязано повторять всю операцию после подтверждённого serialization failure. Повтор одного последнего <code>UPDATE</code> оставляет устаревшее решение.</p><h2>Что именно сломалось</h2><p>Проблема начинается с cross-row инварианта: <code>count(enabled rows for one shift) >= 1</code>. Поле <code>enabled</code> описывает одну строку. Инвариант описывает набор строк одной смены. Если приложение проверяет только текущего врача, оно не видит, кто ещё дежурит. Если оно читает весь набор, но делает проверку и запись в разных границах, другой writer может изменить набор между этими действиями.</p><p>В стандартном для PostgreSQL уровне <code>READ COMMITTED</code> обычный <code>SELECT</code> получает snapshot на момент начала statement. Два SELECT внутри одной transaction могут увидеть разные данные, если между ними commit-ится другая transaction. При этом запрос не читает незакоммиченные изменения. Snapshot отвечает на вопрос «что вижу сейчас», но не гарантирует, что решение можно безопасно сочетать с concurrent commit.</p><p>Обычный <code>CHECK</code> тоже не является универсальным решением. PostgreSQL проверяет его для новой или изменённой строки и не поддерживает через него постоянное правило, зависящее от данных других строк. Если ограничение можно выразить через <code>UNIQUE</code>, <code>EXCLUDE</code> или <code>FOREIGN KEY</code>, лучше использовать constraint. Для правила «в каждой смене остаётся хотя бы один активный» нужен согласованный протокол чтения и записи либо другая модель данных.</p><figure><img src='/assets/editorial/2021/transaction-conflict-diagnosis-2021.svg' alt='Схема диагностики конфликта транзакций: write skew, неполный scope блокировки, deadlock и serialization failure' loading='lazy' /><figcaption>Схема переводит общий симптом в четыре проверяемые формы: несовместимые решения, неполный scope, разный порядок блокировок и SQLSTATE 40001.</figcaption></figure><h2>Сценарий и минимальное воспроизведение</h2><p>Сначала зафиксируем модель, чтобы не спорить о словах. Ниже — учебный стенд: две строки принадлежат одной смене, а удалять себя можно только пока после операции остаётся хотя бы один активный врач. Эти команды не запускались при подготовке статьи; они задают минимальные данные для integration test на разрешённом экземпляре PostgreSQL.</p><pre><code>CREATE TABLE on_call ( shift text NOT NULL, doctor text NOT NULL, enabled boolean NOT NULL, PRIMARY KEY (shift, doctor) ); INSERT INTO on_call (shift, doctor, enabled) VALUES ('night', 'anna', true), ('night', 'boris', true); -- В T1 и T2 отдельно выполняется проверка count(*) и UPDATE своей строки. -- После обоих COMMIT ожидаем: active_count = 0, что нарушает invariant.</code></pre><p>Наивный протокол выглядит так: T1 и T2 начинают transaction, каждая читает <code>active_count = 2</code>, принимает решение и выключает свою строку. T1 фиксирует результат, затем T2 фиксирует свой. Ни один statement не увидел грязное чтение. Ошибка возникла потому, что два разрешённых по отдельности решения нельзя объединить в один допустимый итог.</p><pre><code>-- Учебный anti-pattern. Этот SQL не запускался при подготовке статьи. BEGIN; SELECT count(*) AS active_count FROM on_call WHERE shift = :shift AND enabled = true; -- Приложение отдельно решает, можно ли выключить себя. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Две разные UPDATE-строки не защищают правило active_count >= 1.</code></pre><p>У этого пути две границы. Первая — набор строк, который участвует в проверке. Вторая — момент, когда результат становится committed. Если другой writer использует другой predicate, проверяет только одну строку или пишет вне этой transaction, приложение не имеет общего протокола. Название endpoint и наличие <code>BEGIN</code> этого не меняют.</p><h2>Явная граница строк</h2><p>Если scope известен, можно сначала заблокировать весь набор, затем посчитать его в той же transaction. В примере scope — все дежурные ночной смены. Одинаковый <code>ORDER BY doctor</code> фиксирует логический порядок обработки нескольких строк для всех writers и снижает риск deadlock. Это не блокировка «смены» и не автоматическое принуждение чужого кода: каждый writer должен использовать тот же scope и порядок.</p><pre><code>-- Учебный protocol для фиксированного набора строк. Команды не выполнялись на PostgreSQL при подготовке статьи. BEGIN; SELECT doctor, enabled FROM on_call WHERE shift = :shift ORDER BY doctor FOR UPDATE; -- После получения набора считаем активные строки и принимаем решение. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Если active_count = 1, UPDATE не выполняется, transaction возвращает отказ.</code></pre><p><code>FOR UPDATE</code> блокирует возвращённые строки до окончания transaction. T2 ждёт освобождения строки, а после commit T1 получает обновлённую версию строки и должна заново проверить условие. Поэтому проверка должна использовать результат заблокированного запроса, а не старое значение, полученное до ожидания.</p><p>Сравнивать нужно predicate бизнес-правила с predicate блокировки. Если инвариант зависит от другой роли, региона или временного интервала, запрос должен покрыть и эти строки. Если возможна вставка новой строки, блокировка существующих строк может не описывать весь риск. Тогда меняют модель или выбирают механизм, который учитывает появление новых записей.</p><p>Ожидание блокировки и deadlock — разные симптомы. Ожидание T2 за строками T1 может быть штатной частью протокола. Deadlock возникает, когда T1 держит A и ждёт B, а T2 держит B и ждёт A. PostgreSQL отменит одну из таких transaction. Единый порядок получения нескольких объектов — первая защита; если deadlock всё же разрешился отменой, повторяют всю отменённую операцию только после проверки причины. Бесконечный цикл вокруг любой ошибки маскирует нарушения данных, сетевые сбои и ошибки валидации.</p><h2>SERIALIZABLE и полный retry</h2><p><code>SERIALIZABLE</code> полезен, когда правило зависит от чтений и его трудно свести к небольшому заранее известному набору строк. Для concurrent writers, которые участвуют в этом уровне изоляции, PostgreSQL допускает commit только для результата, совместимого с некоторым последовательным порядком операций. Это не означает, что обе transaction завершатся успешно: при опасной зависимости одна может получить serialization failure с SQLSTATE <code>40001</code>. Writer, который обходит этот protocol, не получает обещаний от одного лишь уровня изоляции.</p><pre><code>-- Учебный shape. Нужна адаптация к драйверу и проектной policy. BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT doctor, enabled FROM on_call WHERE shift = :shift; -- Здесь выполняются проверка invariant и нужная запись. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- При 40001 повторяется вся операция, а не только UPDATE.</code></pre><p>Полный retry означает новый <code>BEGIN</code>, новые чтения, новую проверку, новую запись и новый <code>COMMIT</code>. T2 после rollback не может использовать решение «в смене два активных», которое получила до commit T1. Она должна получить новый snapshot и заново увидеть один активный ряд. Если условие больше не выполняется, операция возвращает отказ без записи.</p><pre><code>async function retryWholeOperation(runOnce) { for (let attempt = 1; attempt <= 3; attempt += 1) { try { return await runOnce(); } catch (error) { if (error.code !== '40001' || attempt === 3) throw error; } } } // Учебный shape: runOnce должен включать reads, decision, writes и commit. // Перед следующим BEGIN нужен rollback; между попытками нужен bounded backoff.</code></pre><p>Этот фрагмент не является готовым адаптером драйвера. В реальной системе нужны rollback, deadline, backoff, журнал причины и политика после исчерпания попыток. Письмо, публикация события или HTTP-вызов внешнего сервиса не откатываются транзакцией PostgreSQL. Если такой side effect произошёл до commit, retry может отправить его дважды. Сначала фиксируют данные, затем публикуют внешний эффект отдельным механизмом с собственным контрактом идемпотентности.</p><h2>Симптом → причина → проверка → действие</h2><div class='table-scroll'><table><caption>Диагностика конкурентной операции</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Два запроса успешны, invariant нарушен</td><td>Оба приняли решение по одному старому состоянию</td><td>Зафиксировать interleaving: два read, два write, два commit</td><td>Объединить read, check и write в общий protocol</td></tr><tr><td><code>FOR UPDATE</code> есть, ошибка остаётся</td><td>Заблокированный set меньше set инварианта</td><td>Сравнить оба predicate и всех writers</td><td>Расширить scope или изменить модель правила</td></tr><tr><td>Запрос долго ждёт</td><td>Другой writer держит ту же строку</td><td>Сопоставить statement, transaction и данные о lock в <code>pg_locks</code></td><td>Сократить transaction и принять ожидаемое ожидание</td></tr><tr><td>Deadlock и отмена transaction</td><td>Разные пути берут строки в разном порядке</td><td>Выписать порядок A/B для каждого writer</td><td>Установить один order и повторять только подтверждённую отмену</td></tr><tr><td>SQLSTATE <code>40001</code></td><td>Serializable обнаружил опасную зависимость</td><td>Проверить outcome transaction и момент side effect</td><td>Повторить всю operation с новым snapshot</td></tr><tr><td>После retry появился duplicate effect</td><td>Внешняя публикация попала внутрь повторяемой границы</td><td>Найти её точное место относительно commit</td><td>Вынести публикацию и задать idempotency contract</td></tr></tbody></table></div><h2>Порядок проверки</h2><ol><li>Запишите invariant как условие после успешного commit. Для примера: в каждой ночной смене остаётся минимум один активный дежурный.</li><li>Выпишите полный scope: shift, role, region, временной диапазон и возможные новые строки. Одного <code>doctor_id</code> недостаточно.</li><li>Найдите все writers этого набора. Один обходящий protocol UPDATE обесценивает защиту остальных путей.</li><li>Запишите фиксированный schedule двух операций: что прочла каждая, когда приняла решение, кто ждал и какой commit произошёл первым.</li><li>Проверьте уровень изоляции до первого query. <code>SET TRANSACTION</code> нельзя переносить после чтения.</li><li>Для явной блокировки проверьте полный returned set и единый order. Для <code>SERIALIZABLE</code> проверьте, что все нужные writers участвуют в уровне, и обработку <code>40001</code>.</li><li>Убедитесь, что retry повторяет чтения, проверку и запись. Не повторяйте устаревший blind write.</li><li>Сначала запустите учебную модель на тестовой PostgreSQL, затем integration test с двумя sessions и заранее согласованным cleanup.</li><li>Отдельно проверьте side effects после commit. В критерий готовности включите отказ, retry и поведение после исчерпания попыток.</li></ol><h2>Ограничения и критерий готовности</h2><p>Учебный пример фиксирует две строки, одну смену и один schedule. Он не измеряет latency, throughput, contention или длительность locks. Он не показывает план PostgreSQL, поведение конкретного драйвера и всех writers приложения. SQL-фрагменты выше не выполнялись при подготовке текста. Поэтому нельзя переносить их как готовую production-конфигурацию и нельзя объявлять проблему доказанной только по fixture.</p><p>Для одной строки часто достаточно атомарного <code>UPDATE ... WHERE</code> с проверяемым условием. Для cross-row правила нужен полный scope. Constraint может быть лучше transaction protocol, если правило выразимо на уровне схемы. Явная блокировка подходит при известном наборе. <code>SERIALIZABLE</code> подходит при сложной зависимости, если операция безопасно повторяется и все критичные writers участвуют в протоколе. Ни один вариант не отменяет необходимость перечислить writers и внешние эффекты.</p><p>Операция готова к выпуску, когда integration test на целевой версии PostgreSQL запускает два конкурентных пути и подтверждает запрещённый наивный schedule, выбранный механизм, правильный итог после ожидания или retry, SQLSTATE для отменённой transaction и отсутствие duplicate side effect. Дополнительно тест должен показать, что обходящий writer не остаётся незамеченным. Это проверяемый критерий. Наличие <code>BEGIN</code> в коде таким критерием не является.</p><p>Граница транзакции заканчивается там, где заканчивается состояние базы и её protocol. Назовите invariant, scope, порядок locks и момент commit. После этого ошибка перестаёт быть загадочным «конфликтом транзакций»: её можно свести к неполному набору, разному порядку, ожидаемой блокировке или serialization failure и выбрать конкретное действие.</p><h2>Проверяемые источники</h2><ul><li><a href='https://www.postgresql.org/docs/current/transaction-iso.html' target='_blank' rel='noopener noreferrer'>PostgreSQL: Transaction Isolation</a> — уровни изоляции, snapshots, поведение <code>READ COMMITTED</code>, <code>SERIALIZABLE</code> и обязательный retry всей transaction</li><li><a href='https://www.postgresql.org/docs/current/explicit-locking.html' target='_blank' rel='noopener noreferrer'>PostgreSQL: Explicit Locking</a> — row-level locks, время их удержания и deadlock при разном порядке</li><li><a href='https://www.postgresql.org/docs/current/ddl-constraints.html' target='_blank' rel='noopener noreferrer'>PostgreSQL: Constraints</a> — границы <code>CHECK</code> и варианты выражения cross-row ограничений</li></ul>"}
|