PostgreSQL locksБлокировки в PostgreSQL

Which locks Postgres commands take, who they block, and how to change the schema on a busy database without the app noticing. An endless-question trainer at the end.

Какие блокировки берут команды Postgres, кого они останавливают и как менять схему на нагруженной базе так, чтобы приложение этого не заметило. В конце тренажёр с бесконечными вопросами.

There are two kinds of locks: on the whole table (eight levels) and on individual rows (four of their own). A write usually takes both.

A lock is held until the end of the transaction, not the statement. Even a SELECT in an open transaction holds it until COMMIT or ROLLBACK.

Блокировки бывают двух видов: на таблицу целиком (восемь уровней) и на отдельные строки (четыре своих). Запись обычно берёт оба.

Блокировка держится до конца транзакции, а не запроса. Даже SELECT в открытой транзакции держит её до COMMIT или ROLLBACK.

How it worksКак это устроено

Lock levels, what conflicts with what, and how the queue forms.

Уровни блокировок, что с чем конфликтует и как выстраивается очередь.

Table lock levelsУровни блокировки таблицы

Eight levels from weak to strong. The bar height shows how many levels a lock conflicts with, the color — what the app feels meanwhile. The word ROW in the name means "I work with rows", not "I locked a row": UPDATE … WHERE id = 42 holds ROW EXCLUSIVE on the table so it isn't rebuilt, and locks the row itself separately.

Восемь уровней от слабого к сильному. Высота столбца показывает, со сколькими уровнями конфликтует блокировка, цвет — что в это время почувствует приложение. Слово ROW в названии значит «работаю со строками», а не «заблокировал строку»: UPDATE … WHERE id = 42 держит ROW EXCLUSIVE на таблицу, чтобы её не перестроили, а саму строку блокирует отдельно.

Compare two commandsСравнить две команды

Conflict matrixМатрица конфликтов 8 × 8

× — two locks can't be held on the same table at once. Click a row or column to highlight a level.

× — две блокировки не могут одновременно удерживаться на одной таблице. Нажмите на строку или столбец, чтобы выделить уровень.

QueueОчередь

Locks are granted in order, and the queue isn't jumped: a new request lines up behind a waiter if it conflicts with it, even when it doesn't conflict with those already working. Here you can build your own queue for the orders table. Time doesn't pass: a transaction holds its lock until you press COMMIT.

Блокировки выдаются по очереди, и очередь не обгоняют: новый запрос встаёт за ожидающим, если конфликтует с ним, даже когда с теми, кто уже работает, конфликта нет. Здесь можно собрать свою очередь к таблице orders. Время не идёт: транзакция держит блокировку, пока вы не нажмёте COMMIT.

Examples:Примеры:

Only table locks are shown, without row locks and without waiting on old transactions for CONCURRENTLY.

Показаны только блокировки таблицы, без блокировок строк и без ожидания старых транзакций у CONCURRENTLY.

Row locksБлокировки строк

Taken only on the rows a query touched. A plain SELECT neither locks a row nor waits: it reads the last committed version. Row locks are stored in the rows themselves, so pg_locks shows them only when someone is waiting.

Берутся только на те строки, которые затронул запрос. Обычный SELECT строки не блокирует и не ждёт: он читает последнюю закоммиченную версию. Блокировки строк хранятся в самих строках, поэтому в pg_locks их видно, только когда кто-то ждёт.

An UPDATE that changes only ordinary columns (name, status, last_login) takes the weaker FOR NO KEY UPDATE: references to the row from other tables won't break. An UPDATE of a key column and DELETE take FOR UPDATE, because references may become invalid afterwards.

UPDATE, который меняет только обычные колонки (имя, статус, last_login), берёт более слабую FOR NO KEY UPDATE: ссылки на строку из других таблиц от этого не сломаются. UPDATE ключевой колонки и DELETE берут FOR UPDATE, потому что после них ссылки могут стать недействительными.

Foreign-key example. Inserting an order with user_id = 7 takes FOR KEY SHARE on the row of user 7 so it isn't deleted. A plain UPDATE of the user, for example last_login, takes FOR NO KEY UPDATE. These levels are compatible, so order inserts and user updates don't interfere. A DELETE of the user takes FOR UPDATE and will wait.

Пример с внешним ключом. Вставка заказа с user_id = 7 берёт FOR KEY SHARE на строку пользователя 7, чтобы его не удалили. Обычный UPDATE пользователя, например last_login, берёт FOR NO KEY UPDATE. Эти уровни совместимы, поэтому вставки заказов и обновления пользователя не мешают друг другу. А DELETE пользователя берёт FOR UPDATE и будет ждать.

Instead of waiting: with NOWAIT the query fails immediately with an error, with SKIP LOCKED locked rows are skipped (handy for task queues), with lock_timeout the query waits no longer than the set limit.

Вместо ожидания: с NOWAIT запрос сразу падает с ошибкой, с SKIP LOCKED заблокированные строки пропускаются (удобно для очередей задач), с lock_timeout запрос ждёт не дольше заданного.

What counts as a keyЧто считается ключом

Columns a foreign key from another table can reference: the primary key and columns with a unique index (except partial indexes and expression indexes). Usually just id, sometimes also something like email with a unique index. Whether anyone actually references them doesn't matter.

Колонки, на которые может ссылаться внешний ключ из другой таблицы: первичный ключ и колонки с уникальным индексом (кроме частичных индексов и индексов по выражению). Обычно это просто id, иногда ещё что-то вроде email с уникальным индексом. Реально ли на них кто-то ссылается, неважно.

Index on the foreign-key columnИндекс на колонке внешнего ключа

On DELETE of a user or a change of their key, Postgres looks for referencing orders by orders.user_id. An index on that column isn't created automatically, and without it every such check reads the whole orders, while row locks are held the entire time. If parent rows are deleted or their key is changed, the index on the FK column in the child table is needed (create it with CONCURRENTLY).

При DELETE пользователя или изменении его ключа Postgres ищет ссылающиеся заказы по orders.user_id. Индекс на эту колонку сам не создаётся, и без него каждая такая проверка читает всю orders, а блокировки строк всё это время держатся. Если строки родителя удаляют или меняют их ключ, индекс на колонке FK в дочерней таблице нужен (создавать через CONCURRENTLY).

Changing the schemaКак менять схему

What to check before a migration, what to replace dangerous commands with, and how to check your SQL.

Что проверить перед миграцией, чем заменить опасные команды и как проверить свой SQL.

Before a migrationПеред миграцией

RecipesРецепты

A dangerous command, a safe replacement, and the cost of that replacement. The timeouts from the checklist are assumed in every recipe. The safe variants share a common cost:

Опасная команда, безопасная замена и цена этой замены. Таймауты из чеклиста подразумеваются в каждом рецепте. У безопасных вариантов есть общая цена:

CONCURRENTLY can't run inside a transaction, so the migration tool needs a separate mode.

CONCURRENTLY waits for other transactions. One forgotten idle in transaction can stall the command for a long time.

Slower and heavier: more passes over the data, more disk space, longer I/O load.

On failure an intermediate state remains: an invalid index, an unvalidated constraint, a partition stuck "detaching".

CONCURRENTLY нельзя внутри транзакции, поэтому инструменту миграций нужен отдельный режим.

CONCURRENTLY ждёт чужие транзакции. Одна забытая idle in transaction может подвесить команду надолго.

Медленнее и тяжелее: больше проходов по данным, больше места на диске, дольше нагрузка на ввод-вывод.

При сбое остаётся промежуточное состояние: невалидный индекс, непроверенное ограничение, партиция в состоянии «отключается».

The plain variant makes more sense when the table is small, is created in the same migration, there's no load, or the change must go in one transaction with others.

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

Check a migrationПроверить миграцию

Paste your migration SQL: dangerous commands are highlighted, with links to recipes nearby. The check runs by patterns right in the browser, is sent nowhere, and sees neither table sizes nor load.

Вставьте SQL миграции: опасные команды подсветятся, рядом будут ссылки на рецепты. Проверка идёт по шаблонам прямо в браузере, никуда не отправляется и не видит ни размер таблиц, ни нагрузку.

Rails, Django, Alembic and Flyway run in one transaction by default.

В одной транзакции по умолчанию работают Rails, Django, Alembic и Flyway.

Command referenceСправочник команд

What lock each command takes, who it stops, and how much work it does.

Какую блокировку берёт каждая команда, кого она остановит и сколько работы сделает.

All commandsВсе команды

Migration toolsИнструменты миграций

Most often safe techniques break not in SQL but in the tool: it wraps the migration in a transaction and CONCURRENTLY fails, or it doesn't set timeouts.

Чаще всего безопасные приёмы ломаются не в SQL, а в инструменте: он оборачивает миграцию в транзакцию, и CONCURRENTLY падает, или не ставит таймауты.

Settings for Rails, Django, Alembic, Flyway and LiquibaseНастройки для Rails, Django, Alembic, Flyway и Liquibase

An example for each tool: an index without a write lock, plus lock_timeout.

Пример для каждого инструмента: индекс без блокировки записи и lock_timeout.

When something breaksКогда что-то сломалось

Diagnostic queries, deadlocks, table bloat, and walkthroughs of typical incidents.

Запросы для диагностики, дедлоки, раздувание таблиц и разборы типичных инцидентов.

How to see what's lockedКак посмотреть, что заблокировано

Queries are grouped by symptom. You can copy without expanding.

Запросы сгруппированы по симптому. Копировать можно не раскрывая.

Something is hanging right nowЧто-то висит сейчас

Who waits on whomКто кого ждётThe main query during an incident.Главный запрос при инциденте.

Whoever has a non-empty blocked_by is waiting. The oldest transaction with no blocked_by is usually the cause.

У кого непустой blocked_by, тот ждёт. Самая старая транзакция без blocked_by обычно и есть причина.

SELECT pid, pg_blocking_pids(pid) AS blocked_by, state,
       wait_event_type, wait_event,
       now() - xact_start AS xact_age, left(query, 80) AS query
FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid()
ORDER BY xact_start;
Table locksБлокировки таблицWho holds and who waits for locks on tables and indexes.Кто держит и кто ждёт блокировки на таблицах и индексах.

granted = true means "holds", false — "waits". Indexes appear in the list too.

granted = true значит «держит», false — «ждёт». Индексы тоже попадают в список.

SELECT l.pid, l.relation::regclass AS object, l.mode, l.granted,
       left(a.query, 60) AS query
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'relation'
  AND l.database = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY object, l.granted DESC;
Row waitsОжидания на строкахWhich row is waited on and whose transaction.Какую строку ждут и чью транзакцию.

When someone waits for a row, tuple entries appear (the row address: page and tuple) and transactionid (whose transaction is waited on). By the address the row can be found via ctid.

Когда кто-то ждёт строку, появляются записи tuple (адрес строки: page и tuple) и transactionid (чью транзакцию ждут). По адресу строку можно найти через ctid.

SELECT l.pid, l.locktype, l.relation::regclass AS tbl, l.page, l.tuple,
       l.transactionid, l.mode, l.granted
FROM pg_locks l
WHERE l.locktype IN ('tuple', 'transactionid');

-- find the row by the address from page and tuple
SELECT * FROM orders WHERE ctid = '(1234,5)';
SELECT l.pid, l.locktype, l.relation::regclass AS tbl, l.page, l.tuple,
       l.transactionid, l.mode, l.granted
FROM pg_locks l
WHERE l.locktype IN ('tuple', 'transactionid');

-- найти строку по адресу из page и tuple
SELECT * FROM orders WHERE ctid = '(1234,5)';
Advisory locksAdvisory-блокировкиLocks the application or migration tool takes itself.Блокировки, которые приложение или инструмент миграций берёт само.

They're taken via pg_advisory_lock, for example by Rails and Flyway, so two migrations don't run at once. A session advisory lock is held until an explicit unlock or until the connection closes, even after COMMIT. If a migration "waits for another migration" and none is visible, look here.

Их берут через pg_advisory_lock, например Rails и Flyway, чтобы две миграции не шли одновременно. Сессионная advisory-блокировка держится до явного unlock или до закрытия соединения, даже после COMMIT. Если миграция «ждёт другую миграцию», а других не видно, ищите здесь.

SELECT l.pid, l.classid, l.objid, l.mode, l.granted,
       a.state, a.application_name, now() - a.state_change AS since,
       left(a.query, 60) AS query
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory'
ORDER BY l.granted DESC, since DESC;
Stop a queryОстановить запросcancel cancels the query, terminate closes the connection.cancel отменяет запрос, terminate закрывает соединение.

The connection remains after cancel. terminate is needed for sessions in idle in transaction: they have no current query to cancel.

Соединение после cancel остаётся. terminate нужен для сессий в состоянии idle in transaction: у них нет текущего запроса, который можно отменить.

SELECT pg_cancel_backend(12345);
SELECT pg_terminate_backend(12345);
All locked rows of a tableВсе заблокированные строки таблицыVia pgrowlocks, for a staging box only.Через pgrowlocks, только для стенда.

Shows locks even if no one is waiting. Scans the whole table, so not for large tables in prod.

Показывает блокировки, даже если никто не ждёт. Сканирует всю таблицу, поэтому не для больших таблиц в проде.

CREATE EXTENSION IF NOT EXISTS pgrowlocks;

SELECT t.id, r.modes, r.pids
FROM orders t
JOIN pgrowlocks('orders') r ON t.ctid = r.locked_row;

Tables are bloatingТаблицы раздуваются

Oldest transactionsСамые старые транзакцииWho holds the VACUUM horizon.Кто держит горизонт VACUUM.

Watch for idle in transaction with a large xact_age.

Обращайте внимание на idle in transaction с большим xact_age.

SELECT pid, state, backend_xid, backend_xmin,
       now() - xact_start AS xact_age, left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL OR backend_xmin IS NOT NULL
ORDER BY xact_start
LIMIT 10;
Dead rows by tableМёртвые строки по таблицамWhere dead versions pile up.Где копятся мёртвые версии.

If n_dead_tup is large and doesn't drop after autovacuum, something holds the horizon.

Если n_dead_tup большой и не уменьшается после autovacuum, что-то держит горизонт.

SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

During and after a migrationВо время и после миграции

Index build progressПрогресс построения индексаPhase, processed blocks, and how many transactions still wait.Фаза, обработанные блоки и сколько транзакций ещё ждёт.

For CREATE INDEX and CREATE INDEX CONCURRENTLY.

Для CREATE INDEX и CREATE INDEX CONCURRENTLY.

SELECT pid, phase, blocks_done, blocks_total, lockers_done, lockers_total
FROM pg_stat_progress_create_index;
Invalid indexesНевалидные индексыLeft after a failed CREATE INDEX CONCURRENTLY.Остаются после упавшего CREATE INDEX CONCURRENTLY.

They must be dropped with DROP INDEX CONCURRENTLY and rebuilt.

Их нужно удалить через DROP INDEX CONCURRENTLY и построить заново.

SELECT indexrelid::regclass AS index, indrelid::regclass AS tbl
FROM pg_index
WHERE NOT indisvalid;

Post-mortemРазбор задним числом

Waits in the logОжидания в логеlog_lock_waits logs long lock waits.log_lock_waits пишет в лог долгие ожидания блокировок.

Every wait longer than deadlock_timeout goes to the log: who waited, for what, and who held it. From the log you can reconstruct an incident that's already over.

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

ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();

DeadlocksДедлоки

A deadlock is when two transactions wait for each other: each holds what the other needs. They won't get out of it on their own, so if a transaction waits for a lock longer than deadlock_timeout (1 second by default), Postgres checks for a cycle and cancels one of the transactions with error 40P01. The other then continues.

Дедлок — это когда две транзакции ждут друг друга: каждая держит то, что нужно другой. Сами они из этого не выйдут, поэтому если транзакция ждёт блокировку дольше deadlock_timeout (по умолчанию 1 секунда), Postgres проверяет, нет ли цикла, и отменяет одну из транзакций с ошибкой 40P01. Вторая после этого продолжает работу.

StepШагTransaction A, transfer 1 → 2Транзакция A, перевод 1 → 2Transaction B, transfer 2 → 1Транзакция B, перевод 2 → 1
1UPDATE accounts … WHERE id = 1holds row 1держит строку 1
2UPDATE accounts … WHERE id = 2holds row 2держит строку 2
3UPDATE accounts … WHERE id = 2waits for Bждёт B
4UPDATE accounts … WHERE id = 1waits for A: a cycle, one transaction will be canceledждёт A: цикл, одна транзакция будет отменена

How to avoid. Lock rows in the same order in all transactions, for example by ascending id. It's convenient to take everything you need up front in one query: SELECT … WHERE id IN (1, 2) ORDER BY id FOR UPDATE. Keep transactions short. Handle error 40P01 by retrying the transaction in code: even with a careful order, rare deadlocks are possible.

Как избегать. Блокировать строки во всех транзакциях в одном порядке, например по возрастанию id. Удобно брать всё нужное заранее одним запросом: SELECT … WHERE id IN (1, 2) ORDER BY id FOR UPDATE. Держать транзакции короткими. Обрабатывать ошибку 40P01 повтором транзакции в коде: даже при аккуратном порядке редкие дедлоки возможны.

How migrations get into a deadlockКак в дедлок попадают миграции

If a migration in one transaction first reads a table and then does an ALTER, it strengthens its lock and can cycle with the application. That's why DDL is better run in separate short transactions.

Если миграция в одной транзакции сначала читает таблицу, а потом делает ALTER, она усиливает свою блокировку и может зациклиться с приложением. Поэтому DDL лучше выполнять отдельными короткими транзакциями.

How to read a log entryКак читать запись в логе

The line waits for ShareLock on transaction 93012 means "waits for a row changed by transaction 93012". Below in the log are the queries of both processes, showing which rows were taken and in what order.

Строка waits for ShareLock on transaction 93012 значит «ждёт строку, которую изменила транзакция 93012». Ниже в логе перечислены запросы обоих процессов, по ним видно, какие строки и в каком порядке брались.

Long transactions and VACUUMДолгие транзакции и VACUUM

UPDATE doesn't overwrite a row, it creates a new version of it; the old one becomes dead. VACUUM removes dead versions, but only those that no open transaction can still see. So one long transaction, even one that blocks nothing and works with another table, prevents cleaning every table in the database, and they bloat.

UPDATE не перезаписывает строку, а создаёт её новую версию; старая становится мёртвой. VACUUM удаляет мёртвые версии, но только те, которые уже не может увидеть ни одна открытая транзакция. Поэтому одна долгая транзакция, даже ничего не блокирующая и работающая с другой таблицей, мешает чистить все таблицы базы, и они раздуваются.

live versionживая версияdead, but an open transaction can still see itмёртвая, но её может видеть открытая транзакцияdead, VACUUM will remove itмёртвая, VACUUM удалит

Protection: idle_in_transaction_session_timeout closes forgotten transactions, statement_timeout limits long queries, and the "Oldest transactions" query from diagnostics shows who holds the horizon right now.

Защита: idle_in_transaction_session_timeout закрывает забытые транзакции, statement_timeout ограничивает долгие запросы, а запрос «Самые старые транзакции» из диагностики показывает, кто держит горизонт сейчас.

Who holds the horizonКто держит горизонт

A transaction in REPEATABLE READ or SERIALIZABLE with an open snapshot, any transaction that has already changed something, a long query, an open cursor, a replication slot, and on replicas with hot_standby_feedback — long queries on the replica itself. A plain READ COMMITTED transaction that only read and is now waiting doesn't hold the horizon between queries.

Транзакция в REPEATABLE READ или SERIALIZABLE с открытым снимком, любая транзакция, которая уже что-то изменила, долгий запрос, открытый курсор, слот репликации, а на репликах с hot_standby_feedback — долгие запросы на самой реплике. Обычная транзакция в READ COMMITTED, которая только читала и теперь ждёт, горизонт между запросами не держит.

When autovacuum won't yield to a migrationКогда autovacuum не уступает миграции

Ordinary autovacuum holds SHARE UPDATE EXCLUSIVE but cancels itself if someone waits for a conflicting lock, so it usually doesn't bother migrations. The exception is anti-wraparound autovacuum: in pg_stat_activity it shows as autovacuum: VACUUM … (to prevent wraparound) and doesn't cancel itself. ALTER TABLE queues behind it, and behind ALTER all other queries, as in the first incident. With lock_timeout the migration simply fails and retries later. You can cancel such an autovacuum, but it will start again: the table still needs to be finished, better run VACUUM manually during a quiet time.

Обычный autovacuum держит SHARE UPDATE EXCLUSIVE, но сам прерывается, если кто-то ждёт конфликтующую блокировку, поэтому миграциям он обычно не мешает. Исключение — autovacuum для защиты от wraparound: в pg_stat_activity он виден как autovacuum: VACUUM … (to prevent wraparound) и не прерывается. ALTER TABLE встаёт за ним в очередь, а за ALTER все остальные запросы, как в первом инциденте. С lock_timeout миграция просто упадёт и повторит попытку позже. Отменять такой autovacuum можно, но он запустится снова: таблицу всё равно нужно довести до конца, лучше выполнить VACUUM вручную в спокойное время.

Locks on replicasБлокировки на репликах

ACCESS EXCLUSIVE is written to the WAL and replayed on replicas. If a query to that table is running on the replica, WAL replay waits for it up to max_standby_streaming_delay, then the query is canceled with a recovery conflict error; while it waits, lag grows. So even an instant DDL on the primary can cancel long reports on a replica.

VACUUM at the end also briefly takes ACCESS EXCLUSIVE to truncate empty pages at the end of the table. On the primary it doesn't wait and skips this step on conflict, but on the replica it's the same lock. If this is a problem, truncation is disabled for the table: ALTER TABLE … SET (vacuum_truncate = off).

ACCESS EXCLUSIVE записывается в WAL и воспроизводится на репликах. Если на реплике идёт запрос к этой таблице, применение WAL ждёт его до max_standby_streaming_delay, затем запрос отменяется с ошибкой конфликта восстановления; пока ждёт, растёт лаг. Поэтому даже мгновенный DDL на мастере может отменить долгие отчёты на реплике.

Сам VACUUM в конце тоже кратко берёт ACCESS EXCLUSIVE, чтобы отрезать пустые страницы в конце таблицы. На мастере он не ждёт и пропускает этот шаг при конфликте, а на реплике это та же блокировка. Если это мешает, обрезку отключают для таблицы: ALTER TABLE … SET (vacuum_truncate = off).

Incident walkthroughsРазборы инцидентов

Typical lock situations in prod: what you see in monitoring, what to do right now, and how to prevent a repeat.

Типичные ситуации с блокировками в проде: что видно в мониторинге, что делать прямо сейчас и как не допустить повторения.

TrainerТренажёр

Questions are generated at random and never run out, answers are saved in this browser. Commands and levels in the text are clickable. Keys: 1–4 — answer, ← and → — flip.

Вопросы генерируются случайно и не заканчиваются, ответы сохраняются в этом браузере. Команды и уровни в тексте кликабельны. Клавиши: 1–4 — ответ, ← и → — листать.

Topics:Темы: все

ProgressПрогресс

Details for each command: the PostgreSQL documentation, the Explicit Locking and ALTER TABLE sections.

Подробности по каждой команде: документация PostgreSQL, разделы Explicit Locking и ALTER TABLE.