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 — Блокировки

$ глава 21 · 55 минут

Блокировки отношений и строк

MVCC сделал так, что читатели не мешают писателям, а писатели - читателям. Это снимает большую часть конфликтов, но не все. Как только две транзакции хотят изменить одну строку, или одна меняет данные, а другая - структуру таблицы, кто-то должен подождать. За это «подождать» отвечают блокировки.

Блокировки в PostgreSQL пугают, пока их не увидишь. На самом деле это обычная структура: список «кто что держит и кто чего ждёт» в разделяемой памяти, и его целиком видно через pg_locks. В этой главе мы возьмём две сессии, заставим одну ждать другую, найдём блокировщика по PID и снимем его. После этого «висит на деплое» перестанет быть загадкой.

21.1 Зачем нужны блокировки, если есть MVCC

MVCC решает конфликт «читатель против писателя»: читателю достаётся старая версия строки, писатель делает новую, никто не ждёт. Но есть конфликты, которые версионностью не закрыть.

Первый - два писателя на одну строку. Если T1 и T2 одновременно делают UPDATE accounts SET balance = balance - 100 WHERE id = 5, то «последний выиграл» дал бы потерянное обновление. PostgreSQL этого не допускает: вторая транзакция ждёт, пока первая зафиксируется, и только потом перечитывает строку. Ожидание - это блокировка строки.

Второй - изменение самой таблицы. Пока кто-то читает flights, нельзя посреди чтения выполнить DROP TABLE flights или переписать её формат через ALTER TABLE. Чтение и структурное изменение должны выстроиться в очередь. Это блокировка отношения.

Так появляются два мира блокировок: тяжёлые (heavyweight) на объекты и блокировки строк. Они живут по разным правилам и хранятся в разных местах. Разберём оба.

21.2 Восемь режимов блокировки отношения

Когда транзакция трогает таблицу, она берёт на неё блокировку одного из восьми режимов. Режим зависит от команды, а не от твоего желания: PostgreSQL выбирает минимально достаточный уровень сам.

РежимКто берётСмысл
ACCESS SHARESELECT«я читаю, не мешайте удалять таблицу»
ROW SHARESELECT ... FOR UPDATE/SHARE«я читаю с прицелом изменить строки»
ROW EXCLUSIVEINSERT, UPDATE, DELETE«я пишу данные»
SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLY«обслуживание без блокировки записи»
SHARECREATE INDEX (обычный)«строю индекс, запись подождёт»
SHARE ROW EXCLUSIVECREATE TRIGGER, часть ALTER TABLEредкий промежуточный
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYблокирует всё, кроме SELECT
ACCESS EXCLUSIVEDROP, TRUNCATE, VACUUM FULL, большинство ALTER TABLE«никого, даже читателей»

Сверху вниз режимы «тяжелеют». ACCESS SHARE конфликтует только с ACCESS EXCLUSIVE. ACCESS EXCLUSIVE конфликтует со всеми, включая сам себя и обычный SELECT. Поэтому ALTER TABLE на горячей таблице - классический способ остановить базу: он ждёт, пока уйдут все читатели, и держит за собой всю очередь.

Подробный разбор каждого режима - в relation-locks.

21.3 Таблица конфликтов: кто кого ждёт

Два запроса могут держать блокировки на одну таблицу одновременно, если их режимы не конфликтуют. SELECT и UPDATE уживаются: ACCESS SHARE и ROW EXCLUSIVE совместимы. Два UPDATE на разные строки тоже уживаются - конфликт будет уже на уровне строк, не таблицы.

Простое правило вместо заучивания матрицы 8×8: режимы со словом EXCLUSIVE конфликтуют с записью и друг с другом; ACCESS EXCLUSIVE конфликтует вообще со всеми. Всё, что не EXCLUSIVE, между собой совместимо.

sql
-- Сессия A
BEGIN;
SELECT * FROM flights LIMIT 1;   -- ACCESS SHARE, держится до COMMIT
-- Сессия B (пока A не закоммитила)
ALTER TABLE flights ADD COLUMN note text;  -- хочет ACCESS EXCLUSIVE
-- → ВИСНЕТ: ждёт, пока A отпустит ACCESS SHARE

Команда B не выполнится, пока A не сделает COMMIT или ROLLBACK. Блокировка отношения держится до конца транзакции, а не до конца запроса - это ключ к понимаю каскадов.

21.4 Как увидеть блокировки: pg_locks

pg_locks - это сама таблица блокировок из разделяемой памяти, показанная как view. Одна строка - одна запрошенная блокировка. Колонка granted говорит, выдана она (t) или транзакция ждёт в очереди (f).

sql
SELECT locktype, relation::regclass, mode, granted, pid
FROM pg_locks
WHERE relation = 'flights'::regclass
ORDER BY granted DESC;

Если в выводе есть строка с granted = f, кто-то стоит в очереди. Чтобы понять, кого он ждёт, не нужно глазами сводить таблицу - есть функция:

sql
SELECT pid, pg_blocking_pids(pid) AS blocked_by, query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

pg_blocking_pids(pid) возвращает массив PID, которые держат блокировки, мешающие этому процессу. Это самый короткий путь от «всё висит» к «вот этот бэкенд виноват».

21.4.1 Подводный камень: блокировку держит транзакция, а не запрос

Частая ошибка - думать, что блокировка снимается, когда запрос отработал. Нет: тяжёлая блокировка живёт до конца транзакции.

Сессия выполнила SELECT, получила результат, и человек ушёл пить кофе, не закрыв транзакцию (BEGIN без COMMIT). Запрос давно закончился, но ACCESS SHARE на таблице всё ещё держится. Для psql признак такого состояния - приглашение меняется с lab=# на lab=*#: звёздочка значит «открыта транзакция».

Отсюда правило: смотри не на последний запрос блокировщика, а на его state в pg_stat_activity. Если там idle in transaction - блокировку держит «забытая» открытая транзакция, и сам по себе он её не отпустит.

21.5 Блокировки строк: четыре режима

Когда UPDATE или DELETE трогает строку, он блокирует именно её, а не всю таблицу (на таблице при этом висит лёгкий ROW EXCLUSIVE). Явно строки блокируют через SELECT ... FOR:

РежимКомандаКогда нужен
FOR KEY SHAREслабейшийудержать строку от удаления/смены ключа
FOR SHAREразделяемый«никто не изменит, пока я считаю»
FOR NO KEY UPDATEUPDATE неключевых колонокобычное обновление
FOR UPDATEсильнейшийSELECT ... FOR UPDATE, DELETE

Зачем четыре, а не один? Из-за внешних ключей. Когда ты вставляешь строку в tickets, PostgreSQL должен убедиться, что родительская bookings никуда не денется - но менять её данные ты не собираешься. Поэтому он берёт слабый FOR KEY SHARE: он пропускает параллельный UPDATE неключевых колонок родителя и блокирует только удаление или смену ключа. До PostgreSQL 9.3 тут был полноценный конфликт, и вставки в дочернюю таблицу сериализовались на пустом месте.

Подробности по режимам строк - в row-locks.

21.6 Где хранится блокировка строки

Блокировок строк нет в pg_locks как отдельных записей - иначе на миллионе обновлённых строк таблица блокировок взорвалась бы. PostgreSQL хранит факт блокировки прямо в самой строке: в поле xmax заголовка кортежа и в битах t_infomask. Заблокированная строка - это строка, чей xmax равен номеру держащей транзакции, но при этом строка не удалена (об этом говорят hint-биты).

Поэтому блокировка строки не стоит ничего, пока строк мало под замком: она часть данных, а не отдельный объект в памяти. В pg_locks ты увидишь только косвенный след: ждущая транзакция висит на locktype = 'transactionid', ожидая завершения той, чей xmax стоит на строке.

sql
-- T1: блокирует строку, не отпускает
BEGIN;
SELECT * FROM bookings WHERE book_ref = '0000a1' FOR UPDATE;
-- T2: хочет ту же строку → встаёт в очередь
BEGIN;
SELECT * FROM bookings WHERE book_ref = '0000a1' FOR UPDATE;  -- виснет
-- Из третьей сессии видно, на что ждёт T2:
SELECT pid, wait_event_type, wait_event, state
FROM pg_stat_activity WHERE wait_event_type = 'Lock';

21.6.1 Копнуть глубже: tuple lock и multixact

Если одну строку хотят заблокировать сразу несколько транзакций в разделяемом режиме (FOR SHARE), одного xmax не хватит - в нём помещается только один номер. Тогда PostgreSQL заводит multixact: xmax указывает не на транзакцию, а на запись в pg_multixact, где перечислены все участники и их режимы.

Это та самая структура, чей возраст отслеживает relminmxid и которую тоже нужно замораживать, иначе - переполнение по той же логике, что и обычный wraparound. Multixact не виден в обычной работе, но всплывает в логах vacuum и в редких ошибках «multixact members limit exceeded» под тяжёлой нагрузкой на FOR SHARE.

На время самого захвата строки в pg_locks мелькает запись locktype = 'tuple' - короткоживущая блокировка на конкретный кортеж. Она нужна, чтобы упорядочить претендентов, и снимается сразу после того, как право на строку определено.

21.7 Каскад от idle-in-transaction

Теперь соберём механизм, который кладёт прод. Возьмём три сессии.

sql
-- A: открыл транзакцию, что-то прочитал и «забыл» закрыть
BEGIN;
SELECT count(*) FROM flights;   -- держит ACCESS SHARE, state = idle in transaction
-- B: миграция выкатывается ровно сейчас
ALTER TABLE flights ADD COLUMN gate text;  -- хочет ACCESS EXCLUSIVE → встаёт за A
-- C: обычный пользовательский запрос
SELECT * FROM flights WHERE flight_no = 'PG1';  -- хочет ACCESS SHARE → встаёт за B

Казалось бы, C совместим с A - два читателя. Но между ними в очереди стоит B с ACCESS EXCLUSIVE, а очередь блокировок честная (FIFO): новые запросы не могут «перепрыгнуть» ждущего. Поэтому безобидный SELECT из C тоже виснет. Один забытый BEGIN в A плюс одна миграция в B - и встала вся таблица.

Вывод неочевидный, но важный: опасен не ALTER TABLE сам по себе, а его сочетание с долгой открытой транзакцией впереди. Разбор сценария целиком - в idle-in-transaction-cascade.

21.8 Найти и снять блокировщика

Диагностика всегда идёт от ждущего к держащему. Находим, кто стоит в очереди, спрашиваем pg_blocking_pids, смотрим, что за процесс виноват.

sql
SELECT
  w.pid          AS waiting_pid,
  w.query        AS waiting_query,
  b.pid          AS blocking_pid,
  b.state        AS blocking_state,
  b.query        AS blocking_query
FROM pg_stat_activity w
JOIN LATERAL unnest(pg_blocking_pids(w.pid)) AS bp(pid) ON true
JOIN pg_stat_activity b ON b.pid = bp.pid
WHERE cardinality(pg_blocking_pids(w.pid)) > 0;

Если виновник - idle in transaction, у тебя два инструмента:

sql
SELECT pg_cancel_backend(<pid>);     -- отменить текущий запрос (мягко)
SELECT pg_terminate_backend(<pid>);  -- закрыть сессию целиком (жёстко)

pg_cancel_backend шлёт сигнал отмены запроса; для idle in transaction он часто бесполезен - там нет активного запроса, есть открытая транзакция. Тогда нужен pg_terminate_backend: он обрывает соединение, транзакция откатывается, блокировки спадают, очередь рассасывается. На проде от такого спасает настройка idle_in_transaction_session_timeout, которая прибивает забытые транзакции автоматически.

Уроки в sandbox

lab-21.1. Поймать и снять каскад от idle-in-transaction

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

  1. В сессии A открой транзакцию и прочитай таблицу, не коммить: BEGIN; SELECT count(*) FROM flights;. Приглашение станет lab=*# - транзакция открыта.

  2. В сессии B запусти ALTER TABLE flights ADD COLUMN gate text;. Команда повиснет. Предскажи: сколько строк с granted = f появится в pg_locks по flights?

  3. В сессии C запусти обычный SELECT * FROM flights LIMIT 1;. Он тоже повиснет, хотя совместим с A. Объясни почему (подсказка: очередь FIFO, впереди B).

  4. В четвёртой сессии найди блокировщиков: SELECT pid, pg_blocking_pids(pid), state, query FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;. Запиши корень каскада - это PID сессии A.

  5. Сними блокировщика: для idle-in-transaction нужен SELECT pg_terminate_backend(<pid сессии A>);. Проверь, что B и C тут же выполнились.

  6. Повтори с включённым idle_in_transaction_session_timeout = '5s' (через SET в сессии A до BEGIN не сработает - это серверный таймаут; обсуди, почему его ставят в конфиг).

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

Резюме

  • Блокировки закрывают конфликты, которые MVCC не закрывает: писатель против писателя на одну строку и изменение структуры таблицы против любого доступа к ней.
  • Отношение блокируется одним из 8 режимов. SELECT берёт ACCESS SHARE, запись - ROW EXCLUSIVE, DDL - ACCESS EXCLUSIVE, который конфликтует со всеми.
  • Тяжёлая блокировка держится до конца транзакции, а не до конца запроса. Открытый BEGIN держит её, даже если запрос давно отработал.
  • Блокировки строк хранятся в самой строке (xmax + infomask), а не в pg_locks. Их четыре режима, слабые нужны для внешних ключей.
  • Очередь блокировок честная (FIFO): ждущий с тяжёлым режимом останавливает за собой даже совместимые запросы - так рождается каскад.
  • pg_locks показывает запрошенные блокировки и granted, а pg_blocking_pids(pid) сразу называет виновника без ручного сведения таблицы.
  • Снять блокировщика: pg_cancel_backend отменяет запрос, pg_terminate_backend закрывает сессию. От забытых транзакций защищает idle_in_transaction_session_timeout.

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

  1. Почему `ALTER TABLE` опасно выполнять на горячей таблице, даже если он быстрый?

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

    ALTER TABLE (в большинстве форм) запрашивает ACCESS EXCLUSIVE, который конфликтует со всеми режимами, включая обычный SELECT. Сама команда может отработать за миллисекунды, но сначала ей надо дождаться, пока уйдут все текущие читатели и писатели. Пока она ждёт в очереди, за ней копятся новые запросы - очередь честная, они не могут её обойти. Если впереди оказалась долгая транзакция, таблица встаёт целиком. Поэтому DDL на проде выкатывают с коротким lock_timeout и в моменты низкой нагрузки.

  2. Сессия выполнила SELECT и ничего больше не делает, но миграция за ней висит. Как это возможно?

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

    SELECT взял ACCESS SHARE на таблицу. Если транзакция не закрыта (BEGIN без COMMIT/ROLLBACK), эта блокировка держится до конца транзакции, а не до конца запроса. В pg_stat_activity такая сессия в состоянии idle in transaction. Миграция хочет ACCESS EXCLUSIVE, который конфликтует с ACCESS SHARE, и ждёт. Лечится закрытием транзакции или pg_terminate_backend, а предотвращается через idle_in_transaction_session_timeout.

  3. Почему блокировок строк нет в `pg_locks`, а блокировки таблиц есть?

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

    Блокировка строки хранится в самой строке: номер держащей транзакции пишется в xmax, статус - в t_infomask. Это бесплатно по памяти и масштабируется на миллионы строк. Отдельная запись в pg_locks на каждую заблокированную строку взорвала бы фиксированную таблицу блокировок в разделяемой памяти. В pg_locks виден только косвенный след: ждущая транзакция стоит на locktype = 'transactionid', ожидая завершения держателя.

  4. Зачем PostgreSQL целых четыре режима блокировки строк?

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

    Из-за внешних ключей. Когда вставляешь строку в дочернюю таблицу, нужно гарантировать, что родительская строка не исчезнет, но менять её ты не собираешься. Слабый FOR KEY SHARE удерживает родителя от удаления и смены ключа, но пропускает параллельный UPDATE его неключевых колонок. Без такой градации (как было до 9.3) любая вставка в дочернюю таблицу конфликтовала бы с любым обновлением родителя, сериализуя то, что могло идти параллельно.

  5. В чём разница между `pg_cancel_backend` и `pg_terminate_backend`?

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

    pg_cancel_backend(pid) отменяет текущий запрос процесса, как Ctrl+C, но оставляет соединение и транзакцию живыми. Для idle in transaction он обычно бесполезен: активного запроса нет, отменять нечего, а открытая транзакция и её блокировки остаются. pg_terminate_backend(pid) закрывает само соединение: транзакция откатывается, все её блокировки спадают. Поэтому забытую открытую транзакцию снимают именно terminate, а не cancel.

← Предыдущая20-wal-levelsСледующая →22-deadlocks
Footer
linuxlab-
Copyright © 2026 LinuxLab. Все права защищены.
Учебники
Цены
О платформе
Конфиденциальность и куки