Перейти к содержанию

Взаимные блокировки

В уроке о строковых блокировках второй клиент дожидался первого. Но иногда ожидания образуют цикл: каждый держит один ресурс и ждёт другой, занятый соседом. Ни один участник не может закончить обычным продолжением. Это взаимная блокировка, или deadlock. Приложение должно сокращать вероятность такого порядка и понимать отказ одной из операций.

Используем PostgreSQL 17.11 и два существующих курса с ключами один и два. Снимок catalog-db-advanced/lesson-21 находится в архиве продолжения. SQL и Python не запускались. Конкурентный опыт подготовлен как ручные шаги двух живых сессий, а не как автоматический тест времени или выбор заранее известной жертвы.

Цикл из двух строк

После отдельного reset сессия A выполняет A1, меняя первую строку и удерживая блокировку:

BEGIN;
UPDATE catalog.courses SET version = version + 1 WHERE id = 1;

Сессия B выполняет B1 над второй строкой:

BEGIN;
UPDATE catalog.courses SET version = version + 1 WHERE id = 2;

Затем A отправляет A2 и ожидает строку B:

UPDATE catalog.courses SET version = version + 1 WHERE id = 2;

Пока A ждёт, B отправляет B2 над первой строкой. Теперь B ждёт ресурс A, а A ждёт ресурс B. Простого освобождения после завершения команды не будет: ни один блок не может дойти до выбранного COMMIT по своей нормальной цепочке.

PostgreSQL обнаруживает взаимную блокировку и прекращает одну из транзакций. Её ошибка имеет SQLSTATE 40P01. Правила и пример цикла описаны в руководстве блокировок PostgreSQL. Не закладывайте в программу предположение, что всегда отменится именно B, потому что она отправила последнюю команду.

После отказа одна сторона получает ошибку, другая может продолжить. Нужно прочитать состояние обоих клиентов: неуспешная сторона выполняет ROLLBACK, успешная отдельно завершает COMMIT, если предметная операция всё ещё допустима. В снимке для этого существуют отдельные cleanup.sql и commit-survivor.sql; они не запускаются без понимания выбранной стороны.

Предсказуемый порядок ресурсов

Причина простого цикла — разный порядок: A выбирает один, затем два; B — два, затем один. Если обе операции получают этот набор в одном договорном порядке, такой конкретный цикл исчезает. Для неизменяемых числовых ключей можно сначала получить обе строки упорядоченно:

BEGIN;
SELECT id
FROM catalog.courses
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
UPDATE catalog.courses SET version = version + 1 WHERE id IN (1, 2);
COMMIT;

Этот отдельный вариант находится в ordered-operation.sql. Он не продолжается посреди прежнего ошибочного блока: сначала завершите обе сессии и отдельно восстановите исходное состояние, если хотите повторить ожидаемые версии. Сортировка выбрана по стабильному ключу, который операция не изменяет, поэтому порядок ресурса не зависит от новых названий или цены.

Согласованный порядок двух строк не доказывает отсутствие всех взаимных блокировок приложения. Другая часть операции может затрагивать уроки, темы или дополнительные ограничения. Нужно рассматривать весь набор ресурсов и протокол всех писателей. Чем короче и понятнее граница транзакции, тем легче увидеть возможный цикл.

Повторяется вся операция

Если транзакция завершилась ошибкой, повтор одной последней команды не восстанавливает прежнее согласованное действие. Необходимо заново начать блок, прочитать требуемое состояние и выполнить все шаги. Это относится и к ошибкам сериализации 40001, обсуждавшимся в уроке изоляции. Политику повторов объясняет документация обработки serialization failures.

В архиве есть подготовленный клиент Psycopg 3.3.6, который ограничивает число попыток тремя. Он открывает отдельное соединение для попытки, проверяет учебную базу, получает строки в установленном порядке и увеличивает их версии внутри явной транзакции. При двух выбранных типах отказа повтор начинается с целой операции, а не с частично продолженного блока.

for attempt in range(attempts):
    try:
        with psycopg.connect(dsn, autocommit=True) as conn:
            # Здесь проверяется professorweb_lab.
            with conn.transaction():
                # Здесь выполняется вся ordered operation из файла.
                perform_operation(conn)
        return
    except (DeadlockDetected, SerializationFailure):
        if attempt + 1 == attempts:
            raise
        time.sleep(0.1 * (2 ** attempt) + random.uniform(0, 0.1))

Это выдержка структуры; полный самостоятельный retry_client.py содержит проверку подключения и SQL, без несуществующей функции perform_operation. Числа задержки являются выбранной политикой примера, а не измеренной длительностью обнаружения конфликта PostgreSQL. Код не запускался и не создавал фактическую нагрузку.

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

Не каждый отказ временный

Уникальный конфликт, отсутствие прав и нарушение CHECK требуют другого разбора. Безусловно повторять их той же записью бессмысленно. Повтор выбирается по известной категории и предметному договору. Даже уникальная ошибка в некоторых конкурентных сценариях может нуждаться в повторном чтении, но это не даёт право считать любую 23505 временным сбоем.

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

Повторяемая попытка и внешнее действие

Чтобы целую операцию можно было повторить, все решения внутри попытки должны опираться на новое чтение текущей базы. Недостаточно открыть новую транзакцию и снова использовать вычисленный ранее остаток или порядок объектов из незавершённой попытки. Если первое чтение определяло, какие курсы менять, после отката это решение также нуждается в пересмотре.

Наш клиент каждый раз создаёт транзакционную границу заново и получает выбранные строки в согласованном порядке. Случайная короткая задержка между ограниченными попытками уменьшает вероятность немедленного повторения одинаковой конкуренции, но не доказывает исчезновение причины. Если после последней попытки конфликт сохраняется, ошибка остаётся видимым исходом, который приложение должно обработать понятно.

Особенно важно отделить SQL-работу от внешнего эффекта. Внутри первой попытки можно записать будущую публикацию в базу, но нельзя без дополнительного договора отправить подписчику письмо, затем после deadlock отправить его ещё раз. ROLLBACK отменяет записи PostgreSQL и не отменяет сетевую отправку. Поэтому сервису потребуется отдельная согласованная модель уведомлений, когда они действительно появятся.

Также не следует превращать любой SQLSTATE в повод для повторения. Ошибка недопустимой цены или отсутствующего разрешения не исчезает из-за нового соединения. Бизнес-конфликт версии формы выражает другое пользовательское состояние. Перечень повторяемых исходов выбирается узко, а диагностическая запись сохраняет причину завершения. Тогда retry остаётся управлением конкуренцией, а не способом скрыть любую неисправность каталога.

Наблюдение вместо угаданной скорости

В ручном опыте отправьте A2, дождитесь состояния ожидания клиента и только затем B2. Если выполнить сначала COMMIT одного участника, цикл не возникнет. Если закрыть A после A1, её ресурс освободится и получится другой сценарий. Поэтому файлы загружают через \i в тех же двух открытых psql-сессиях.

Настроенные таймеры могут прекратить ожидание раньше обнаружения подготовленного цикла. Тогда наблюдаемая ошибка будет другой и опыт следует объяснить с учётом среды. В статье не указано, через сколько миллисекунд сервер обязан выбрать жертву; такие числа нельзя придумать из структуры примера.

Для повторения завершите оба блока, затем отдельно восстановите reset. Ожидаемый результат этой главы — обнаруженный цикл с одной отменённой операцией, согласованный альтернативный порядок и ограниченный повтор всей транзакции. Теперь ошибка конкуренции является частью явного клиентского договора, а не неожиданным поводом продолжить прерванный SQL с середины.

Оглавление курса.