DarkRiDDeR16 мин

Разбор конфликта транзакций: где заканчивается граница PostgreSQL

PostgreSQLОтладкаДанные

Симптом в поле звучит неприятно именно потому, что оба запроса вернули успех. Один дежурный выключил Анну, другой — Бориса. Каждая команда изменила «свою» строку, ошибок 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.

Evidence для учебного разбора конфликтующей операции
АртефактСодержимое в примереДиагностический вопросНе является доказательством
InvariantactiveCount >= 1какой committed result запрещёнформы UI или имени endpoint
Scope queryдва primary rows для одной сменыкакие rows участвуют в решениитого, что все другие writers используют same query
Lock orderORDER BY doctorв каком порядке операции просят несколько rowsтого, что deadlock невозможен во всём приложении
Retry contractwhole operation after confirmed failureкакие reads и decisions повторяютсябезопасности уже отправленного внешнего эффекта
Fixture scheduleT1 read, T2 read, T1 commit, T2 commit/wake/retryкакое взаимное положение шагов проверяемнастоящего wait, lock graph или latency сервера

В разборе используется PostgreSQL 13 — актуальная ветка января 2021 после 13.1. Версия, driver и exact SQL path всё равно должны быть входами integration test; учебная схема не переносит гарантию на любое окружение.

Диагностика начинается с формы конфликта

Вертикальная схема диагностики с четырьмя карточками: оба запроса успешно нарушили правило, SELECT FOR UPDATE покрывает неполный scope, операции берут объекты в разном порядке и Serializable возвращает SQLSTATE 40001; у каждой карточки указаны причина, проверка и действие.
Схема не пытается назвать виновника по одной ошибке. Она переводит симптом в проверяемый predicate, порядок и retry contract.

Первый 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 falsetwo reads приняли решение по одному старому условиювоспроизвести fixed interleaving и посчитать result after both commitsобъединить check/write в protocol, затем тестировать real SQL
FOR UPDATE есть, ошибка остаётсяlocked set меньше invariant setсравнить predicate checks и predicate locking queryрасширить scope или изменить модель правила
Запрос ждёт/получает deadlockmulti-row paths имеют разный acquisition orderвыписать order для каждого writerединый order, short transaction, retry only aborted work
40001Serializable выявил dangerous dependencyпроверить transaction outcome и отсутствие premature side effectfull retry с новым snapshot либо controlled rejection
Повтор создал duplicate outside effectretry пересёк границу БДнайти момент публикации 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.

Нумерованный маршрут разбора

  1. Сохраните invariant и exact predicate. Начните не с ошибки драйвера, а с запрещённого committed state.
  2. Запишите fixed schedule двух operations: что каждая прочла, когда решила писать и в каком порядке commits или waits произошли.
  3. Проверьте version PostgreSQL, isolation и границу первого statement. Не меняйте уровень после уже выполненного query.
  4. Сравните scope locking query с scope invariant. Для multi-row paths зафиксируйте один acquisition order.
  5. Разделите outcome: normal wait, deadlock abort, serialization failure и business rejection требуют разных действий.
  6. Для confirmed 40001 повторите всю transaction и заново вычислите решение; не повторяйте blind write.
  7. Отдельно проверьте side effects после transaction. Если их нельзя повторить, они не должны жить внутри blind retry.
  8. Покройте выбранный 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