From 70d8e14e3c45fd366041bcc5fd40bd1fe070a82f Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 21:32:32 +0300 Subject: [PATCH] editorial: refine PostgreSQL transaction article 251 --- editorial/agent-rewrites/251.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, но после этого в смене не осталось дежурного врача. Первый запрос выключил Анну, второй — Бориса. Каждый проверил строку, которую менял. Ошибка появилась в общем правиле: для одной смены должен оставаться хотя бы один активный врач. Цена ошибки — не только неверная строка в таблице. Следующий запрос доверяет этому состоянию и может принять решение без ответственного человека.

\n

Тезис простой: BEGIN не описывает бизнес-инвариант. PostgreSQL защищает согласованность транзакции, но приложение должно выбрать способ защиты общего правила. В одном случае хватит атомарного UPDATE. В другом нужен фиксированный набор строк с SELECT ... FOR UPDATE. Для сложной проверки можно выбрать Serializable, но тогда код обязан повторить всю операцию после serialization failure. Повтор последней команды не исправляет старое решение.

\n

Что именно видит транзакция

\n

В PostgreSQL 13 по умолчанию действует Read Committed. Обычный SELECT видит данные, зафиксированные до начала этого statement. Поэтому два SELECT в одной транзакции могут получить разные snapshots. Между ними другая транзакция успеет выполнить UPDATE и COMMIT. Это не dirty read: незакоммиченные данные по-прежнему скрыты. Меняется момент, на который смотрит каждый запрос.

\n

Repeatable Read сохраняет snapshot после первого запроса транзакции. Последующие чтения видят одну картину. Но стабильное чтение не означает право безопасно изменить строки, которые участвуют в общем инварианте. Если две транзакции прочитали один набор и приняли несовместимые решения, PostgreSQL может отменить одну из них. Код должен обработать отказ.

\n

Serializable добавляет более сильное условие для успешного commit: результат параллельных транзакций должен быть объясним некоторым последовательным порядком. Это не пропуск для любого запроса. При конфликте PostgreSQL завершает одну транзакцию ошибкой сериализации, обычно с SQLSTATE 40001. Вызвавший код получает возможность начать операцию с новым snapshot.

\n
\"Матрица
Snapshot отвечает за видимость, блокировка — за конфликт на возвращённых строках, а Serializable — за допустимость успешного общего результата.
\n

Блокировка защищает возвращённые строки

\n

SELECT ... FOR UPDATE ставит блокировку на строки, которые вернул запрос. Конфликтующий UPDATE, DELETE или другой запрос с блокировкой будет ждать завершения первой транзакции. PostgreSQL не угадывает, какие строки составляют ваш инвариант. Если правило относится к двум врачам, а запрос зафиксировал только текущего врача, второй остаётся вне протокола.

\n

У всех writers должен быть один и тот же scope. Они должны выбирать один набор строк, использовать одинаковый порядок и удерживать блокировку до изменения и commit. ORDER BY помогает сделать порядок явным, но не расширяет набор. Если predicate допускает новые строки, одного row lock может быть мало: вставка, не попавшая в result, не ждёт блокировку уже выбранных строк.

\n

Учебный пример: запретить отключение последнего врача

\n

Ниже приведён учебный SQL-пример для объяснения протокола. Он не сообщает production-латентность, количество конфликтов или поведение конкретного сервиса. Пусть таблица содержит shift, doctor и enabled. Операция должна отключить врача только тогда, когда после изменения останется хотя бы один активный врач.

\n
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.

\n

Когда нужен полный retry

\n

При Serializable транзакция должна быть повторяемой целиком. Новый запуск заново выполняет чтения, проверку, решение, записи и commit. Только так решение строится на новом snapshot.

\n
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.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Два запроса изменили разные строки и нарушили правилоПроверка охватила неполный набор или 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 40001Serializable обнаружил конфликт сериализацииПроверить SQLSTATE и границу операции в логахПовторить весь read/decide/write/commit; ограничить число попыток
Правило нарушается после добавления новой записиFOR UPDATE зафиксировал существующие rows, но не описал insertПроверить predicate и конкурентный INSERTПересмотреть модель: constraint, другой lock scope или Serializable
\n

Порядок проверки

\n
  1. Запишите invariant в форме допустимого и запрещённого committed result. Например: для каждой смены active_count >= 1.
  2. Выпишите все rows и predicates, от которых зависит решение. Отдельно перечислите каждый writer, включая фоновые задачи и административные команды.
  3. Проверьте, можно ли выразить правило одним атомарным SQL statement. Если да, измерьте и проверьте именно этот путь.
  4. Если нужен набор строк, зафиксируйте его через один query, задайте стабильный порядок и удерживайте lock до commit. Проверьте два конкурентных соединения.
  5. Если scope нельзя надёжно перечислить, рассмотрите Serializable. Задайте уровень до первого query и обработайте только ожидаемые serialization failures.
  6. Отделите retryable часть от внешних действий. Идемпотентность, outbox или другой механизм нужны там, где commit не может отменить уже отправленный эффект.
  7. Зафиксируйте результат интеграционным тестом на реальной версии PostgreSQL. Учебная fixture объясняет schedule, но не заменяет две сессии базы данных.
\n

Ограничения

\n

Уровень изоляции не исправит неверный invariant и не заставит старый код соблюдать новый protocol. Row lock не защищает строки, которые query не вернул. Serializable может увеличить число отказов и повторов; их частота зависит от длительности транзакции, индексов, плана и конкурентной нагрузки. Учебный пример не даёт production-результата и не задаёт число retry для всех систем.

\n

Граница PostgreSQL заканчивается на состоянии базы. Письмо, вызов HTTP-сервиса и сообщение в очереди не откатываются автоматически. Не публикуйте такие эффекты до подтверждённого commit или связывайте их с базой через отдельный надёжный протокол.

\n

Критерий готовности

\n

Операция готова, когда тест с двумя конкурентными сессиями подтверждает invariant после каждого успешного commit, ожидаемый конфликт даёт понятный outcome, а отказ 40001 повторяет всю операцию и не дублирует внешний эффект. В логах видны transaction id, число попыток и причина отказа. Если любой writer обходит описанный scope, критерий не выполнен.

\n

Проверяемые источники

\n" + "excerpt": "Два запроса могут честно выполнить SELECT и COMMIT, но нарушить общее правило данных. Разбираем, что видит snapshot, какие строки временно блокирует FOR UPDATE и почему Serializable требует повторить всю операцию.", + "contentHtml": "

Симптом выглядит безопасно: два запроса вернули успех, оба завершились COMMIT, но после этого в смене не осталось дежурного врача. Первый запрос выключил Анну, второй — Бориса. Каждый проверил строку, которую менял. Ошибка появилась в общем правиле: для одной смены должен оставаться хотя бы один активный врач. Цена ошибки — не только неверная строка в таблице. Следующий запрос доверяет этому состоянию и может принять решение без ответственного человека.

\n

Граница проходит в другом месте: BEGIN не описывает бизнес-инвариант. PostgreSQL защищает согласованность транзакции, но приложение должно выбрать способ защиты общего правила. В одном случае хватит атомарного UPDATE. В другом нужен фиксированный набор строк с SELECT ... FOR UPDATE. Для сложной проверки можно выбрать Serializable, но тогда код обязан повторить всю операцию после serialization failure. Повтор последней команды не исправляет старое решение.

\n

Что именно видит транзакция

\n

В PostgreSQL 13 по умолчанию действует Read Committed. Обычный SELECT видит данные, зафиксированные до начала этого statement. Поэтому два SELECT в одной транзакции могут получить разные snapshots. Между ними другая транзакция успеет выполнить UPDATE и COMMIT. Это не dirty read: незакоммиченные данные по-прежнему скрыты. Меняется момент, на который смотрит каждый запрос.

\n

Repeatable Read сохраняет snapshot после первого запроса транзакции. Последующие чтения видят одну картину. Но стабильное чтение не означает право безопасно изменить строки, которые участвуют в общем инварианте. Если две транзакции прочитали один набор и приняли несовместимые решения, PostgreSQL может отменить одну из них, но не обязан: write skew может позволить обеим транзакциям выполнить COMMIT. Поэтому код должен отдельно защитить инвариант и обработать возможный отказ.

\n

Serializable добавляет более сильное условие для успешного commit: результат параллельных транзакций должен быть объясним некоторым последовательным порядком. Это не пропуск для любого запроса. При конфликте PostgreSQL завершает одну транзакцию ошибкой сериализации с SQLSTATE 40001. Вызвавший код должен начать операцию заново с новым snapshot.

\n
\"Матрица
Snapshot отвечает за видимость, блокировка — за конфликт на возвращённых строках, а Serializable — за допустимость успешного общего результата.
\n

Блокировка защищает возвращённые строки

\n

SELECT ... FOR UPDATE ставит блокировку на строки, которые вернул запрос. Конфликтующий UPDATE, DELETE или другой запрос с блокировкой будет ждать завершения первой транзакции. Блокировка действует до конца транзакции и сама по себе не запрещает последующее изменение строки после COMMIT или ROLLBACK. PostgreSQL не угадывает, какие строки составляют ваш инвариант. Если правило относится к двум врачам, а запрос зафиксировал только текущего врача, второй остаётся вне протокола.

\n

У всех writers должен быть один и тот же scope. Они должны выбирать один набор строк, использовать одинаковый порядок и удерживать блокировку до изменения и commit. ORDER BY помогает сделать порядок явным, но не расширяет набор. Если инвариант зависит от отсутствия строк или от диапазона, одного row lock может быть мало: вставка, не попавшая в result, не ждёт блокировку уже выбранных строк.

\n

Учебный пример: запретить отключение последнего врача

\n

Ниже приведён учебный SQL-пример для объяснения протокола. Он не сообщает production-латентность, количество конфликтов или поведение конкретного сервиса. Пусть таблица содержит shift, doctor и enabled. Операция должна отключить врача только тогда, когда после изменения останется хотя бы один активный врач.

\n
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.

\n

Когда нужен полный retry

\n

При Serializable транзакция должна быть повторяемой целиком. Новый запуск заново выполняет чтения, проверку, решение, записи и commit. Только так решение строится на новом snapshot.

\n
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.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Два запроса изменили разные строки и нарушили правилоПроверка охватила неполный набор или 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 40001Serializable обнаружил конфликт сериализацииПроверить SQLSTATE и границу операции в логахПовторить весь read/decide/write/commit; ограничить число попыток
Правило нарушается после добавления новой записиFOR UPDATE зафиксировал существующие rows, но не описал insertПроверить predicate и конкурентный INSERTПересмотреть модель: constraint, другой lock scope или Serializable
\n

Порядок проверки

\n
  1. Запишите invariant в форме допустимого и запрещённого committed result. Например: для каждой смены active_count >= 1.
  2. Выпишите все rows и predicates, от которых зависит решение. Отдельно перечислите каждый writer, включая фоновые задачи и административные команды.
  3. Проверьте, можно ли выразить правило одним атомарным SQL statement. Если да, измерьте и проверьте именно этот путь.
  4. Если нужен набор строк, зафиксируйте его через один query, задайте стабильный порядок и удерживайте lock до commit. Проверьте два конкурентных соединения.
  5. Если scope нельзя надёжно перечислить, рассмотрите Serializable. Задайте уровень до первого query и обработайте только ожидаемые serialization failures.
  6. Отделите retryable часть от внешних действий. Идемпотентность, outbox или другой механизм нужны там, где commit не может отменить уже отправленный эффект.
  7. Зафиксируйте результат интеграционным тестом на реальной версии PostgreSQL. Учебная fixture объясняет schedule, но не заменяет две сессии базы данных.
\n

Ограничения

\n

Уровень изоляции не исправит неверный invariant и не заставит старый код соблюдать новый protocol. Row lock не защищает строки, которые query не вернул. Serializable может увеличить число отказов и повторов; их частота зависит от длительности транзакции, индексов, плана и конкурентной нагрузки. Учебный пример привязан к PostgreSQL 13 и не задаёт число retry или поведение драйвера для всех систем.

\n

Граница PostgreSQL заканчивается на состоянии базы. Письмо, вызов HTTP-сервиса и сообщение в очереди не откатываются автоматически. Не публикуйте такие эффекты до подтверждённого commit или связывайте их с базой через отдельный надёжный протокол.

\n

Критерий готовности

\n

Операция готова, когда тест с двумя конкурентными сессиями подтверждает invariant после каждого успешного commit, ожидаемый конфликт даёт понятный outcome, а отказ 40001 повторяет всю операцию и не дублирует внешний эффект. В логах видны transaction id, число попыток и причина отказа. Если любой writer обходит описанный scope, критерий не выполнен.

\n

Проверяемые источники

\n" }