8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"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 может отменить одну из них. Код должен обработать отказ.</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> или другой запрос с блокировкой будет ждать завершения первой транзакции. PostgreSQL не угадывает, какие строки составляют ваш инвариант. Если правило относится к двум врачам, а запрос зафиксировал только текущего врача, второй остаётся вне протокола.</p>\n<p>У всех writers должен быть один и тот же scope. Они должны выбирать один набор строк, использовать одинаковый порядок и удерживать блокировку до изменения и commit. <code>ORDER BY</code> помогает сделать порядок явным, но не расширяет набор. Если predicate допускает новые строки, одного 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-- Приложение принимает решение по полному набору строк.\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 <= 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) => row.enabled).length;\n if (active <= 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 >= 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> может увеличить число отказов и повторов; их частота зависит от длительности транзакции, индексов, плана и конкурентной нагрузки. Учебный пример не даёт production-результата и не задаёт число 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/current/transaction-iso.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL: Transaction Isolation</a> — snapshots, уровни изоляции и serialization failure.</li><li><a href=\"https://www.postgresql.org/docs/current/explicit-locking.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL: Explicit Locking</a> — row-level locks и срок их действия.</li><li><a href=\"https://www.postgresql.org/docs/current/mvcc.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL: Concurrency Control</a> — MVCC и границы конкурентного доступа.</li></ul>"
|
||
}
|