8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"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 <= 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>"
|
||
}
|