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

8 lines
16 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": 252,
"slug": "editorial-2021-01-practice-transactions",
"title": "Граница транзакции PostgreSQL: как сохранить правило между строками",
"excerpt": "Два запроса могут успешно изменить разные строки и вместе нарушить одно бизнес-правило. Разбираем write skew, область блокировки и полный retry на учебном примере PostgreSQL.",
"contentHtml": "<p>Представим учебную смену: оператор отправляет два запроса и получает <code>200 OK</code>, но после ответа смена остаётся без дежурного. Первый запрос выключает Анну, второй — Бориса. Каждый изменяет свою строку, база не сообщает об ошибке. Нарушено общее правило: после любой операции должен остаться хотя бы один активный дежурный. Цена сбоя — не только неверная строка. Следующий процесс увидит допустимое на уровне типов состояние и примет решение на ложных данных.</p>\n<p>Причина скрывается между строками. Сначала каждый запрос читает число активных дежурных, затем меняет одну строку. При двух активных оба принимают решение «можно». Если они не видят изменение друг друга, оба <code>COMMIT</code> проходят. Транзакция сама по себе не защищает предикат — условие выборки, которое приложение проверило до записи.</p>\n<p>Граница транзакции должна охватывать чтение, проверку и запись, а механизм — все строки, от которых зависит инвариант: условие, которое должно быть истинным после <code>COMMIT</code>. Для известного набора строк подойдёт единый порядок <code>SELECT ... FOR UPDATE</code>. Для более сложного read/write-пути может подойти <code>Serializable</code> с повтором всей операции после <code>40001</code>. Корректность нужно проверять на всех остальных путях записи: один writer, обходящий протокол, оставляет дыру.</p>\n<h2>Учебная сцена: что именно нарушается</h2>\n<p>Пусть таблица хранит дежурства одной смены.</p>\n<pre><code>CREATE 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 должно быть не меньше одного.</code></pre>\n<p>Транзакции T1 и T2 начинают работу почти одновременно. Обе читают <code>activeCount = 2</code>. T1 выключает Анну. T2 выключает Бориса. Записи не конфликтуют: это разные primary key. Но решения конфликтуют по смыслу. После двух <code>COMMIT</code> значение равно нулю.</p>\n<p>Это write skew — перекос записи: система не потеряла обновление одной колонки, а приняла два независимых изменения, несовместимых вместе. Дополнительный <code>SELECT count(*)</code> перед <code>COMMIT</code> не исправляет путь. В режиме <code>Read Committed</code> следующий statement может получить новый snapshot, но само чтение не блокирует другого writer и не отменяет уже принятое решение.</p>\n<h2>Момент чтения важнее названия уровня</h2>\n<p>В PostgreSQL обычный <code>Read Committed</code> получает snapshot на начало каждого statement. Два запроса в одной транзакции могут увидеть разные зафиксированные состояния. <code>Repeatable Read</code> удерживает snapshot после первого запроса, но стабильное чтение не превращает проверку нескольких строк в последовательное выполнение. <code>Serializable</code> добавляет более сильное требование к успешным результатам: они должны быть объяснимы некоторым последовательным порядком. За это одна из конфликтующих транзакций может быть отменена.</p>\n<p>Уровень изоляции задают до первого запроса текущей транзакции. Если приложение сначала выполнило «безобидный» <code>SELECT</code>, а затем пытается изменить уровень, оно уже выбрало границу видимости. Поэтому настройка и контракт повтора должны находиться рядом с началом операции.</p>\n<pre><code>BEGIN;\nSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = $1\nORDER BY doctor;\n\n-- Проверяем инвариант и выполняем write в этой же транзакции.\nUPDATE on_call\nSET enabled = false\nWHERE shift = $1 AND doctor = $2;\n\nCOMMIT;</code></pre>\n<p>Это форма prepared statement: <code>$1</code> и <code>$2</code> подставляет драйвер. Фрагмент не является выполненным запросом и не подтверждает поведение конкретной схемы. Если PostgreSQL отменит транзакцию с SQLSTATE <code>40001</code>, приложение должно повторить <code>BEGIN → reads → check → writes → COMMIT</code> целиком. Повтор одного <code>UPDATE</code> использует старое решение и обходит защиту.</p>\n<h2>Явная граница строк</h2>\n<p>Когда набор строк известен и невелик, можно сначала заблокировать его в одном порядке, затем проверить правило и выполнить изменение. <code>FOR UPDATE</code> блокирует возвращённые строки для конфликтующих writers до завершения транзакции. Обычный <code>SELECT</code> такой блокировки не ставит.</p>\n<pre><code>BEGIN;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = $1\nORDER BY doctor\nFOR UPDATE;\n\nSELECT count(*) AS active_count\nFROM on_call\nWHERE shift = $1 AND enabled = true;\n\n-- Если active_count &lt;= 1, операцию отклоняем.\nUPDATE on_call\nSET enabled = false\nWHERE shift = $1 AND doctor = $2;\n\nCOMMIT;</code></pre>\n<p>Блокировка защищает результат запроса, а не слово «смена». Если проверка учитывает строки для ролей <code>primary</code> и <code>backup</code>, а lock-запрос выбирает только <code>primary</code>, доказательство неполно. Если другой endpoint меняет те же строки без <code>FOR UPDATE</code>, он обходит протокол. Все writers должны использовать совместимую область и одинаковый порядок. Иначе возможны неполная защита или deadlock.</p>\n<figure><img src=\"/assets/editorial/2021/transaction-timeline-2021.svg\" alt=\"Временная шкала двух операций: небезопасные чтения оставляют ноль активных дежурных, а единая граница строк заставляет вторую операцию перечитать состояние и отказаться от записи\" loading=\"lazy\" /><figcaption>Одна и та же операция даёт разные результаты в зависимости от границы. Рисунок показывает учебный schedule, а не трассировку реальной базы.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика нарушения cross-row правила</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Два успеха, правило ложно</td><td>Два read/write-пути приняли решение по одному старому условию</td><td>Воспроизвести порядок T1 read, T2 read, T1 commit, T2 commit</td><td>Собрать check и write в общий протокол, затем проверить реальный SQL двумя sessions</td></tr><tr><td><code>FOR UPDATE</code> не помог</td><td>Заблокированный набор меньше набора invariant</td><td>Сравнить predicate проверки и predicate lock-запроса</td><td>Расширить scope или изменить модель правила</td></tr><tr><td>Запрос ждёт или получает deadlock</td><td>Разные writers берут несколько строк в разном порядке</td><td>Выписать порядок захвата для каждого пути и сопоставить с lock-диагностикой</td><td>Ввести единый порядок, сократить транзакцию, повторять только отменённую работу</td></tr><tr><td><code>40001</code></td><td><code>Serializable</code> обнаружил несовместимую зависимость</td><td>Проверить код ошибки, границу транзакции и отсутствие внешнего эффекта до commit</td><td>Повторить всю операцию с новым snapshot или вернуть контролируемый отказ</td></tr><tr><td>Retry создал дубликат во внешней системе</td><td>Повтор пересёк границу базы и внешнего side effect</td><td>Найти момент публикации эффекта относительно commit</td><td>Отделить retryable часть от публикации и задать отдельный idempotency contract</td></tr></tbody></table></div>\n<h2>Порядок действий</h2>\n<ol><li>Сформулируйте invariant после commit одним предложением. Для примера: «в смене остаётся минимум один активный дежурный».</li><li>Выпишите полный predicate и все строки, от которых зависит решение. Не заменяйте их одним target ID.</li><li>Найдите каждый writer: endpoint, job, админский скрипт и миграцию, которые могут менять эти строки.</li><li>Определите минимальный механизм. Для одной строки используйте атомарное изменение или constraint. Не пытайтесь выразить правило по другим строкам через <code>CHECK</code>: PostgreSQL проверяет его на новой или изменённой строке, а не как постоянный cross-row invariant. Для известного набора — единый row-lock protocol; для сложного read/write-пути — Serializable с full retry.</li><li>Поместите чтение, проверку и запись в короткую транзакцию. Не держите lock во время HTTP-вызова или ожидания пользователя.</li><li>Если выбрали <code>FOR UPDATE</code>, зафиксируйте scope и <code>ORDER BY</code> для всех writers. Если выбрали Serializable, повторяйте только подтверждённый <code>40001</code>.</li><li>Проверьте отрицательный путь: после первого commit второе решение должно перечитать состояние и отказаться от записи, дождаться корректного результата или получить контролируемую ошибку.</li><li>Проведите integration test с двумя реальными sessions на поддерживаемой версии PostgreSQL и сохраните SQLSTATE, commit outcome и итоговое число активных строк.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Учебный пример не измеряет latency, throughput, длительность ожидания или число блокировок. Он не заменяет integration test, план запроса и проверку всех writers. Стоимость row locks зависит от размера набора, индексов, длительности транзакции и конкурентной нагрузки. Стоимость Serializable зависит от частоты отмен и возможности безопасно повторить работу. Нельзя переносить результат примера на схему, где появились новые predicate, роли или внешние side effects.</p>\n<p>Готовность проверяема. Для поддерживаемой версии базы два конкурентных запуска не оставляют запрещённое состояние; при конфликте второй путь либо ждёт и повторно проверяет invariant, либо получает документированный <code>40001</code> и безопасно повторяет всю операцию. Ни один внешний эффект не публикуется до успешного commit без отдельного idempotency-механизма. Если это не подтверждено тестом с реальным SQL, граница транзакции остаётся гипотезой.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.postgresql.org/docs/current/transaction-iso.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Documentation: Transaction Isolation</a> — уровни изоляции, snapshots, Serializable и serialization failure.</li><li><a href=\"https://www.postgresql.org/docs/current/explicit-locking.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Documentation: Explicit Locking</a> — режимы блокировок строк, время их удержания и deadlock.</li><li><a href=\"https://www.postgresql.org/docs/current/applevel-consistency.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Documentation: Data Consistency Checks at the Application Level</a> — выбор между Serializable и явными блокировками для проверок согласованности.</li><li><a href=\"https://www.postgresql.org/docs/current/ddl-constraints.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Documentation: Constraints</a> — ограничения <code>CHECK</code> и границы правил, зависящих от других строк.</li></ul>"
}