Files

8 lines
17 KiB
JSON
Raw Permalink 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": 251,
"slug": "editorial-2021-01-mechanism-transactions",
"title": "Почему транзакция не спасает сама по себе: snapshot, блокировки и retry в PostgreSQL",
"excerpt": "Два запроса могут честно выполнить SELECT и COMMIT, но нарушить общее правило данных. Разбираем, что видит snapshot, какие строки временно блокирует FOR UPDATE и почему Serializable требует повторить всю операцию.",
"contentHtml": "<p>Симптом выглядит безопасно: два запроса вернули успех, оба завершились <code>COMMIT</code>, но после этого в смене не осталось дежурного врача. Первый запрос выключил Анну, второй — Бориса. Каждый проверил строку, которую менял. Ошибка появилась в общем правиле: для одной смены должен оставаться хотя бы один активный врач. Цена ошибки — не только неверная строка в таблице. Следующий запрос доверяет этому состоянию и может принять решение без ответственного человека.</p>\n<p>Граница проходит в другом месте: <code>BEGIN</code> не описывает бизнес-инвариант. PostgreSQL защищает согласованность транзакции, но приложение должно выбрать способ защиты общего правила. В одном случае хватит атомарного <code>UPDATE</code>. В другом нужен фиксированный набор строк с <code>SELECT ... FOR UPDATE</code>. Для сложной проверки можно выбрать <code>Serializable</code>, но тогда код обязан повторить всю операцию после serialization failure. Повтор последней команды не исправляет старое решение.</p>\n<h2>Что именно видит транзакция</h2>\n<p>В PostgreSQL 13 по умолчанию действует <code>Read Committed</code>. Обычный <code>SELECT</code> видит данные, зафиксированные до начала этого statement. Поэтому два <code>SELECT</code> в одной транзакции могут получить разные snapshots. Между ними другая транзакция успеет выполнить <code>UPDATE</code> и <code>COMMIT</code>. Это не dirty read: незакоммиченные данные по-прежнему скрыты. Меняется момент, на который смотрит каждый запрос.</p>\n<p><code>Repeatable Read</code> сохраняет snapshot после первого запроса транзакции. Последующие чтения видят одну картину. Но стабильное чтение не означает право безопасно изменить строки, которые участвуют в общем инварианте. Если две транзакции прочитали один набор и приняли несовместимые решения, PostgreSQL может отменить одну из них, но не обязан: write skew может позволить обеим транзакциям выполнить <code>COMMIT</code>. Поэтому код должен отдельно защитить инвариант и обработать возможный отказ.</p>\n<p><code>Serializable</code> добавляет более сильное условие для успешного commit: результат параллельных транзакций должен быть объясним некоторым последовательным порядком. Это не пропуск для любого запроса. При конфликте PostgreSQL завершает одну транзакцию ошибкой сериализации с SQLSTATE <code>40001</code>. Вызвавший код должен начать операцию заново с новым snapshot.</p>\n<figure><img src=\"/assets/editorial/2021/transaction-isolation-matrix-2021.svg\" alt=\"Матрица уровней изоляции PostgreSQL и область действия блокировки SELECT FOR UPDATE\"><figcaption>Snapshot отвечает за видимость, блокировка — за конфликт на возвращённых строках, а Serializable — за допустимость успешного общего результата.</figcaption></figure>\n<h2>Блокировка защищает возвращённые строки</h2>\n<p><code>SELECT ... FOR UPDATE</code> ставит блокировку на строки, которые вернул запрос. Конфликтующий <code>UPDATE</code>, <code>DELETE</code> или другой запрос с блокировкой будет ждать завершения первой транзакции. Блокировка действует до конца транзакции и сама по себе не запрещает последующее изменение строки после <code>COMMIT</code> или <code>ROLLBACK</code>. PostgreSQL не угадывает, какие строки составляют ваш инвариант. Если правило относится к двум врачам, а запрос зафиксировал только текущего врача, второй остаётся вне протокола.</p>\n<p>У всех writers должен быть один и тот же scope. Они должны выбирать один набор строк, использовать одинаковый порядок и удерживать блокировку до изменения и commit. <code>ORDER BY</code> помогает сделать порядок явным, но не расширяет набор. Если инвариант зависит от отсутствия строк или от диапазона, одного row lock может быть мало: вставка, не попавшая в result, не ждёт блокировку уже выбранных строк.</p>\n<h2>Учебный пример: запретить отключение последнего врача</h2>\n<p>Ниже приведён учебный SQL-пример для объяснения протокола. Он не сообщает production-латентность, количество конфликтов или поведение конкретного сервиса. Пусть таблица содержит <code>shift</code>, <code>doctor</code> и <code>enabled</code>. Операция должна отключить врача только тогда, когда после изменения останется хотя бы один активный врач.</p>\n<pre><code>BEGIN;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = $1\nORDER BY doctor\nFOR UPDATE;\n\nSELECT count(*)\nFROM on_call\nWHERE shift = $1 AND enabled = true;\n\n-- Если count <= 1, приложение делает ROLLBACK и возвращает отказ.\n-- Иначе приложение выполняет UPDATE:\nUPDATE on_call\nSET enabled = false\nWHERE shift = $1 AND doctor = $2;\n\nCOMMIT;</code></pre>\n<p>Протокол работает только при дисциплине всех writers. Каждый маршрут, который меняет активность врача для той же смены, должен брать тот же набор строк и тот же порядок. Если один путь делает прямой <code>UPDATE</code>, он обходит договорённость. Для одного счётчика часто безопаснее выразить правило атомарным условным обновлением и проверить число изменённых строк. Не добавляйте широкую блокировку, пока не назвали точный invariant.</p>\n<h2>Когда нужен полный retry</h2>\n<p>При <code>Serializable</code> транзакция должна быть повторяемой целиком. Новый запуск заново выполняет чтения, проверку, решение, записи и commit. Только так решение строится на новом snapshot.</p>\n<pre><code>async function disableDoctor(shift, doctor) {\n for (let attempt = 1; attempt &lt;= 3; attempt += 1) {\n try {\n await db.begin({ isolation: 'serializable' });\n const rows = await db.query(\n 'SELECT doctor, enabled FROM on_call WHERE shift = $1 ORDER BY doctor',\n [shift],\n );\n const active = rows.filter((row) =&gt; row.enabled).length;\n if (active &lt;= 1) throw new Error('last active doctor');\n await db.query(\n 'UPDATE on_call SET enabled = false WHERE shift = $1 AND doctor = $2',\n [shift, doctor],\n );\n await db.commit();\n return;\n } catch (error) {\n await db.rollback();\n if (error.sqlState !== '40001' || attempt === 3) throw error;\n }\n }\n}</code></pre>\n<p>Код иллюстрирует границу retry, а не готовый клиентский API. Методы <code>begin</code>, <code>rollback</code> и поле <code>sqlState</code> зависят от драйвера. При повторе нельзя повторно отправить письмо, списать деньги или опубликовать событие до успешного commit без отдельного механизма идемпотентности. Внешний side effect не откатывается вместе с PostgreSQL.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Два запроса изменили разные строки и нарушили правило</td><td>Проверка охватила неполный набор или writers используют разные пути</td><td>Сравнить predicate, строки result и SQL всех writers</td><td>Сузить invariant до точного scope и выбрать общий lock protocol либо атомарный запрос</td></tr><tr><td>Вторая транзакция долго ждёт</td><td>Она конфликтует с row lock первой транзакции</td><td>Сопоставить <code>pg_stat_activity</code>, ожидание и удерживающую транзакцию</td><td>Сократить работу между lock и commit; проверить порядок взятия нескольких строк</td></tr><tr><td>Два чтения в одном BEGIN вернули разные данные</td><td>Работает <code>Read Committed</code>, snapshot создаётся для каждого statement</td><td>Записать время начала обоих statements и commit конкурирующего запроса</td><td>Сделать операцию атомарной или задать isolation до первого запроса</td></tr><tr><td>Транзакция падает с SQLSTATE <code>40001</code></td><td>Serializable обнаружил конфликт сериализации</td><td>Проверить SQLSTATE и границу операции в логах</td><td>Повторить весь read/decide/write/commit; ограничить число попыток</td></tr><tr><td>Правило нарушается после добавления новой записи</td><td>FOR UPDATE зафиксировал существующие rows, но не описал insert</td><td>Проверить predicate и конкурентный INSERT</td><td>Пересмотреть модель: constraint, другой lock scope или Serializable</td></tr></tbody></table>\n<h2>Порядок проверки</h2>\n<ol><li>Запишите invariant в форме допустимого и запрещённого committed result. Например: для каждой смены <code>active_count &gt;= 1</code>.</li><li>Выпишите все rows и predicates, от которых зависит решение. Отдельно перечислите каждый writer, включая фоновые задачи и административные команды.</li><li>Проверьте, можно ли выразить правило одним атомарным SQL statement. Если да, измерьте и проверьте именно этот путь.</li><li>Если нужен набор строк, зафиксируйте его через один query, задайте стабильный порядок и удерживайте lock до commit. Проверьте два конкурентных соединения.</li><li>Если scope нельзя надёжно перечислить, рассмотрите <code>Serializable</code>. Задайте уровень до первого query и обработайте только ожидаемые serialization failures.</li><li>Отделите retryable часть от внешних действий. Идемпотентность, outbox или другой механизм нужны там, где commit не может отменить уже отправленный эффект.</li><li>Зафиксируйте результат интеграционным тестом на реальной версии PostgreSQL. Учебная fixture объясняет schedule, но не заменяет две сессии базы данных.</li></ol>\n<h2>Ограничения</h2>\n<p>Уровень изоляции не исправит неверный invariant и не заставит старый код соблюдать новый protocol. Row lock не защищает строки, которые query не вернул. <code>Serializable</code> может увеличить число отказов и повторов; их частота зависит от длительности транзакции, индексов, плана и конкурентной нагрузки. Учебный пример привязан к PostgreSQL 13 и не задаёт число retry или поведение драйвера для всех систем.</p>\n<p>Граница PostgreSQL заканчивается на состоянии базы. Письмо, вызов HTTP-сервиса и сообщение в очереди не откатываются автоматически. Не публикуйте такие эффекты до подтверждённого commit или связывайте их с базой через отдельный надёжный протокол.</p>\n<h2>Критерий готовности</h2>\n<p>Операция готова, когда тест с двумя конкурентными сессиями подтверждает invariant после каждого успешного commit, ожидаемый конфликт даёт понятный outcome, а отказ <code>40001</code> повторяет всю операцию и не дублирует внешний эффект. В логах видны transaction id, число попыток и причина отказа. Если любой writer обходит описанный scope, критерий не выполнен.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.postgresql.org/docs/13/transaction-iso.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL 13: Transaction Isolation</a> — snapshots, уровни изоляции и serialization failure.</li><li><a href=\"https://www.postgresql.org/docs/13/explicit-locking.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL 13: Explicit Locking</a> — row-level locks и срок их действия.</li><li><a href=\"https://www.postgresql.org/docs/13/applevel-consistency.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL 13: Data Consistency Checks</a> — Serializable и explicit blocking locks для бизнес-инвариантов.</li><li><a href=\"https://www.postgresql.org/docs/13/sql-set-transaction.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL 13: SET TRANSACTION</a> — момент, до которого задаётся уровень изоляции.</li><li><a href=\"https://www.postgresql.org/docs/13/ddl-constraints.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL 13: Constraints</a> — граница CHECK для данных других строк.</li></ul>"
}