Симптом в поле звучит неприятно именно потому, что оба запроса вернули успех. Один дежурный выключил Анну, другой — Бориса. Каждая команда изменила «свою» строку, ошибок SQL не видно, но ночная смена осталась без активного человека. Цена здесь выше локальной ошибки формы: следующий процесс считает данные допустимыми и может принять решение на ложном состоянии. Если в разборе просто написать «поставим транзакцию», команда получит ещё один туман вместо ответа, какая строка, какой predicate и какой момент commit были потеряны.
Ниже — controlled разбор, а не рассказ об измеренном production-инциденте. У нас фиксированы две строки, один invariant и один schedule. Это делает диагноз проверяемым: оба requests стартуют от двух active rows, T1 выключает Анну, T2 — Бориса. Затем мы смотрим четыре причины, которые в настоящем проекте нельзя смешивать: check оказался вне transaction, lock покрывает неполный set, locks взяты в разном order либо Serializable отменил operation. Для каждой причины есть отдельная проверка и безопасное следующее действие.
Фиксируем evidence до первой правки
Первый артефакт — минимальный contract: invariant, scope, order, write и outcome. Без predicate нельзя проверить, что FOR UPDATE вернул нужные rows; без момента side effect нельзя оценить безопасность retry.
| Артефакт | Содержимое в примере | Диагностический вопрос | Не является доказательством |
|---|---|---|---|
| Invariant | activeCount >= 1 | какой committed result запрещён | формы UI или имени endpoint |
| Scope query | два primary rows для одной смены | какие rows участвуют в решении | того, что все другие writers используют same query |
| Lock order | ORDER BY doctor | в каком порядке операции просят несколько rows | того, что deadlock невозможен во всём приложении |
| Retry contract | whole operation after confirmed failure | какие reads и decisions повторяются | безопасности уже отправленного внешнего эффекта |
| Fixture schedule | T1 read, T2 read, T1 commit, T2 commit/wake/retry | какое взаимное положение шагов проверяем | настоящего wait, lock graph или latency сервера |
В разборе используется PostgreSQL 13 — актуальная ветка января 2021 после 13.1. Версия, driver и exact SQL path всё равно должны быть входами integration test; учебная схема не переносит гарантию на любое окружение.
Диагностика начинается с формы конфликта
Первый shape — write skew: T1 и T2 пишут разные rows, но оба решили по activeCount = 2. Локально writes допустимы, вместе оставляют ноль. Повтор старого решения не помогает; нужно заново читать и проверять invariant.
Второй shape — неполный scope. SELECT FOR UPDATE блокирует returned rows, а не сущности приложения. Сравните predicate invariant и locking query. Если sets различаются, сузьте правило до одной row или расширьте protocol.
Третий shape — wait или deadlock. Ожидание одного row может быть штатным. Deadlock возникает, если T1 держит A и ждёт B, а T2 — наоборот. Зафиксируйте один acquisition order и retry-те только отменённую transaction.
Четвёртый shape — SQLSTATE 40001. Serializable отверг dangerous read/write pattern. Убедитесь, что failure подтверждён и side effect не ушёл до COMMIT; затем повторите entire operation с новым snapshot.
Две проверяемые SQL-формы, но ни одной выполненной команды
Первый пример показывает protocol rows для известной смены. Он не утверждает, что этот exact query подходит любому schema. В одном проекте scope может быть ограничен role и region, в другом появятся временные интервалы или новые rows во время операции. SQL нужен здесь как предмет ревью: можно увидеть WHERE, ORDER BY, момент FOR UPDATE и write. Выполнять его стоит только на разрешённой контрольной БД, где есть две sessions и заранее согласованный cleanup.
-- Учебный protocol для fixed row set; команды этой статьёй не запускались.
BEGIN;
SELECT doctor, enabled
FROM on_call
WHERE shift = 'night'
ORDER BY doctor
FOR UPDATE;
-- Проверяем invariant по возвращённому набору в этой же transaction.
UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor;
COMMIT;
-- Все writers этого правила должны брать тот же scope и тот же order.
Второй пример — минимальная диагностика контекста session. SHOW transaction_isolation отвечает, с каким уровнем вообще началась текущая transaction. pg_locks может показать locks, удерживаемые текущим backend, но сам по себе не объясняет business invariant и не строит историю всех sessions. Поэтому вывод из него всегда сопоставляют с exact SQL, временем schedule и ожидаемым lock mode. В этой партии команда не выполнялась и никаких реальных PID, locks или waits мы не заявляем.
-- Команды для отдельного разрешённого стенда; в этой работе не выполнялись.
SHOW transaction_isolation;
SELECT locktype, mode, granted
FROM pg_locks
WHERE pid = pg_backend_pid()
ORDER BY locktype, mode;
-- Результат надо сверять с конкретным SQL, версией и планом проверки.
Фиксированный schedule позволяет проверить текст без сервера
Fixture запускает три ветки. Первая намеренно плохая: T1 и T2 читают одно initial state и оба commit разные off-flags. Вторая вводит teaching boundary для rows [anna, boris] в одном fixed order. T2 отмечается как blocked до T1, затем читает уже одно enabled значение и отказывается от write. Третья ветка добавляет model version: T2 видит, что T1 изменил state после её read, делает full restart и снова получает precondition false. Assertions фиксируют результат каждой ветки.
# Только in-memory schedule из revision-модуля; PostgreSQL не запускается.
node scripts/upgrade-2021-01.mjs --verify-fixture
# Ожидаемые свойства:
# naiveScheduleViolatesInvariant: true
# secondOperationIsBlockedInTeachingSchedule: true
# teachingBoundaryPreservesInvariant: true
# restartModelRetriesWholeOperationAndRejects: true
| Симптом | Вероятная причина | Точная проверка | Безопасное действие |
|---|---|---|---|
| Два success, invariant false | two reads приняли решение по одному старому условию | воспроизвести fixed interleaving и посчитать result after both commits | объединить check/write в protocol, затем тестировать real SQL |
FOR UPDATE есть, ошибка остаётся | locked set меньше invariant set | сравнить predicate checks и predicate locking query | расширить scope или изменить модель правила |
| Запрос ждёт/получает deadlock | multi-row paths имеют разный acquisition order | выписать order для каждого writer | единый order, short transaction, retry only aborted work |
40001 | Serializable выявил dangerous dependency | проверить transaction outcome и отсутствие premature side effect | full retry с новым snapshot либо controlled rejection |
| Повтор создал duplicate outside effect | retry пересёк границу БД | найти момент публикации effect относительно commit | вынести publication из retryable boundary и задать отдельный contract |
Полный retry — не цикл вокруг одной команды
Когда operation зависит от прочитанных rows, retry обязан заново получить эти rows и заново решить, разрешено ли действие. В первом run T2 решила «можно выключить Бориса», потому что увидела двух active. После T1 такого решения больше нет. Повтор только UPDATE on_call SET enabled = false обходит новый check и делает именно то, от чего мы защищаемся. Это можно увидеть даже без сервера: version fixture изменился, значит cached decision непригоден. В PostgreSQL причина может выражаться иначе, но правило для business logic остаётся тем же.
async function retryWholeOperation(runOnce) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await runOnce(); // BEGIN, reads, check, writes, COMMIT together
} catch (error) {
if (error.code !== '40001' || attempt === 3) throw error;
}
}
}
// Учебный shape. Он не является готовым driver adapter или retry policy.
Фрагмент задаёт только shape retry. Перед его применением нужно подтвердить, что runOnce не публикует внешний эффект до commit; jitter, deadline и policy остальных ошибок зависят от operation.
Нумерованный маршрут разбора
- Сохраните invariant и exact predicate. Начните не с ошибки драйвера, а с запрещённого committed state.
- Запишите fixed schedule двух operations: что каждая прочла, когда решила писать и в каком порядке commits или waits произошли.
- Проверьте version PostgreSQL, isolation и границу первого statement. Не меняйте уровень после уже выполненного query.
- Сравните scope locking query с scope invariant. Для multi-row paths зафиксируйте один acquisition order.
- Разделите outcome: normal wait, deadlock abort, serialization failure и business rejection требуют разных действий.
- Для confirmed
40001повторите всю transaction и заново вычислите решение; не повторяйте blind write. - Отдельно проверьте side effects после transaction. Если их нельзя повторить, они не должны жить внутри blind retry.
- Покройте выбранный case integration test на разрешённой БД, а fixture оставьте как быстрый test контракта текста.
Где разбор останавливается
Эта статья не утверждает, что обнаружила actual blocker, настроила deadlock timeout или измерила contention. Не запускались PostgreSQL, две session, SQL-план, driver, HTTP, queue или browser. Диаграмма и fixture помогают разобрать модель, но не заменяют server evidence. Это не слабость материала: граница доказательства защищает от ложной уверенности, когда красивый narrative выдаёт учебный schedule за трассу системы.
Хороший итог разбора звучит так: «инвариант зависит от двух rows, наивный schedule допускает два conflicting decisions; выбран protocol описывает scope, order и handling конкретного failure; остаётся проверить его на PostgreSQL 13 в двух sessions». Такой итог меньше похож на громкий incident report, но его можно отдать следующему инженеру и проверить. Для автора начала 2021 это более полезная экспертиза: видеть не только SQL-команду, но и границу, в которой она действительно что-то доказывает.
Все имена дежурных, schedule, версии, результаты и SQL-фрагменты ниже учебные. Revision-модуль работает только в памяти: он не открывает PostgreSQL, не выполняет SQL, не создаёт настоящее lock, не измеряет wait и не описывает реальную нагрузку.
Проверяемые источники
- PostgreSQL 13.1 released, 12 November 2020 — к январю 2021 ветка 13 уже имела минорный релиз 13.1; статьи фиксируют именно документацию PostgreSQL 13, а не современное поведение другой версии
- PostgreSQL 13: Transaction Isolation — описывает Read Committed как default, snapshot-границы, PostgreSQL Repeatable Read, Serializable и необходимость повторять отменённую транзакцию
- PostgreSQL 13: Explicit Locking — описывает row-level lock modes, конфликтующие операции, освобождение lock при завершении transaction и риск deadlock при разном порядке
- PostgreSQL 13: Data Consistency Checks at the Application Level — разделяет Serializable-подход и explicit blocking locks; отдельно предупреждает, что SELECT FOR UPDATE не сохраняет строку после окончания transaction сам по себе
- PostgreSQL 13: SET TRANSACTION — задаёт синтаксис isolation level и ограничение: уровень нельзя менять после первого query или data-modification statement текущей transaction
- PostgreSQL 13: Constraints — объясняет, что CHECK не предназначен для постоянного контроля других строк таблицы; cross-row правило требует иной формулировки и защиты
- PostgreSQL 13: pg_locks — описывает системное представление активных lockable objects, requested modes и процессов; его вывод нужно читать вместе с конкретным statement и проверяемым protocol