В предыдущем уроке мы посмотрели восемь уровней блокировок и узнали, какая команда какой уровень берёт. Само по себе это знание ничего не даёт — главный вопрос: какие уровни конфликтуют между собой? Если у меня уже взят ROW EXCLUSIVE на таблице, может ли вторая сессия одновременно взять ROW EXCLUSIVE? SHARE? ACCESS EXCLUSIVE?
Ответ задаётся матрицей 8×8 — её надо запомнить (или хотя бы знать главные клетки наизусть).
Матрица совместимости
Столбцы — что уже взято, строки — что мы пытаемся взять. ACCESS SHARE конфликтует только с ACCESS EXCLUSIVE. ROW EXCLUSIVE совместим сам с собой (две UPDATE-сессии параллельно). ACCESS EXCLUSIVE несовместим вообще ни с чем.
Главные клетки, которые надо знать наизусть:
ACCESS SHARE↔ACCESS EXCLUSIVE— единственный конфликт самого лёгкого уровня. ЛюбойSELECTблокируетDROP TABLEи наоборот. Всё остальное (INSERT, UPDATE, CREATE INDEX, VACUUM) — параллельно с SELECT.ROW EXCLUSIVE↔ROW EXCLUSIVE— не конфликтуют. Две UPDATE-сессии работают параллельно на table-level. Если они трогают одну и ту же строку — конфликт перейдёт на row-level черезxmax.ROW EXCLUSIVE↔SHARE— конфликтуют. ПоэтомуCREATE INDEX(без CONCURRENTLY) встаёт в очередь за всеми пишущими и сам блокирует новых.SHARE UPDATE EXCLUSIVE↔SHARE UPDATE EXCLUSIVE— конфликтуют. Это значит, что нельзя запустить дваVACUUMпараллельно на одну таблицу, нельзя одновременноANALYZEиVACUUM, нельзя дваCREATE INDEX CONCURRENTLY. Постгрес сериализует их.ACCESS EXCLUSIVE— конфликтует со всем, включая ACCESS SHARE. Это полная блокировка таблицы.
Правило большого пальца
В большинстве OLTP-сценариев тебя интересуют только три комбинации:
SELECT не блокирует ничего кроме DDL. INSERT/UPDATE/DELETE параллельны между собой, не блокируют чтение. CREATE INDEX CONCURRENTLY = безопасная альтернатива.
Очередь блокировок и эффект «FIFO загораживается»
Самый коварный эффект, про который не пишут в туториалах — это очередь блокировок. Когда транзакция не может взять lock, она встаёт в очередь. Очередь обслуживается FIFO: новый запрос на lock не может «перепрыгнуть» через уже стоящего в очереди, даже если они совместимы.
Классический сценарий миграции в проде:
t0: SELECT (T1) держит ACCESS SHARE, работает медленно. t1: ALTER TABLE (T2) приходит, хочет ACCESS EXCLUSIVE — встаёт в очередь. t2: новый SELECT (T3) приходит, хочет ACCESS SHARE — он совместим с T1, но НЕ может перепрыгнуть через T2 в очереди. Результат — все новые SELECT встают, прод лежит.
Это главная причина того, что миграция в проде неожиданно валит всю базу. Сама миграция занимает 5 ms, но за время ожидания одной случайной транзакции с долгим SELECT весь OLTP встаёт в очередь за ней.
Защита: lock_timeout + retry
Стандартная практика безопасной миграции в Postgres:
SET lock_timeout = '2s';
SET statement_timeout = '5s';
ALTER TABLE orders ADD COLUMN refunded_at TIMESTAMPTZ;
Если за 2 секунды не удалось получить ACCESS EXCLUSIVE — миграция отвалится с ошибкой canceling statement due to lock timeout, очередь рассосётся, и можно попробовать снова через несколько секунд. Без lock_timeout миграция может стоять часами, держа всю базу.
Дополнительные приёмы:
- В Postgres 11+
ADD COLUMN <type> DEFAULT <const>не переписывает heap — это просто запись вpg_attribute. Только короткийACCESS EXCLUSIVEна момент записи метаданных. ADD COLUMN ... NOT NULL DEFAULT <const>— тоже моментально (Postgres 11+).ALTER TABLE ... DROP CONSTRAINT— не требует переписывания heap.- А вот
ADD CONSTRAINT NOT NULL(без default) илиALTER COLUMN ... TYPE ...требуют полного сканирования таблицы подACCESS EXCLUSIVE— это часами на больших таблицах. Используй вариантADD CONSTRAINT ... NOT VALID; VALIDATE CONSTRAINT;— первая часть моментальна (ACCESS EXCLUSIVEтолько на метаданные), вторая часть берёт более лёгкийSHARE UPDATE EXCLUSIVE.
Смотрим конфликт в pg_locks
Посмотрим, как блокировка выглядит изнутри. Запустим транзакцию, которая держит ROW EXCLUSIVE, и в той же сессии попробуем LOCK TABLE ... IN ACCESS EXCLUSIVE MODE NOWAIT — это покажет, что конфликт есть:
Берём ROW EXCLUSIVE, потом пытаемся взять ACCESS EXCLUSIVE сами на ту же таблицу — это разрешено (внутри одной транзакции lock 'апгрейдится' без конфликта, потому что Postgres различает локи по transaction id, а не по pid).
Видишь — мы держим и RowExclusiveLock, и AccessExclusiveLock одновременно. В рамках одной транзакции LOCK TABLE поверх своего же UPDATE не конфликтует. Конфликт возникает только между разными транзакциями.
LOCK TABLE — отдельная команда, явно берущая указанный уровень. Используется редко — нужно либо для очень специальных миграций, либо для тестирования. В обычной OLTP-нагрузке руками LOCK TABLE не пишут.
Smart trick: SET LOCAL lock_timeout
Хорошая привычка для миграций:
SET LOCAL действует только до конца транзакции. После ROLLBACK/COMMIT параметр вернётся к глобальному значению. Используем для безопасных миграций.
Если бы в этой таблице кто-то держал ACCESS SHARE дольше 500 ms — миграция бы отвалилась с ошибкой canceling statement due to lock timeout, не положив остальную нагрузку.
Чек-лист
- Lock conflict matrix 8×8 — нужно знать главные клетки наизусть.
ACCESS SHAREсовместим со всем, кромеACCESS EXCLUSIVE. Это значит, что любой SELECT блокируется только DDL.ROW EXCLUSIVE(INSERT/UPDATE/DELETE) совместим сам с собой на table-level. Конфликты идут на row-level черезxmax.SHARE UPDATE EXCLUSIVE(VACUUM,ANALYZE,CREATE INDEX CONCURRENTLY) совместим сROW EXCLUSIVE(можно вакуумить под нагрузкой).ACCESS EXCLUSIVEнесовместим вообще ни с чем — это полная блокировка.- Очередь блокировок — FIFO: новый совместимый запрос не может перепрыгнуть через несовместимый.
ACCESS EXCLUSIVEв очереди «загораживает» всё. - Безопасная миграция =
SET lock_timeout+ retry. Без timeout миграция может стоять часами, держа всю базу. LOCK TABLEдля апгрейда lock внутри своей транзакции не конфликтует — Postgres различает блокировки по transaction id.