diff --git a/editorial/agent-rewrites/252.json b/editorial/agent-rewrites/252.json index 3e91c81..f4d9953 100644 --- a/editorial/agent-rewrites/252.json +++ b/editorial/agent-rewrites/252.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-01-practice-transactions", "title": "Граница транзакции PostgreSQL: как сохранить правило между строками", "excerpt": "Два запроса могут успешно изменить разные строки и вместе нарушить одно бизнес-правило. Разбираем write skew, область блокировки и полный retry на учебном примере PostgreSQL.", - "contentHtml": "

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

\n

Причина скрывается между строками. Каждый запрос сначала читает число активных дежурных, потом меняет одну строку. При двух активных оба запроса принимают решение «можно». Если они не видят изменение друг друга, оба commit проходят. Транзакция сама по себе не защищает predicate, который приложение проверило до записи.

\n

Тезис статьи простой: граница транзакции должна охватывать чтение, проверку и запись, а механизм должен покрывать все строки, от которых зависит invariant. Для известного набора строк подойдёт единый порядок SELECT ... FOR UPDATE. Для более сложного read/write-пути может подойти Serializable с повтором всей операции после 40001. Ни один вариант не доказывает корректность, пока остальные writers не соблюдают тот же протокол.

\n

Что именно нарушается

\n

Пусть таблица хранит дежурства одной смены.

\n
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 должно быть не меньше одного.
\n

Транзакции T1 и T2 начинают работу почти одновременно. Обе читают activeCount = 2. T1 выключает Анну. T2 выключает Бориса. Записи не конфликтуют: это разные primary key. Но решения конфликтуют по смыслу. После двух commit значение равно нулю.

\n

Это write skew. Система не потеряла обновление одной колонки. Она приняла два независимых изменения, которые несовместимы вместе. Дополнительный SELECT count(*) перед commit не исправляет путь. В режиме Read Committed следующий statement может получить новый snapshot, но сам факт чтения не блокирует другого writer и не отменяет уже принятое решение.

\n

Момент чтения важнее названия уровня

\n

В PostgreSQL обычный Read Committed создаёт видимость для каждого statement. Два запроса в одной транзакции могут увидеть разные committed состояния. Repeatable Read удерживает snapshot после первого запроса, но стабильное чтение не превращает cross-row проверку в последовательное выполнение. Serializable добавляет более сильное требование к успешным результатам: они должны быть объяснимы некоторым последовательным порядком. За это одна из конфликтующих транзакций может быть отменена.

\n

Уровень изоляции задают до первого запроса текущей транзакции. Если приложение сначала выполнило «безобидный» SELECT, а затем пытается изменить уровень, оно уже выбрало границу видимости. Поэтому настройка и контракт retry должны находиться рядом с началом операции.

\n
BEGIN;\nSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = :shift\nORDER BY doctor;\n\n-- Проверяем invariant и выполняем write в этой же транзакции.\nUPDATE on_call\nSET enabled = false\nWHERE shift = :shift AND doctor = :current_doctor;\n\nCOMMIT;
\n

Этот фрагмент показывает форму операции. Он не является выполненным запросом и не подтверждает поведение конкретной схемы. Если PostgreSQL отменит транзакцию с SQLSTATE 40001, приложение должно повторить BEGIN → reads → check → writes → COMMIT целиком. Повтор одного UPDATE использует старое решение и обходит защиту.

\n

Явная граница строк

\n

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

\n
BEGIN;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = :shift\nORDER BY doctor\nFOR UPDATE;\n\n-- Проверка видит именно заблокированный набор строк.\n-- Если activeCount <= 1, операцию отклоняем.\nUPDATE on_call\nSET enabled = false\nWHERE shift = :shift AND doctor = :current_doctor;\n\nCOMMIT;
\n

Блокировка защищает результат запроса, а не слово «смена». Если проверка учитывает строки для ролей primary и backup, а lock-запрос выбирает только primary, доказательство неполно. Если другой endpoint меняет те же строки без FOR UPDATE, он обходит протокол. Все writers должны использовать совместимую область и одинаковый порядок. Иначе возможны неполная защита или deadlock.

\n
\"Временная
Одна и та же операция даёт разные результаты в зависимости от границы. Рисунок показывает учебный schedule, а не трассировку реальной базы.
\n

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

\n
Диагностика нарушения cross-row правила
СимптомПричинаПроверкаДействие
Два успеха, правило ложноДва read/write-пути приняли решение по одному старому условиюВоспроизвести порядок T1 read, T2 read, T1 commit, T2 commitСобрать check и write в общий протокол, затем проверить реальный SQL двумя sessions
FOR UPDATE не помогЗаблокированный набор меньше набора invariantСравнить predicate проверки и predicate lock-запросаРасширить scope или изменить модель правила
Запрос ждёт или получает deadlockРазные writers берут несколько строк в разном порядкеВыписать порядок захвата для каждого пути и сопоставить с lock-диагностикойВвести единый порядок, сократить транзакцию, повторять только отменённую работу
40001Serializable обнаружил несовместимую зависимостьПроверить код ошибки, границу транзакции и отсутствие внешнего эффекта до commitПовторить всю операцию с новым snapshot или вернуть контролируемый отказ
Retry создал дубликат во внешней системеПовтор пересёк границу базы и внешнего side effectНайти момент публикации эффекта относительно commitОтделить retryable часть от публикации и задать отдельный idempotency contract
\n

Порядок действий

\n
  1. Сформулируйте invariant после commit одним предложением. Для примера: «в смене остаётся минимум один активный дежурный».
  2. Выпишите полный predicate и все строки, от которых зависит решение. Не заменяйте их одним target ID.
  3. Найдите каждый writer: endpoint, job, админский скрипт и миграцию, которые могут менять эти строки.
  4. Определите минимальный механизм. Для одной строки используйте атомарное изменение или constraint; для известного набора — единый row-lock protocol; для сложного read/write-пути — Serializable с full retry.
  5. Поместите чтение, проверку и запись в короткую транзакцию. Не держите lock во время HTTP-вызова или ожидания пользователя.
  6. Если выбрали FOR UPDATE, зафиксируйте scope и ORDER BY для всех writers. Если выбрали Serializable, повторяйте только подтверждённый 40001.
  7. Проверьте отрицательный путь: после первого commit второе решение должно перечитать состояние и отказаться от записи, дождаться корректного результата или получить контролируемую ошибку.
  8. Проведите integration test с двумя реальными sessions на поддерживаемой версии PostgreSQL и сохраните SQLSTATE, commit outcome и итоговое число активных строк.
\n

Ограничения и критерий готовности

\n

Учебный пример не измеряет latency, throughput, длительность ожидания или число блокировок. Он не заменяет integration test, план запроса и проверку всех writers. Стоимость row locks зависит от размера набора, индексов, длительности транзакции и конкурентной нагрузки. Стоимость Serializable зависит от частоты отмен и возможности безопасно повторить работу. Нельзя переносить результат примера на схему, где появились новые predicate, роли или внешние side effects.

\n

Готовность проверяема. Для поддерживаемой версии базы два конкурентных запуска не оставляют запрещённое состояние; при конфликте второй путь либо ждёт и повторно проверяет invariant, либо получает документированный 40001 и безопасно повторяет всю операцию. Ни один внешний эффект не публикуется до успешного commit без отдельного idempotency-механизма. Если это не подтверждено тестом с реальным SQL, граница транзакции остаётся гипотезой.

\n

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

\n" + "contentHtml": "

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

\n

Причина скрывается между строками. Сначала каждый запрос читает число активных дежурных, затем меняет одну строку. При двух активных оба принимают решение «можно». Если они не видят изменение друг друга, оба COMMIT проходят. Транзакция сама по себе не защищает предикат — условие выборки, которое приложение проверило до записи.

\n

Граница транзакции должна охватывать чтение, проверку и запись, а механизм — все строки, от которых зависит инвариант: условие, которое должно быть истинным после COMMIT. Для известного набора строк подойдёт единый порядок SELECT ... FOR UPDATE. Для более сложного read/write-пути может подойти Serializable с повтором всей операции после 40001. Корректность нужно проверять на всех остальных путях записи: один writer, обходящий протокол, оставляет дыру.

\n

Учебная сцена: что именно нарушается

\n

Пусть таблица хранит дежурства одной смены.

\n
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 должно быть не меньше одного.
\n

Транзакции T1 и T2 начинают работу почти одновременно. Обе читают activeCount = 2. T1 выключает Анну. T2 выключает Бориса. Записи не конфликтуют: это разные primary key. Но решения конфликтуют по смыслу. После двух COMMIT значение равно нулю.

\n

Это write skew — перекос записи: система не потеряла обновление одной колонки, а приняла два независимых изменения, несовместимых вместе. Дополнительный SELECT count(*) перед COMMIT не исправляет путь. В режиме Read Committed следующий statement может получить новый snapshot, но само чтение не блокирует другого writer и не отменяет уже принятое решение.

\n

Момент чтения важнее названия уровня

\n

В PostgreSQL обычный Read Committed получает snapshot на начало каждого statement. Два запроса в одной транзакции могут увидеть разные зафиксированные состояния. Repeatable Read удерживает snapshot после первого запроса, но стабильное чтение не превращает проверку нескольких строк в последовательное выполнение. Serializable добавляет более сильное требование к успешным результатам: они должны быть объяснимы некоторым последовательным порядком. За это одна из конфликтующих транзакций может быть отменена.

\n

Уровень изоляции задают до первого запроса текущей транзакции. Если приложение сначала выполнило «безобидный» SELECT, а затем пытается изменить уровень, оно уже выбрало границу видимости. Поэтому настройка и контракт повтора должны находиться рядом с началом операции.

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

Это форма prepared statement: $1 и $2 подставляет драйвер. Фрагмент не является выполненным запросом и не подтверждает поведение конкретной схемы. Если PostgreSQL отменит транзакцию с SQLSTATE 40001, приложение должно повторить BEGIN → reads → check → writes → COMMIT целиком. Повтор одного UPDATE использует старое решение и обходит защиту.

\n

Явная граница строк

\n

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

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

Блокировка защищает результат запроса, а не слово «смена». Если проверка учитывает строки для ролей primary и backup, а lock-запрос выбирает только primary, доказательство неполно. Если другой endpoint меняет те же строки без FOR UPDATE, он обходит протокол. Все writers должны использовать совместимую область и одинаковый порядок. Иначе возможны неполная защита или deadlock.

\n
\"Временная
Одна и та же операция даёт разные результаты в зависимости от границы. Рисунок показывает учебный schedule, а не трассировку реальной базы.
\n

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

\n
Диагностика нарушения cross-row правила
СимптомПричинаПроверкаДействие
Два успеха, правило ложноДва read/write-пути приняли решение по одному старому условиюВоспроизвести порядок T1 read, T2 read, T1 commit, T2 commitСобрать check и write в общий протокол, затем проверить реальный SQL двумя sessions
FOR UPDATE не помогЗаблокированный набор меньше набора invariantСравнить predicate проверки и predicate lock-запросаРасширить scope или изменить модель правила
Запрос ждёт или получает deadlockРазные writers берут несколько строк в разном порядкеВыписать порядок захвата для каждого пути и сопоставить с lock-диагностикойВвести единый порядок, сократить транзакцию, повторять только отменённую работу
40001Serializable обнаружил несовместимую зависимостьПроверить код ошибки, границу транзакции и отсутствие внешнего эффекта до commitПовторить всю операцию с новым snapshot или вернуть контролируемый отказ
Retry создал дубликат во внешней системеПовтор пересёк границу базы и внешнего side effectНайти момент публикации эффекта относительно commitОтделить retryable часть от публикации и задать отдельный idempotency contract
\n

Порядок действий

\n
  1. Сформулируйте invariant после commit одним предложением. Для примера: «в смене остаётся минимум один активный дежурный».
  2. Выпишите полный predicate и все строки, от которых зависит решение. Не заменяйте их одним target ID.
  3. Найдите каждый writer: endpoint, job, админский скрипт и миграцию, которые могут менять эти строки.
  4. Определите минимальный механизм. Для одной строки используйте атомарное изменение или constraint. Не пытайтесь выразить правило по другим строкам через CHECK: PostgreSQL проверяет его на новой или изменённой строке, а не как постоянный cross-row invariant. Для известного набора — единый row-lock protocol; для сложного read/write-пути — Serializable с full retry.
  5. Поместите чтение, проверку и запись в короткую транзакцию. Не держите lock во время HTTP-вызова или ожидания пользователя.
  6. Если выбрали FOR UPDATE, зафиксируйте scope и ORDER BY для всех writers. Если выбрали Serializable, повторяйте только подтверждённый 40001.
  7. Проверьте отрицательный путь: после первого commit второе решение должно перечитать состояние и отказаться от записи, дождаться корректного результата или получить контролируемую ошибку.
  8. Проведите integration test с двумя реальными sessions на поддерживаемой версии PostgreSQL и сохраните SQLSTATE, commit outcome и итоговое число активных строк.
\n

Ограничения и критерий готовности

\n

Учебный пример не измеряет latency, throughput, длительность ожидания или число блокировок. Он не заменяет integration test, план запроса и проверку всех writers. Стоимость row locks зависит от размера набора, индексов, длительности транзакции и конкурентной нагрузки. Стоимость Serializable зависит от частоты отмен и возможности безопасно повторить работу. Нельзя переносить результат примера на схему, где появились новые predicate, роли или внешние side effects.

\n

Готовность проверяема. Для поддерживаемой версии базы два конкурентных запуска не оставляют запрещённое состояние; при конфликте второй путь либо ждёт и повторно проверяет invariant, либо получает документированный 40001 и безопасно повторяет всю операцию. Ни один внешний эффект не публикуется до успешного commit без отдельного idempotency-механизма. Если это не подтверждено тестом с реальным SQL, граница транзакции остаётся гипотезой.

\n

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

\n" }