diff --git a/editorial/agent-rewrites/251.json b/editorial/agent-rewrites/251.json index 00d22a3..b36c4de 100644 --- a/editorial/agent-rewrites/251.json +++ b/editorial/agent-rewrites/251.json @@ -2,6 +2,6 @@ "index": 251, "slug": "editorial-2021-01-mechanism-transactions", "title": "Почему транзакция не спасает сама по себе: snapshot, блокировки и retry в PostgreSQL", - "excerpt": "Два запроса могут честно выполнить SELECT и COMMIT, но нарушить общее правило данных. Разбираем, что видит snapshot, какие строки покрывает FOR UPDATE и почему Serializable требует повторить всю операцию.", - "contentHtml": "
Симптом выглядит безопасно: два запроса вернули успех, оба завершились COMMIT, но после этого в смене не осталось дежурного врача. Первый запрос выключил Анну, второй — Бориса. Каждый проверил строку, которую менял. Ошибка появилась в общем правиле: для одной смены должен оставаться хотя бы один активный врач. Цена ошибки — не только неверная строка в таблице. Следующий запрос доверяет этому состоянию и может принять решение без ответственного человека.
Тезис простой: BEGIN не описывает бизнес-инвариант. PostgreSQL защищает согласованность транзакции, но приложение должно выбрать способ защиты общего правила. В одном случае хватит атомарного UPDATE. В другом нужен фиксированный набор строк с SELECT ... FOR UPDATE. Для сложной проверки можно выбрать Serializable, но тогда код обязан повторить всю операцию после serialization failure. Повтор последней команды не исправляет старое решение.
В PostgreSQL 13 по умолчанию действует Read Committed. Обычный SELECT видит данные, зафиксированные до начала этого statement. Поэтому два SELECT в одной транзакции могут получить разные snapshots. Между ними другая транзакция успеет выполнить UPDATE и COMMIT. Это не dirty read: незакоммиченные данные по-прежнему скрыты. Меняется момент, на который смотрит каждый запрос.
Repeatable Read сохраняет snapshot после первого запроса транзакции. Последующие чтения видят одну картину. Но стабильное чтение не означает право безопасно изменить строки, которые участвуют в общем инварианте. Если две транзакции прочитали один набор и приняли несовместимые решения, PostgreSQL может отменить одну из них. Код должен обработать отказ.
Serializable добавляет более сильное условие для успешного commit: результат параллельных транзакций должен быть объясним некоторым последовательным порядком. Это не пропуск для любого запроса. При конфликте PostgreSQL завершает одну транзакцию ошибкой сериализации, обычно с SQLSTATE 40001. Вызвавший код получает возможность начать операцию с новым snapshot.
SELECT ... FOR UPDATE ставит блокировку на строки, которые вернул запрос. Конфликтующий UPDATE, DELETE или другой запрос с блокировкой будет ждать завершения первой транзакции. PostgreSQL не угадывает, какие строки составляют ваш инвариант. Если правило относится к двум врачам, а запрос зафиксировал только текущего врача, второй остаётся вне протокола.
У всех writers должен быть один и тот же scope. Они должны выбирать один набор строк, использовать одинаковый порядок и удерживать блокировку до изменения и commit. ORDER BY помогает сделать порядок явным, но не расширяет набор. Если predicate допускает новые строки, одного row lock может быть мало: вставка, не попавшая в result, не ждёт блокировку уже выбранных строк.
Ниже приведён учебный SQL-пример для объяснения протокола. Он не сообщает production-латентность, количество конфликтов или поведение конкретного сервиса. Пусть таблица содержит shift, doctor и enabled. Операция должна отключить врача только тогда, когда после изменения останется хотя бы один активный врач.
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;\nПротокол работает только при дисциплине всех writers. Каждый маршрут, который меняет активность врача для той же смены, должен брать тот же набор строк и тот же порядок. Если один путь делает прямой UPDATE, он обходит договорённость. Для одного счётчика часто безопаснее выразить правило атомарным условным обновлением и проверить число изменённых строк. Не добавляйте широкую блокировку, пока не назвали точный invariant.
При Serializable транзакция должна быть повторяемой целиком. Новый запуск заново выполняет чтения, проверку, решение, записи и commit. Только так решение строится на новом snapshot.
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}\nКод иллюстрирует границу retry, а не готовый клиентский API. Методы begin, rollback и поле sqlState зависят от драйвера. При повторе нельзя повторно отправить письмо, списать деньги или опубликовать событие до успешного commit без отдельного механизма идемпотентности. Внешний side effect не откатывается вместе с PostgreSQL.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Два запроса изменили разные строки и нарушили правило | Проверка охватила неполный набор или writers используют разные пути | Сравнить predicate, строки result и SQL всех writers | Сузить invariant до точного scope и выбрать общий lock protocol либо атомарный запрос |
| Вторая транзакция долго ждёт | Она конфликтует с row lock первой транзакции | Сопоставить pg_stat_activity, ожидание и удерживающую транзакцию | Сократить работу между lock и commit; проверить порядок взятия нескольких строк |
| Два чтения в одном BEGIN вернули разные данные | Работает Read Committed, snapshot создаётся для каждого statement | Записать время начала обоих statements и commit конкурирующего запроса | Сделать операцию атомарной или задать isolation до первого запроса |
Транзакция падает с SQLSTATE 40001 | Serializable обнаружил конфликт сериализации | Проверить SQLSTATE и границу операции в логах | Повторить весь read/decide/write/commit; ограничить число попыток |
| Правило нарушается после добавления новой записи | FOR UPDATE зафиксировал существующие rows, но не описал insert | Проверить predicate и конкурентный INSERT | Пересмотреть модель: constraint, другой lock scope или Serializable |
active_count >= 1.Serializable. Задайте уровень до первого query и обработайте только ожидаемые serialization failures.Уровень изоляции не исправит неверный invariant и не заставит старый код соблюдать новый protocol. Row lock не защищает строки, которые query не вернул. Serializable может увеличить число отказов и повторов; их частота зависит от длительности транзакции, индексов, плана и конкурентной нагрузки. Учебный пример не даёт production-результата и не задаёт число retry для всех систем.
Граница PostgreSQL заканчивается на состоянии базы. Письмо, вызов HTTP-сервиса и сообщение в очереди не откатываются автоматически. Не публикуйте такие эффекты до подтверждённого commit или связывайте их с базой через отдельный надёжный протокол.
\nОперация готова, когда тест с двумя конкурентными сессиями подтверждает invariant после каждого успешного commit, ожидаемый конфликт даёт понятный outcome, а отказ 40001 повторяет всю операцию и не дублирует внешний эффект. В логах видны transaction id, число попыток и причина отказа. Если любой writer обходит описанный scope, критерий не выполнен.
Симптом выглядит безопасно: два запроса вернули успех, оба завершились COMMIT, но после этого в смене не осталось дежурного врача. Первый запрос выключил Анну, второй — Бориса. Каждый проверил строку, которую менял. Ошибка появилась в общем правиле: для одной смены должен оставаться хотя бы один активный врач. Цена ошибки — не только неверная строка в таблице. Следующий запрос доверяет этому состоянию и может принять решение без ответственного человека.
Граница проходит в другом месте: BEGIN не описывает бизнес-инвариант. PostgreSQL защищает согласованность транзакции, но приложение должно выбрать способ защиты общего правила. В одном случае хватит атомарного UPDATE. В другом нужен фиксированный набор строк с SELECT ... FOR UPDATE. Для сложной проверки можно выбрать Serializable, но тогда код обязан повторить всю операцию после serialization failure. Повтор последней команды не исправляет старое решение.
В PostgreSQL 13 по умолчанию действует Read Committed. Обычный SELECT видит данные, зафиксированные до начала этого statement. Поэтому два SELECT в одной транзакции могут получить разные snapshots. Между ними другая транзакция успеет выполнить UPDATE и COMMIT. Это не dirty read: незакоммиченные данные по-прежнему скрыты. Меняется момент, на который смотрит каждый запрос.
Repeatable Read сохраняет snapshot после первого запроса транзакции. Последующие чтения видят одну картину. Но стабильное чтение не означает право безопасно изменить строки, которые участвуют в общем инварианте. Если две транзакции прочитали один набор и приняли несовместимые решения, PostgreSQL может отменить одну из них, но не обязан: write skew может позволить обеим транзакциям выполнить COMMIT. Поэтому код должен отдельно защитить инвариант и обработать возможный отказ.
Serializable добавляет более сильное условие для успешного commit: результат параллельных транзакций должен быть объясним некоторым последовательным порядком. Это не пропуск для любого запроса. При конфликте PostgreSQL завершает одну транзакцию ошибкой сериализации с SQLSTATE 40001. Вызвавший код должен начать операцию заново с новым snapshot.
SELECT ... FOR UPDATE ставит блокировку на строки, которые вернул запрос. Конфликтующий UPDATE, DELETE или другой запрос с блокировкой будет ждать завершения первой транзакции. Блокировка действует до конца транзакции и сама по себе не запрещает последующее изменение строки после COMMIT или ROLLBACK. PostgreSQL не угадывает, какие строки составляют ваш инвариант. Если правило относится к двум врачам, а запрос зафиксировал только текущего врача, второй остаётся вне протокола.
У всех writers должен быть один и тот же scope. Они должны выбирать один набор строк, использовать одинаковый порядок и удерживать блокировку до изменения и commit. ORDER BY помогает сделать порядок явным, но не расширяет набор. Если инвариант зависит от отсутствия строк или от диапазона, одного row lock может быть мало: вставка, не попавшая в result, не ждёт блокировку уже выбранных строк.
Ниже приведён учебный SQL-пример для объяснения протокола. Он не сообщает production-латентность, количество конфликтов или поведение конкретного сервиса. Пусть таблица содержит shift, doctor и enabled. Операция должна отключить врача только тогда, когда после изменения останется хотя бы один активный врач.
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;\nПротокол работает только при дисциплине всех writers. Каждый маршрут, который меняет активность врача для той же смены, должен брать тот же набор строк и тот же порядок. Если один путь делает прямой UPDATE, он обходит договорённость. Для одного счётчика часто безопаснее выразить правило атомарным условным обновлением и проверить число изменённых строк. Не добавляйте широкую блокировку, пока не назвали точный invariant.
При Serializable транзакция должна быть повторяемой целиком. Новый запуск заново выполняет чтения, проверку, решение, записи и commit. Только так решение строится на новом snapshot.
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}\nКод иллюстрирует границу retry, а не готовый клиентский API. Методы begin, rollback и поле sqlState зависят от драйвера. При повторе нельзя повторно отправить письмо, списать деньги или опубликовать событие до успешного commit без отдельного механизма идемпотентности. Внешний side effect не откатывается вместе с PostgreSQL.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Два запроса изменили разные строки и нарушили правило | Проверка охватила неполный набор или writers используют разные пути | Сравнить predicate, строки result и SQL всех writers | Сузить invariant до точного scope и выбрать общий lock protocol либо атомарный запрос |
| Вторая транзакция долго ждёт | Она конфликтует с row lock первой транзакции | Сопоставить pg_stat_activity, ожидание и удерживающую транзакцию | Сократить работу между lock и commit; проверить порядок взятия нескольких строк |
| Два чтения в одном BEGIN вернули разные данные | Работает Read Committed, snapshot создаётся для каждого statement | Записать время начала обоих statements и commit конкурирующего запроса | Сделать операцию атомарной или задать isolation до первого запроса |
Транзакция падает с SQLSTATE 40001 | Serializable обнаружил конфликт сериализации | Проверить SQLSTATE и границу операции в логах | Повторить весь read/decide/write/commit; ограничить число попыток |
| Правило нарушается после добавления новой записи | FOR UPDATE зафиксировал существующие rows, но не описал insert | Проверить predicate и конкурентный INSERT | Пересмотреть модель: constraint, другой lock scope или Serializable |
active_count >= 1.Serializable. Задайте уровень до первого query и обработайте только ожидаемые serialization failures.Уровень изоляции не исправит неверный invariant и не заставит старый код соблюдать новый protocol. Row lock не защищает строки, которые query не вернул. Serializable может увеличить число отказов и повторов; их частота зависит от длительности транзакции, индексов, плана и конкурентной нагрузки. Учебный пример привязан к PostgreSQL 13 и не задаёт число retry или поведение драйвера для всех систем.
Граница PostgreSQL заканчивается на состоянии базы. Письмо, вызов HTTP-сервиса и сообщение в очереди не откатываются автоматически. Не публикуйте такие эффекты до подтверждённого commit или связывайте их с базой через отдельный надёжный протокол.
\nОперация готова, когда тест с двумя конкурентными сессиями подтверждает invariant после каждого успешного commit, ожидаемый конфликт даёт понятный outcome, а отказ 40001 повторяет всю операцию и не дублирует внешний эффект. В логах видны transaction id, число попыток и причина отказа. Если любой writer обходит описанный scope, критерий не выполнен.