Files
progcode/editorial/agent-rewrites/250.json
T

2 lines
22 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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) &gt;= 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 &gt;= 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 &lt;= 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>"}