8 lines
21 KiB
JSON
8 lines
21 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> получает снимок на момент начала statement. Два SELECT внутри одной transaction не обязаны видеть одинаковое состояние. Даже если оба запроса прочитали корректный снимок, это ещё не разрешает им независимо принять решения по одному общему правилу. Snapshot отвечает на вопрос «что вижу», а не на вопрос «можно ли мой будущий commit сочетать с другим commit».</p><p>Обычный <code>CHECK</code> тоже не является универсальным решением. Он проверяет новую или изменённую строку. Он не поддерживает постоянное правило, которое зависит от других строк таблицы. Если правило можно выразить через <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>Механизм: snapshot, строки и commit</h2><p>Наивный путь выглядит так: запрос считает активных сотрудников, приложение принимает решение, затем выключает текущего сотрудника. Временная шкала может быть такой: T1 читает <code>active_count = 2</code>; T2 читает <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> задаёт единый порядок получения нескольких строк. T2 будет ждать, пока T1 завершит transaction. После пробуждения T2 должна читать состояние, которое теперь включает commit T1, и отказаться от своей записи, если активным остался только один человек.</p><pre><code>-- Учебный protocol для фиксированного набора строк. Команды не выполнялись на PostgreSQL при подготовке статьи. BEGIN; SELECT doctor, enabled FROM on_call WHERE shift = :shift ORDER BY doctor FOR UPDATE; -- Проверяем count по возвращённому набору в этой же transaction. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Все writers этого правила должны использовать тот же scope и порядок.</code></pre><p><code>FOR UPDATE</code> блокирует возвращённые строки до окончания transaction. Он не блокирует абстрактную «смену» и не заставляет чужой код использовать тот же запрос. Поэтому нужно сравнить predicate бизнес-правила с predicate блокировки. Если инвариант зависит от строк с другой ролью, региона или временного интервала, запрос должен покрыть и их. Если возможна вставка новой строки, блокировка существующих строк может не описывать весь риск. В такой ситуации меняют модель или выбирают другой механизм.</p><p>Ожидание блокировки и deadlock — разные симптомы. Ожидание T2 за строками T1 может быть штатной частью протокола. Deadlock возникает, когда T1 держит A и ждёт B, а T2 держит B и ждёт A. Единый порядок получения строк уменьшает такую возможность. Если сервер всё же отменил transaction, повторяют отменённую операцию только после проверки причины. Бесконечный цикл вокруг любой ошибки маскирует нарушения данных, сетевые сбои и ошибки валидации.</p><h2>Serializable и полный retry</h2><p><code>SERIALIZABLE</code> полезен, когда правило зависит от чтений и его трудно свести к небольшому заранее известному набору строк. PostgreSQL допускает commit только для результата, который можно объяснить некоторым последовательным порядком операций. Это не означает, что обе concurrent transaction всегда завершатся успешно. При опасной зависимости одна может получить serialization failure с SQLSTATE <code>40001</code>.</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 после отката не может использовать решение «в смене два активных», которое она получила до 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. // Внешний side effect нельзя бездумно помещать внутрь такого retry.</code></pre><p>Этот фрагмент не является готовым адаптером драйвера. В реальной системе нужны rollback, deadline, backoff, журнал причины и политика после исчерпания попыток. Письмо, публикация события или HTTP-вызов внешнего сервиса не откатываются транзакцией PostgreSQL. Если такой side effect произошёл до commit, повтор может отправить его дважды. Сначала фиксируют данные, затем публикуют внешний эффект отдельным механизмом с собственным контрактом идемпотентности.</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> проверьте обработку <code>40001</code>.</li><li>Убедитесь, что retry повторяет чтения, проверку и запись. Не повторяйте устаревший blind write.</li><li>Сначала запустите быструю учебную модель, затем integration test на разрешённой PostgreSQL с двумя 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 и внешние эффекты.</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, Serializable и необходимость повторить отменённую 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> — границы CHECK и варианты выражения cross-row ограничений</li></ul>"
|
||
}
|