linuxlab.io
Учебники▾
  • Линукс и сети
    Файловая система, процессы, TCP/IP, BGP и OSPF
    →
  • Terraform и IaC
    HCL, state, plan/apply на sandbox LocalStack
    →
  • Git и GitHub
    Объектная модель, plumbing, ветвление, GitHub Actions
    →
Все учебники →
ЦеныО платформеВойтиСоздать аккаунт
/
Intro
Lessons
Footer
linuxlab-УчебникиЦеныО платформеКонфиденциальность и куки
Copyright © 2026 LinuxLab. Все права защищены.
linuxlab.io
Учебники▾
  • Линукс и сети
    Файловая система, процессы, TCP/IP, BGP и OSPF
    →
  • Terraform и IaC
    HCL, state, plan/apply на sandbox LocalStack
    →
  • Git и GitHub
    Объектная модель, plumbing, ветвление, GitHub Actions
    →
Все учебники →
ЦеныО платформеВойтиСоздать аккаунт
/
  • Введение
  • Главы
  • How it worksскоро
  • Уроки
  • База знаний
  • Собеседование
Часть V — Блокировки

$ глава 22 · 45 минут

Взаимоблокировки

Очередь блокировок честная: ждущий рано или поздно дождётся. Кроме одного случая - когда дождаться невозможно в принципе. A держит строку 1 и хочет строку 2; B держит строку 2 и хочет строку 1. Оба ждут друг друга вечно. Это взаимоблокировка (deadlock), и сама по себе она не рассосётся.

PostgreSQL умеет её распознавать и разрывать: одну из транзакций он принудительно откатывает с ошибкой deadlock detected. В этой главе мы воспроизведём deadlock из двух UPDATE в обратном порядке, пройдём по графу ожидания, прочитаем сообщение в логе и разберём, как писать код, в котором deadlock не возникает.

22.1 Тупик из двух транзакций

Возьмём перевод денег между двумя бронированиями. T1 списывает с первого и зачисляет на второе; T2 делает то же, но в обратном порядке.

sql
-- T1
BEGIN;
UPDATE bookings SET total = total - 10 WHERE book_ref = '000001';  -- захватил строку 1
-- ... пауза ...
UPDATE bookings SET total = total + 10 WHERE book_ref = '000002';  -- хочет строку 2
-- T2 (в это же время)
BEGIN;
UPDATE bookings SET total = total - 10 WHERE book_ref = '000002';  -- захватил строку 2
-- ... пауза ...
UPDATE bookings SET total = total + 10 WHERE book_ref = '000001';  -- хочет строку 1

После первых двух UPDATE каждая транзакция держит «свою» строку. Дальше T1 ждёт строку 2 (её держит T2), а T2 ждёт строку 1 (её держит T1). Ни один UPDATE не пройдёт: каждый ждёт того, кто ждёт его. Это и есть тупик.

22.2 Граф ожидания и цикл в нём

Формально блокировки описываются графом ожидания (wait-for graph): вершины - транзакции, ребро A → B значит «A ждёт блокировку, которую держит B».

нормальная очередь:   C → B → A        (A держит, ждут B и C)
взаимоблокировка:     T1 → T2 → T1      (цикл!)

Пока граф - это цепочки и деревья без циклов, всё в порядке: корень рано или поздно завершится, и ожидание поедет дальше. Цикл в графе означает, что ждать бесконечно. Обнаружение deadlock - это ровно поиск цикла в графе ожидания.

Цикл может быть длиннее двух: T1 → T2 → T3 → T1. Механизм тот же - замкнутая цепочка «каждый ждёт следующего».

22.3 Как PostgreSQL находит цикл

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

Когда транзакция встаёт в очередь за блокировкой, взводится таймер deadlock_timeout (по умолчанию 1 секунда). Если за это время блокировку выдали - отлично, никто не тратил силы на анализ. Если таймер сработал, а блокировки всё нет - запускается детектор: он строит граф ожидания и ищет цикл.

Нашёл цикл - выбирает жертву и откатывает её. Не нашёл (просто долгая очередь) - продолжает ждать. Отсюда практическое следствие: между возникновением deadlock и его разрывом проходит примерно deadlock_timeout. Уменьшать его ниже секунды обычно вредно - детектор начнёт дёргаться на каждой чуть подзадержавшейся очереди.

22.4 Сообщение в логе

Когда детектор разрывает цикл, проигравшая транзакция получает ошибку, а сервер пишет в лог подробности - это главный источник для расследования.

ERROR:  deadlock detected
DETAIL:  Process 412 waits for ShareLock on transaction 1033;
         blocked by process 410.
         Process 410 waits for ShareLock on transaction 1034;
         blocked by process 412.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,5) in relation "bookings"

Читается как описание цикла: процесс 412 ждёт транзакцию, которую держит 410, а 410 ждёт ту, что держит 412. CONTEXT называет конкретный кортеж и таблицу, на которых всё сошлось. По этим двум запросам почти всегда видно, что они брали одни и те же строки в разном порядке.

Код ошибки - SQLSTATE 40P01. Приложение должно его ловить: deadlock не баг данных, а штатная ситуация при конкуренции, и правильная реакция - повторить транзакцию.

22.4.1 Подводный камень: deadlock без второго UPDATE

Не нужно двух явных UPDATE, чтобы получить цикл. Одного UPDATE, задевающего несколько строк, достаточно, если две транзакции обходят строки в разном физическом порядке:

sql
-- T1
UPDATE bookings SET total = total + 1 WHERE book_ref IN ('000001','000002');
-- T2
UPDATE bookings SET total = total + 1 WHERE book_ref IN ('000002','000001');

Внутри одного UPDATE строки блокируются по мере обхода. Если T1 пошёл по плану от строки 1 к 2, а T2 - от 2 к 1, получается тот же цикл. Порядок обхода задаёт план запроса, а не порядок в IN, поэтому управлять им вручную трудно.

Ещё один источник - внешние ключи: вставка в дочернюю таблицу берёт FOR KEY SHARE на родителя, и две вставки, ссылающиеся на разных родителей крест-накрест, способны сойтись в цикл. Deadlock редко выглядит как очевидный «два UPDATE наоборот».

22.5 Кто становится жертвой

Детектор разрывает цикл, откатывая одну транзакцию. Обычно это та, чьё ожидание замкнуло цикл - то есть процесс, на чьём таймере deadlock_timeout сработал детектор. Гарантировать конкретную жертву нельзя: это деталь реализации, а не контракт.

Жертва получает полный ROLLBACK - откатываются все её изменения в этой транзакции, не только последний запрос. Вторая транзакция продолжает как ни в чём не бывало: блокировки жертвы спали, её UPDATE проходит.

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

22.6 Как писать код без взаимоблокировок

Deadlock нельзя «выключить», но его почти всегда можно спроектировать прочь. Три приёма, по убыванию важности.

Первое - единый порядок блокировок. Если все транзакции, трогающие набор строк, берут их в одном и том же порядке (например, по возрастанию первичного ключа), цикл невозможен в принципе: граф ожидания становится ациклическим. Для UPDATE нескольких строк это ORDER BY в подзапросе или явная блокировка через SELECT ... FOR UPDATE ... ORDER BY id.

Второе - короткие транзакции. Чем меньше времени строка под замком, тем уже окно для пересечения. «Подержать» строку заблокированной, пока приложение ходит во внешний сервис, - верный способ собрать и deadlock, и каскад.

Третье - ретрай. Поскольку deadlock штатен, оберни транзакцию в повтор: поймал 40P01 - подожди немного и выполни заново. Без ретраев даже редкий deadlock превращается в видимую пользователю ошибку.

22.6.1 Копнуть глубже: deadlock_timeout против log_lock_waits

Тот же таймер deadlock_timeout управляет и логированием долгих ожиданий. Если включён log_lock_waits = on, то по истечении deadlock_timeout сервер пишет в лог не только найденные циклы, но и факт «процесс N ждёт блокировку дольше секунды» - даже когда это обычная очередь, а не deadlock.

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

Уроки в sandbox

lab-22.1. Воспроизвести deadlock и прочитать его в логе

Соберём классический цикл из двух транзакций. Перед последним шагом предскажи, какая из них станет жертвой и что покажет счётчик pg_stat_database.deadlocks.

  1. Сессия A: BEGIN; UPDATE bookings SET total = total - 10 WHERE book_ref = '000001';. Строка 1 теперь за A.

  2. Сессия B: BEGIN; UPDATE bookings SET total = total - 10 WHERE book_ref = '000002';. Строка 2 теперь за B.

  3. Сессия A: UPDATE bookings SET total = total + 10 WHERE book_ref = '000002';. Команда повиснет - A ждёт строку 2.

  4. Сессия B: UPDATE bookings SET total = total + 10 WHERE book_ref = '000001';. Через ~1 секунду (deadlock_timeout) одна из транзакций получит ERROR: deadlock detected.

  5. Прочитай лог сервера и найди блок DETAIL с описанием цикла и CONTEXT с номером кортежа. Сопоставь PID в сообщении с сессиями A и B.

  6. Проверь счётчик: SELECT deadlocks FROM pg_stat_database WHERE datname = current_database(); - он должен быть больше нуля. Закоммить выжившую транзакцию.

sandbox с автопроверкой - открыть в песочнице

Резюме

  • Взаимоблокировка - цикл в графе ожидания: каждая транзакция ждёт ту, что ждёт её. Сама по себе она не разрешается.
  • PostgreSQL не ищет цикл сразу: он ждёт deadlock_timeout (по умолчанию 1 с), и только потом запускает детектор. Так нормальные очереди не тратят анализ.
  • Найдя цикл, детектор откатывает одну транзакцию с ошибкой 40P01 `deadlock detected`; вторая продолжает. Откатывается вся транзакция-жертва, а не один запрос.
  • Лог сервера описывает цикл в DETAIL (кто кого ждёт по PID) и называет конкретный кортеж в CONTEXT - по этому почти всегда видно несогласованный порядок блокировок.
  • Цикл не требует двух явных UPDATE: его дают один UPDATE на несколько строк в разном порядке обхода и перекрёстные вставки по внешнему ключу.
  • Главная профилактика - единый порядок блокировки строк (например, по возрастанию ключа). Тогда граф ожидания ацикличен и deadlock невозможен.
  • Deadlock штатен при конкуренции: приложение обязано ловить 40P01 и повторять транзакцию. Рост pg_stat_database.deadlocks - сигнал о несогласованном порядке.

Контрольные вопросы

  1. Почему между возникновением deadlock и его разрывом проходит около секунды?

    Показать ответ

    Потому что PostgreSQL не строит граф ожидания на каждую блокировку - это дорого, а почти все ожидания разрешаются сами. Когда транзакция встаёт в очередь, взводится таймер deadlock_timeout (по умолчанию 1 с). Только если за это время блокировку так и не выдали, запускается детектор циклов. Поэтому реальный тупик живёт примерно deadlock_timeout, прежде чем сервер его заметит и разорвёт.

  2. Можно ли поставить `deadlock_timeout` в 10 мс, чтобы тупики ловились мгновенно?

    Показать ответ

    Технически да, но это вредно. Детектор будет запускаться на каждой очереди, которая задержалась дольше 10 мс, хотя в подавляющем большинстве случаев это не deadlock, а нормальное ожидание, которое вот-вот разрешится. Анализ графа на каждый чих съест процессор. Цель deadlock_timeout - отсечь короткие штатные ожидания от настоящих тупиков, и секунда для этого обычно хороша.

  3. Две транзакции делают `UPDATE` без всякого FOR UPDATE и без второго запроса. Откуда deadlock?

    Показать ответ

    UPDATE блокирует каждую затронутую строку по мере обхода. Если запрос задевает несколько строк, а две транзакции обходят их в разном физическом порядке (порядок задаёт план, не порядок в IN), одна успеет взять строку X и пойдёт за Y, а другая - наоборот. Получается тот же цикл, что и при двух явных UPDATE наоборот. Поэтому массовые UPDATE стоит упорядочивать по ключу.

  4. Транзакция-жертва откатила только последний `UPDATE` или всё?

    Показать ответ

    Всё. Разрыв deadlock - это полный ROLLBACK транзакции-жертвы: откатываются все её изменения с начала транзакции, а соединение получает ошибку 40P01. Поэтому нельзя рассчитывать, что «не прошёл только последний шаг»: после deadlock транзакцию нужно выполнять заново целиком, что и делает правильно написанный ретрай.

  5. Какой самый надёжный способ вообще исключить взаимоблокировки на наборе строк?

    Показать ответ

    Брать строки во всех транзакциях в одном и том же порядке - например, всегда по возрастанию первичного ключа. Тогда граф ожидания не может содержать цикл: если все идут в одном направлении, замкнуться не на чем. На практике это ORDER BY id в SELECT ... FOR UPDATE или в подзапросе перед массовым UPDATE. Дополняют это короткие транзакции и ретрай на 40P01.

← Предыдущая21-relation-row-locksСледующая →23-lightweight-predicate-locks
Footer
linuxlab-
Copyright © 2026 LinuxLab. Все права защищены.
Учебники
Цены
О платформе
Конфиденциальность и куки