Learning Platform
Урок 11.01 · 22 мин
Продвинутый
LocksACCESS SHAREROW EXCLUSIVEACCESS EXCLUSIVEFOR UPDATEpg_locks

В предыдущих модулях мы изучали, как Postgres хранит данные и как MVCC позволяет читателям и писателям не мешать друг другу на уровне версий. Но MVCC покрывает только видимость кортежей — оно ничего не говорит о том, как защитить структуру таблицы от одновременного DROP COLUMN, или как гарантировать, что две UPDATE-сессии не пишут в одну и ту же строку одновременно.

Эту задачу решают блокировки (locks). В Postgres их иерархия неожиданно сложная: восемь уровней table-level locks плюс отдельные row-level. В этом уроке разбираемся, кто что берёт и почему.

Зачем восемь уровней

В простой системе хватило бы двух блокировок: shared (для чтения) и exclusive (для записи). Но Postgres хочет позволить как можно больше параллелизма: пока один читает таблицу, другой может в неё писать, третий — строить индекс CONCURRENTLY, четвёртый — делать ANALYZE. Для этого нужно различать, что именно делает каждый игрок, чтобы корректно решать, конфликтуют ли они между собой.

Все восемь уровней (в порядке возрастания «эксклюзивности»):

Иерархия table-level locks

Слева — самые лёгкие (читатели), справа — самые тяжёлые (DDL). Чем выше уровень, тем меньше других уровней с ним совместимы. ACCESS EXCLUSIVE блокирует всё, включая ACCESS SHARE.

1. ACCESS SHARESELECT
2. ROW SHARESELECT FOR UPDATE/SHARE
3. ROW EXCLUSIVEINSERT, UPDATE, DELETE
4. SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLY
5. SHARECREATE INDEX (non-concurrent)
6. SHARE ROW EXCLUSIVECREATE TRIGGER, некоторые ALTER
7. EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLY
8. ACCESS EXCLUSIVEDROP TABLE, TRUNCATE, ALTER TABLE, VACUUM FULL, REINDEX

Слово «share» и слово «exclusive»

Названия путают всех. Запомни мнемонику:

  • SHARE в названии = блокировка разрешает другим брать SHARE на той же таблице. То есть несколько сессий могут одновременно держать SHARE lock, не мешая друг другу.
  • EXCLUSIVE в названии = эксклюзив на что-то. Например, ROW EXCLUSIVE — это «эксклюзивный доступ к строкам, но другие тоже могут писать в свои строки»; имеется в виду, что обычный читатель и обычный писатель не мешают друг другу, но ACCESS EXCLUSIVE (например, DROP TABLE) — никому.
  • ACCESS — про сам факт обращения к таблице.
    ACCESS SHARE
    = «я к таблице обращаюсь на чтение». ACCESS EXCLUSIVE = «я к таблице обращаюсь так, что больше никто не может ни читать, ни писать».

Какая команда какой уровень берёт

Главная таблица соответствий:

Команда → lock level

Большинство OLTP-операций (SELECT, INSERT, UPDATE, DELETE) на третьем уровне или ниже и взаимно неблокирующие. DDL прыгает сразу на 8-й уровень.

SELECTACCESS SHARE (1)
SELECT FOR UPDATE / FOR SHAREROW SHARE (2)
INSERT / UPDATE / DELETE / MERGEROW EXCLUSIVE (3)
CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVE (4)
VACUUM (без FULL) / ANALYZESHARE UPDATE EXCLUSIVE (4)
CREATE INDEXSHARE (5)
CREATE TRIGGERSHARE ROW EXCLUSIVE (6)
REFRESH MAT VIEW CONCURRENTLYEXCLUSIVE (7)
DROP / TRUNCATE / ALTER / VACUUM FULL / REINDEX / CLUSTERACCESS EXCLUSIVE (8)

Несколько важных деталей:

  • ALTER TABLE всегда берёт ACCESS EXCLUSIVE, даже если ты просто меняешь default колонки. В Postgres 11+ часть ALTER TABLE (добавление колонки с дефолтом, который constant) перестало переписывать heap — но lock всё равно ACCESS EXCLUSIVE на короткое время.
  • CREATE INDEX без CONCURRENTLY берёт SHARE — это значит, никто не может писать в таблицу, пока строится индекс. На больших таблицах в проде это смерть. Поэтому CREATE INDEX CONCURRENTLY — почти всегда правильный выбор: он берёт SHARE UPDATE EXCLUSIVE и пишущие команды (INSERT/UPDATE/DELETE) работают параллельно.
  • VACUUM FULLVACUUM. Обычный — SHARE UPDATE EXCLUSIVE (4). VACUUM FULLACCESS EXCLUSIVE (8). Это объясняет, почему VACUUM FULL в проде запускать страшно.

Смотрим в pg_locks

Postgres экспонирует все взятые блокировки через системный view pg_locks. Можно посмотреть, что моя сессия сейчас держит:

Что держит моя сессия прямо сейчас. ACCESS SHARE появится на customers/orders при чтении из pg_locks, плюс будут virtual-xid locks — это нормально, каждая активная транзакция держит свой virtual transaction id.

PostgreSQL

relation::regclass приводит OID таблицы к её имени (customers вместо 12345). granted = true — блокировка взята; false — мы стоим в очереди и ждём.

Теперь начнём транзакцию с UPDATE и посмотрим, что добавится:

Внутри транзакции с UPDATE берётся ROW EXCLUSIVE на таблице — это видно в pg_locks. Плюс появится transactionid lock на самой транзакции (нужен для row-level конфликтов).

PostgreSQL

Видишь ROW EXCLUSIVE на customers — это lock на таблице. Сам факт, что строка ещё не пробита другой транзакцией, защищается другим механизмомtransactionid lock на нашу собственную транзакцию. Если другая транзакция тоже хочет обновить ту же строку, она увидит xmax, посмотрит наш transactionid lock и заснёт, ожидая нашего COMMIT/ROLLBACK.

Row-level locks

Когда мы делаем UPDATE row WHERE id = 1, Postgres помечает кортеж как занятый: проставляет xmax = my_txid и взводит флаг в infomask («занят на запись»). Это и есть row-level lock. Он живёт до конца транзакции и автоматически снимается при COMMIT/ROLLBACK.

Явно row-level lock можно взять через SELECT FOR UPDATE:

SELECT FOR UPDATE берёт row-level lock на выбранные строки + table-level ROW SHARE. Покажем оба:

PostgreSQL

Заметь — SELECT FOR UPDATE берёт ROW SHARE на таблице (level 2), не ROW EXCLUSIVE. Это потому что table-level lock защищает только от DDL, а сам факт «я занял строки на запись» отслеживается через xmax в tuple header + tuple-level lock в специальной in-memory структуре. Постгрес не держит N row-level locks в памяти — они хранятся в самих кортежах через xmax. Это экономит память при больших SELECT FOR UPDATE (миллион строк = миллион записей в кортежи, а не миллион записей в lock manager).

Lifecycle: когда блокировки снимаются

Все table-level locks (кроме самых лёгких в некоторых случаях) живут до конца транзакции. Это значит:

  • BEGIN; UPDATE ...; <долгая работа>; COMMIT;ROW EXCLUSIVE держится всё это время. На больших OLAP-инсёртах это критично.
  • BEGIN; SELECT ...; <много времени>; COMMIT;ACCESS SHARE тоже держится. Это значит, что простой SELECT в долгой транзакции блокирует любой ALTER TABLE в эту таблицу.

Это объясняет классический шок прод-DBA: «миграция не проходит, висит на ACCESS EXCLUSIVE». Почти всегда виноват какой-нибудь забытый BEGIN в открытой psql-сессии, держащий ACCESS SHARE на таблице.

Single-statement автокоммит (без явного BEGIN) — это та же транзакция, но в одно statement: lock берётся, statement выполняется, COMMIT, lock снимается. Без BEGIN/COMMIT — окно держания минимально.

Чек-лист

  • В Postgres 8 уровней table-level locks: от ACCESS SHARE (1) до ACCESS EXCLUSIVE (8).
  • SELECT берёт ACCESS SHARE. INSERT/UPDATE/DELETEROW EXCLUSIVE. SELECT FOR UPDATEROW SHARE.
  • DDL (ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL, REINDEX) берёт ACCESS EXCLUSIVE (8). Это всё блокирует.
  • CREATE INDEX без CONCURRENTLYSHARE (5), блокирует писателей. С CONCURRENTLYSHARE UPDATE EXCLUSIVE (4), пишущие не блокируются.
  • Row-level locks не хранятся в lock manager в памяти — они живут в xmax кортежа. Это позволяет дёшево лочить миллионы строк.
  • Все table-level locks снимаются только при COMMIT/ROLLBACK. Долгая транзакция с SELECT блокирует миграции.
  • pg_locks — твой главный диагностический view. Смотри relation, mode, granted, pid.
Блокировки строк: SELECT FOR UPDATE, NOWAIT, SKIP LOCKED Синхронизация — race conditions, mutex, semaphore, atomic, memory ordering
Проверка знанийKnowledge check
Ты делаешь миграцию ALTER TABLE orders ADD COLUMN refunded_at TIMESTAMPTZ в проде. Параллельно крутится OLTP-нагрузка: пачка SELECT * FROM orders WHERE id = ? и пачка INSERT INTO orders. Почему миграция, скорее всего, повиснет, и что с этим делать?
ОтветAnswer
ALTER TABLE берёт ACCESS EXCLUSIVE (8) — несовместим вообще ни с чем, включая ACCESS SHARE. То есть пока в системе есть хотя бы один SELECT, держащий ACCESS SHARE на orders (а это любой активный SELECT внутри открытой транзакции — или вообще любой долго работающий SELECT), ALTER TABLE будет стоять в очереди. И что хуже — пока он стоит в очереди, все новые SELECT тоже встанут за ним, потому что pg_locks обслуживается FIFO: ACCESS EXCLUSIVE «загораживает» очередь, не пуская никого нового мимо. Поэтому в проде ALTER TABLE надо: (1) выставлять lock_timeout = 1s, чтобы он сам отвалился, если не получил блокировку быстро; (2) делать миграцию в маленькие шаги — ADD COLUMN с default = NULL в Postgres 11+ моментален (метаданные), а заполнение default'ов и NOT NULL делается отдельно через UPDATE батчами + CHECK + потом SET NOT NULL.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какой уровень table-level lock берёт обычный INSERT/UPDATE/DELETE на таблицу?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5