diff --git a/editorial/agent-rewrites/250.json b/editorial/agent-rewrites/250.json index 67d0cb4..14ebb82 100644 --- a/editorial/agent-rewrites/250.json +++ b/editorial/agent-rewrites/250.json @@ -1,7 +1 @@ -{ - "index": 250, - "slug": "editorial-2021-01-field-transactions", - "title": "Граница транзакции PostgreSQL: как сохранить cross-row инвариант", - "excerpt": "Два успешных запроса могут вместе оставить ночную смену без дежурного. Разбираем scope проверки, row-level locks, deadlock и полный retry для SERIALIZABLE.", - "contentHtml": "

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

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

Что именно сломалось

Проблема начинается с cross-row инварианта: count(enabled rows for one shift) >= 1. Поле enabled описывает одну строку. Инвариант описывает набор строк одной смены. Если приложение проверяет только текущего врача, оно не видит, кто ещё дежурит. Если оно читает весь набор, но делает проверку и запись в разных границах, другой writer может изменить набор между этими действиями.

В стандартном для PostgreSQL уровне READ COMMITTED обычный SELECT получает снимок на момент начала statement. Два SELECT внутри одной transaction не обязаны видеть одинаковое состояние. Даже если оба запроса прочитали корректный снимок, это ещё не разрешает им независимо принять решения по одному общему правилу. Snapshot отвечает на вопрос «что вижу», а не на вопрос «можно ли мой будущий commit сочетать с другим commit».

Обычный CHECK тоже не является универсальным решением. Он проверяет новую или изменённую строку. Он не поддерживает постоянное правило, которое зависит от других строк таблицы. Если правило можно выразить через UNIQUE, EXCLUDE или FOREIGN KEY, лучше использовать constraint. Для правила «в каждой смене остаётся хотя бы один активный» обычно нужен согласованный протокол чтения и записи.

Схема диагностики конфликта транзакций: write skew, неполный scope блокировки, deadlock и serialization failure
Схема переводит общий симптом в четыре проверяемые формы: несовместимые решения, неполный scope, разный порядок блокировок и SQLSTATE 40001.

Механизм: snapshot, строки и commit

Наивный путь выглядит так: запрос считает активных сотрудников, приложение принимает решение, затем выключает текущего сотрудника. Временная шкала может быть такой: T1 читает active_count = 2; T2 читает active_count = 2; T1 выключает Анну и фиксирует результат; T2 выключает Бориса и фиксирует результат. Ни один statement не увидел грязное чтение. Ошибка возникла потому, что два разрешённых по отдельности решения нельзя объединить в один допустимый итог.

-- Учебный anti-pattern. Этот SQL не запускался при подготовке статьи. BEGIN; SELECT count(*) AS active_count FROM on_call WHERE shift = :shift AND enabled = true; -- Приложение отдельно решает, можно ли выключить себя. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Две разные UPDATE-строки не защищают правило active_count >= 1.

У этого пути две границы. Первая — набор строк, который участвует в проверке. Вторая — момент, когда результат становится committed. Если другой writer использует другой predicate, проверяет только одну строку или пишет вне этой transaction, приложение не имеет общего протокола. Название endpoint и наличие BEGIN этого не меняют.

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

Если scope известен, можно сначала заблокировать весь набор, затем посчитать его в той же transaction. В примере scope — все дежурные ночной смены. Стабильный ORDER BY doctor задаёт единый порядок получения нескольких строк. T2 будет ждать, пока T1 завершит transaction. После пробуждения T2 должна читать состояние, которое теперь включает commit T1, и отказаться от своей записи, если активным остался только один человек.

-- Учебный protocol для фиксированного набора строк. Команды не выполнялись на PostgreSQL при подготовке статьи. BEGIN; SELECT doctor, enabled FROM on_call WHERE shift = :shift ORDER BY doctor FOR UPDATE; -- Проверяем count по возвращённому набору в этой же transaction. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Все writers этого правила должны использовать тот же scope и порядок.

FOR UPDATE блокирует возвращённые строки до окончания transaction. Он не блокирует абстрактную «смену» и не заставляет чужой код использовать тот же запрос. Поэтому нужно сравнить predicate бизнес-правила с predicate блокировки. Если инвариант зависит от строк с другой ролью, региона или временного интервала, запрос должен покрыть и их. Если возможна вставка новой строки, блокировка существующих строк может не описывать весь риск. В такой ситуации меняют модель или выбирают другой механизм.

Ожидание блокировки и deadlock — разные симптомы. Ожидание T2 за строками T1 может быть штатной частью протокола. Deadlock возникает, когда T1 держит A и ждёт B, а T2 держит B и ждёт A. Единый порядок получения строк уменьшает такую возможность. Если сервер всё же отменил transaction, повторяют отменённую операцию только после проверки причины. Бесконечный цикл вокруг любой ошибки маскирует нарушения данных, сетевые сбои и ошибки валидации.

Serializable и полный retry

SERIALIZABLE полезен, когда правило зависит от чтений и его трудно свести к небольшому заранее известному набору строк. PostgreSQL допускает commit только для результата, который можно объяснить некоторым последовательным порядком операций. Это не означает, что обе concurrent transaction всегда завершатся успешно. При опасной зависимости одна может получить serialization failure с SQLSTATE 40001.

-- Учебный shape. Нужна адаптация к драйверу и проектной policy. BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT doctor, enabled FROM on_call WHERE shift = :shift; -- Здесь выполняются проверка invariant и нужная запись. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- При 40001 повторяется вся операция, а не только UPDATE.

Полный retry означает новый BEGIN, новые чтения, новую проверку, новую запись и новый COMMIT. T2 после отката не может использовать решение «в смене два активных», которое она получила до commit T1. Она должна получить новый snapshot и заново увидеть один активный ряд. Если условие больше не выполняется, операция возвращает отказ без записи.

async function retryWholeOperation(runOnce) { for (let attempt = 1; attempt <= 3; attempt += 1) { try { return await runOnce(); } catch (error) { if (error.code !== '40001' || attempt === 3) throw error; } } } // Учебный shape: runOnce должен включать reads, decision, writes и commit. // Внешний side effect нельзя бездумно помещать внутрь такого retry.

Этот фрагмент не является готовым адаптером драйвера. В реальной системе нужны rollback, deadline, backoff, журнал причины и политика после исчерпания попыток. Письмо, публикация события или HTTP-вызов внешнего сервиса не откатываются транзакцией PostgreSQL. Если такой side effect произошёл до commit, повтор может отправить его дважды. Сначала фиксируют данные, затем публикуют внешний эффект отдельным механизмом с собственным контрактом идемпотентности.

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

Диагностика конкурентной операции
СимптомПричинаПроверкаДействие
Два запроса успешны, invariant нарушенОба приняли решение по одному старому состояниюЗафиксировать interleaving: два read, два write, два commitОбъединить read, check и write в общий protocol
FOR UPDATE есть, ошибка остаётсяЗаблокированный set меньше set инвариантаСравнить оба predicate и всех writersРасширить scope или изменить модель правила
Запрос долго ждётДругой writer держит ту же строкуСопоставить statement, transaction и lock в pg_locksСократить transaction и принять ожидаемое ожидание
Deadlock и отмена transactionРазные пути берут строки в разном порядкеВыписать порядок A/B для каждого writerУстановить один order и повторять только подтверждённую отмену
SQLSTATE 40001Serializable обнаружил опасную зависимостьПроверить outcome transaction и момент side effectПовторить всю operation с новым snapshot
После retry появился duplicate effectВнешняя публикация попала внутрь повторяемой границыНайти её точное место относительно commitВынести публикацию и задать idempotency contract

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

  1. Запишите invariant как условие после успешного commit. Для примера: в каждой ночной смене остаётся минимум один активный дежурный.
  2. Выпишите полный scope: shift, role, region, временной диапазон и возможные новые строки. Одного doctor_id недостаточно.
  3. Найдите все writers этого набора. Один обходящий protocol UPDATE обесценивает защиту остальных путей.
  4. Запишите фиксированный schedule двух операций: что прочла каждая, когда приняла решение, кто ждал и какой commit произошёл первым.
  5. Проверьте уровень изоляции до первого query. SET TRANSACTION нельзя переносить после чтения.
  6. Для явной блокировки проверьте полный returned set и единый order. Для SERIALIZABLE проверьте обработку 40001.
  7. Убедитесь, что retry повторяет чтения, проверку и запись. Не повторяйте устаревший blind write.
  8. Сначала запустите быструю учебную модель, затем integration test на разрешённой PostgreSQL с двумя sessions и заранее согласованным cleanup.
  9. Отдельно проверьте side effects после commit. В критерий готовности включите отказ, retry и поведение после исчерпания попыток.

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

Учебный пример фиксирует две строки, одну смену и один schedule. Он не измеряет latency, throughput, contention или длительность locks. Он не показывает план PostgreSQL, поведение конкретного драйвера и все writers приложения. SQL-фрагменты выше не выполнялись при подготовке текста. Поэтому нельзя переносить их как готовую production-конфигурацию и нельзя объявлять проблему доказанной только по fixture.

Для одной строки часто достаточно атомарного UPDATE ... WHERE с проверяемым условием. Для cross-row правила нужен полный scope. Constraint может быть лучше transaction protocol, если правило выразимо на уровне схемы. Явная блокировка подходит при известном наборе. SERIALIZABLE подходит при сложной зависимости, если операция безопасно повторяется. Ни один вариант не отменяет необходимость перечислить writers и внешние эффекты.

Операция готова к выпуску, когда integration test на целевой версии PostgreSQL запускает два конкурентных пути и подтверждает запрещённый наивный schedule, выбранный механизм, правильный итог после ожидания или retry, SQLSTATE для отменённой transaction и отсутствие duplicate side effect. Дополнительно тест должен показать, что обходящий writer не остаётся незамеченным. Это проверяемый критерий. Наличие BEGIN в коде таким критерием не является.

Граница транзакции заканчивается там, где заканчивается состояние базы и её protocol. Назовите invariant, scope, порядок locks и момент commit. После этого ошибка перестаёт быть загадочным «конфликтом транзакций»: её можно свести к неполному набору, разному порядку, ожидаемой блокировке или serialization failure и выбрать конкретное действие.

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

" -} +{"index":250,"slug":"editorial-2021-01-field-transactions","title":"Граница транзакции PostgreSQL: как сохранить cross-row инвариант","excerpt":"Два успешных запроса могут вместе оставить ночную смену без дежурного. Разбираем scope проверки, row-level locks, deadlock и полный retry для SERIALIZABLE.","contentHtml":"

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

BEGIN сам по себе не защищает правило между строками. Нужно определить полный scope инварианта, поместить чтение, проверку и запись в одну короткую transaction и выбрать механизм, который соблюдают все writers. Для фиксированного набора строк подойдёт единый порядок SELECT ... FOR UPDATE. Для более сложной зависимости подойдёт SERIALIZABLE, но тогда приложение обязано повторять всю операцию после подтверждённого serialization failure. Повтор одного последнего UPDATE оставляет устаревшее решение.

Что именно сломалось

Проблема начинается с cross-row инварианта: count(enabled rows for one shift) >= 1. Поле enabled описывает одну строку. Инвариант описывает набор строк одной смены. Если приложение проверяет только текущего врача, оно не видит, кто ещё дежурит. Если оно читает весь набор, но делает проверку и запись в разных границах, другой writer может изменить набор между этими действиями.

В стандартном для PostgreSQL уровне READ COMMITTED обычный SELECT получает snapshot на момент начала statement. Два SELECT внутри одной transaction могут увидеть разные данные, если между ними commit-ится другая transaction. При этом запрос не читает незакоммиченные изменения. Snapshot отвечает на вопрос «что вижу сейчас», но не гарантирует, что решение можно безопасно сочетать с concurrent commit.

Обычный CHECK тоже не является универсальным решением. PostgreSQL проверяет его для новой или изменённой строки и не поддерживает через него постоянное правило, зависящее от данных других строк. Если ограничение можно выразить через UNIQUE, EXCLUDE или FOREIGN KEY, лучше использовать constraint. Для правила «в каждой смене остаётся хотя бы один активный» нужен согласованный протокол чтения и записи либо другая модель данных.

Схема диагностики конфликта транзакций: write skew, неполный scope блокировки, deadlock и serialization failure
Схема переводит общий симптом в четыре проверяемые формы: несовместимые решения, неполный scope, разный порядок блокировок и SQLSTATE 40001.

Сценарий и минимальное воспроизведение

Сначала зафиксируем модель, чтобы не спорить о словах. Ниже — учебный стенд: две строки принадлежат одной смене, а удалять себя можно только пока после операции остаётся хотя бы один активный врач. Эти команды не запускались при подготовке статьи; они задают минимальные данные для integration test на разрешённом экземпляре PostgreSQL.

CREATE TABLE on_call ( shift text NOT NULL, doctor text NOT NULL, enabled boolean NOT NULL, PRIMARY KEY (shift, doctor) ); INSERT INTO on_call (shift, doctor, enabled) VALUES ('night', 'anna', true), ('night', 'boris', true); -- В T1 и T2 отдельно выполняется проверка count(*) и UPDATE своей строки. -- После обоих COMMIT ожидаем: active_count = 0, что нарушает invariant.

Наивный протокол выглядит так: T1 и T2 начинают transaction, каждая читает active_count = 2, принимает решение и выключает свою строку. T1 фиксирует результат, затем T2 фиксирует свой. Ни один statement не увидел грязное чтение. Ошибка возникла потому, что два разрешённых по отдельности решения нельзя объединить в один допустимый итог.

-- Учебный anti-pattern. Этот SQL не запускался при подготовке статьи. BEGIN; SELECT count(*) AS active_count FROM on_call WHERE shift = :shift AND enabled = true; -- Приложение отдельно решает, можно ли выключить себя. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Две разные UPDATE-строки не защищают правило active_count >= 1.

У этого пути две границы. Первая — набор строк, который участвует в проверке. Вторая — момент, когда результат становится committed. Если другой writer использует другой predicate, проверяет только одну строку или пишет вне этой transaction, приложение не имеет общего протокола. Название endpoint и наличие BEGIN этого не меняют.

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

Если scope известен, можно сначала заблокировать весь набор, затем посчитать его в той же transaction. В примере scope — все дежурные ночной смены. Одинаковый ORDER BY doctor фиксирует логический порядок обработки нескольких строк для всех writers и снижает риск deadlock. Это не блокировка «смены» и не автоматическое принуждение чужого кода: каждый writer должен использовать тот же scope и порядок.

-- Учебный protocol для фиксированного набора строк. Команды не выполнялись на PostgreSQL при подготовке статьи. BEGIN; SELECT doctor, enabled FROM on_call WHERE shift = :shift ORDER BY doctor FOR UPDATE; -- После получения набора считаем активные строки и принимаем решение. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Если active_count = 1, UPDATE не выполняется, transaction возвращает отказ.

FOR UPDATE блокирует возвращённые строки до окончания transaction. T2 ждёт освобождения строки, а после commit T1 получает обновлённую версию строки и должна заново проверить условие. Поэтому проверка должна использовать результат заблокированного запроса, а не старое значение, полученное до ожидания.

Сравнивать нужно predicate бизнес-правила с predicate блокировки. Если инвариант зависит от другой роли, региона или временного интервала, запрос должен покрыть и эти строки. Если возможна вставка новой строки, блокировка существующих строк может не описывать весь риск. Тогда меняют модель или выбирают механизм, который учитывает появление новых записей.

Ожидание блокировки и deadlock — разные симптомы. Ожидание T2 за строками T1 может быть штатной частью протокола. Deadlock возникает, когда T1 держит A и ждёт B, а T2 держит B и ждёт A. PostgreSQL отменит одну из таких transaction. Единый порядок получения нескольких объектов — первая защита; если deadlock всё же разрешился отменой, повторяют всю отменённую операцию только после проверки причины. Бесконечный цикл вокруг любой ошибки маскирует нарушения данных, сетевые сбои и ошибки валидации.

SERIALIZABLE и полный retry

SERIALIZABLE полезен, когда правило зависит от чтений и его трудно свести к небольшому заранее известному набору строк. Для concurrent writers, которые участвуют в этом уровне изоляции, PostgreSQL допускает commit только для результата, совместимого с некоторым последовательным порядком операций. Это не означает, что обе transaction завершатся успешно: при опасной зависимости одна может получить serialization failure с SQLSTATE 40001. Writer, который обходит этот protocol, не получает обещаний от одного лишь уровня изоляции.

-- Учебный shape. Нужна адаптация к драйверу и проектной policy. BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT doctor, enabled FROM on_call WHERE shift = :shift; -- Здесь выполняются проверка invariant и нужная запись. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- При 40001 повторяется вся операция, а не только UPDATE.

Полный retry означает новый BEGIN, новые чтения, новую проверку, новую запись и новый COMMIT. T2 после rollback не может использовать решение «в смене два активных», которое получила до commit T1. Она должна получить новый snapshot и заново увидеть один активный ряд. Если условие больше не выполняется, операция возвращает отказ без записи.

async function retryWholeOperation(runOnce) { for (let attempt = 1; attempt <= 3; attempt += 1) { try { return await runOnce(); } catch (error) { if (error.code !== '40001' || attempt === 3) throw error; } } } // Учебный shape: runOnce должен включать reads, decision, writes и commit. // Перед следующим BEGIN нужен rollback; между попытками нужен bounded backoff.

Этот фрагмент не является готовым адаптером драйвера. В реальной системе нужны rollback, deadline, backoff, журнал причины и политика после исчерпания попыток. Письмо, публикация события или HTTP-вызов внешнего сервиса не откатываются транзакцией PostgreSQL. Если такой side effect произошёл до commit, retry может отправить его дважды. Сначала фиксируют данные, затем публикуют внешний эффект отдельным механизмом с собственным контрактом идемпотентности.

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

Диагностика конкурентной операции
СимптомПричинаПроверкаДействие
Два запроса успешны, invariant нарушенОба приняли решение по одному старому состояниюЗафиксировать interleaving: два read, два write, два commitОбъединить read, check и write в общий protocol
FOR UPDATE есть, ошибка остаётсяЗаблокированный set меньше set инвариантаСравнить оба predicate и всех writersРасширить scope или изменить модель правила
Запрос долго ждётДругой writer держит ту же строкуСопоставить statement, transaction и данные о lock в pg_locksСократить transaction и принять ожидаемое ожидание
Deadlock и отмена transactionРазные пути берут строки в разном порядкеВыписать порядок A/B для каждого writerУстановить один order и повторять только подтверждённую отмену
SQLSTATE 40001Serializable обнаружил опасную зависимостьПроверить outcome transaction и момент side effectПовторить всю operation с новым snapshot
После retry появился duplicate effectВнешняя публикация попала внутрь повторяемой границыНайти её точное место относительно commitВынести публикацию и задать idempotency contract

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

  1. Запишите invariant как условие после успешного commit. Для примера: в каждой ночной смене остаётся минимум один активный дежурный.
  2. Выпишите полный scope: shift, role, region, временной диапазон и возможные новые строки. Одного doctor_id недостаточно.
  3. Найдите все writers этого набора. Один обходящий protocol UPDATE обесценивает защиту остальных путей.
  4. Запишите фиксированный schedule двух операций: что прочла каждая, когда приняла решение, кто ждал и какой commit произошёл первым.
  5. Проверьте уровень изоляции до первого query. SET TRANSACTION нельзя переносить после чтения.
  6. Для явной блокировки проверьте полный returned set и единый order. Для SERIALIZABLE проверьте, что все нужные writers участвуют в уровне, и обработку 40001.
  7. Убедитесь, что retry повторяет чтения, проверку и запись. Не повторяйте устаревший blind write.
  8. Сначала запустите учебную модель на тестовой PostgreSQL, затем integration test с двумя sessions и заранее согласованным cleanup.
  9. Отдельно проверьте side effects после commit. В критерий готовности включите отказ, retry и поведение после исчерпания попыток.

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

Учебный пример фиксирует две строки, одну смену и один schedule. Он не измеряет latency, throughput, contention или длительность locks. Он не показывает план PostgreSQL, поведение конкретного драйвера и всех writers приложения. SQL-фрагменты выше не выполнялись при подготовке текста. Поэтому нельзя переносить их как готовую production-конфигурацию и нельзя объявлять проблему доказанной только по fixture.

Для одной строки часто достаточно атомарного UPDATE ... WHERE с проверяемым условием. Для cross-row правила нужен полный scope. Constraint может быть лучше transaction protocol, если правило выразимо на уровне схемы. Явная блокировка подходит при известном наборе. SERIALIZABLE подходит при сложной зависимости, если операция безопасно повторяется и все критичные writers участвуют в протоколе. Ни один вариант не отменяет необходимость перечислить writers и внешние эффекты.

Операция готова к выпуску, когда integration test на целевой версии PostgreSQL запускает два конкурентных пути и подтверждает запрещённый наивный schedule, выбранный механизм, правильный итог после ожидания или retry, SQLSTATE для отменённой transaction и отсутствие duplicate side effect. Дополнительно тест должен показать, что обходящий writer не остаётся незамеченным. Это проверяемый критерий. Наличие BEGIN в коде таким критерием не является.

Граница транзакции заканчивается там, где заканчивается состояние базы и её protocol. Назовите invariant, scope, порядок locks и момент commit. После этого ошибка перестаёт быть загадочным «конфликтом транзакций»: её можно свести к неполному набору, разному порядку, ожидаемой блокировке или serialization failure и выбрать конкретное действие.

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

"}