Миграция ещё не выполнилась, а SELECT уже ждёт: очередь блокировок PostgreSQL

При создании кейса на в моем тренажере, который я рассказывал ранее, возникло следующее: добавление пустой колонки выглядит безобидной миграцией: переписывать данные не нужно. Но такой ALTER TABLE может остановить новые чтения таблицы ещё до изменения её структуры. Достаточно старой транзакции, которую забыли завершить. Поясню эту цепочку на отдельном PostgreSQL 17.11 с таблицей orders из одной строки.
Сначала первая сессия выполняет BEGIN и SELECT id FROM orders, после чего остаётся открытой. Сам запрос уже завершён, но транзакция продолжает держать AccessShareLock. Это не мешает другому обычному SELECT: такие блокировки совместимы. Проверка до запуска миграции возвращает строку без ожидания старой транзакции.
Затем вторая сессия запускает ALTER TABLE orders ADD COLUMN note text. Для этого изменения нужна AccessExclusiveLock, несовместимая с блокировкой первой сессии. Миграция встаёт в очередь. Если теперь третья сессия выполнит SELECT id FROM orders, она тоже будет ждать. Её AccessShareLock совместима с уже выданной блокировкой первой сессии, однако впереди стоит запрос миграции на исключительный доступ.
На снимке консоли видна вся цепочка. Сессия old_tx находится в состоянии idle in transaction и держит выданную блокировку: granted = t. У migration блокировка ещё не выдана, а blocked_by указывает на old_tx. Новый запрос api также имеет granted = f, но его blocked_by указывает уже на migration. Таким образом, источник ожидания и непосредственный блокировщик запроса — разные сессии. После ROLLBACK в old_tx миграция завершается, а новый SELECT получает результат.
Во втором прогоне перед ALTER TABLE внутри транзакции установлен SET LOCAL lock_timeout = ‘700ms’. Миграция снова ждёт, затем завершается ошибкой canceling statement due to lock timeout. После завершения её ошибочной транзакции новый SELECT проходит, хотя old_tx всё ещё остаётся открытой. Колонка note при этом не добавлена: ограничение ожидания помогло чтению продолжиться, но миграцию потребуется выполнить отдельно.
Для диагностики полезно смотреть вместе pg_stat_activity, pg_locks и pg_blocking_pids(pid). Одного состояния active недостаточно: запрос может быть активным, но ждать блокировку. Важно найти начало цепочки, возраст транзакции и таблицу, из-за которой растёт очередь. Завершать чужую сессию наугад только по её возрасту не стоит: сначала нужно понять, кому она принадлежит и какую работу выполняет.
Время ожидания блокировки стоит задавать для конкретной миграции с учётом требований приложения. В этом опыте 700 мс — выбранное значение стенда. lock_timeout ограничивает ожидание каждой блокировки, а statement_timeout — длительность SQL-команды в целом. Если миграция выполняется в явной транзакции, после ошибки нужен ROLLBACK. Следующая попытка должна быть запланированной, с паузой и проверкой причины ожидания.
Для воспроизведения очереди не понадобилась большая таблица или высокая нагрузка. В этом примере проблему создаёт порядок получения блокировок: старая транзакция держит чтение, миграция ждёт исключительный доступ, новые чтения ждут за ней. Поэтому даже быструю миграцию полезно проверять при наличии незавершённых транзакций.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.