Если вы когда-либо сталкивались с тем, что запросы внезапно «висят», транзакции ведут себя странно, а UPDATE из двух сессий приводит к неожиданным результатам — эта статья поможет понять, что происходит внутри PostgreSQL. Разберём уровни изоляции, блокировки, VACUUM и покажем всё на живых примерах с SQL.
Несколько лет назад я делал внутренние доклады по PostgreSQL для команды. Потом долго работал с другими технологиями, а недавно вернулся к PostgreSQL — и первым делом пересмотрел собственные записи, чтобы освежить базовые концепции. Подумал, что если мне это пригодилось — может пригодиться и другим. Базовые концепции транзакций и блокировок в PostgreSQL не изменились, но кое-что за 5 лет стало лучше — об этом в конце статьи.
now() vs clock_timestamp()
В PostgreSQL есть две функции для получения текущего времени: now() и clock_timestamp().
SELECT now(), now(), clock_timestamp(), clock_timestamp();
Оба вызова now() вернут одинаковое время. А вот clock_timestamp() может отличаться — даже внутри одного запроса.
Чтобы увидеть разницу, откроем транзакцию:
BEGIN; SELECT now(); -- ждём немного... SELECT now(); SELECT clock_timestamp(); COMMIT;
now() всегда возвращает время открытия транзакции. Это полезно, когда нужно поставить одинаковую дату создания и обновления в рамках одной операции — now() гарантирует одно и то же значение на протяжении всей транзакции.
clock_timestamp() — реальное текущее время, не привязанное к транзакции.
Уровни изоляции
Первое, что отличает PostgreSQL от многих других СУБД: всего три уровня изоляции.
Read Uncommitted / Read Committed — для PostgreSQL это одно и то же. Грязного чтения нет в принципе.
Repeatable Read
Serializable
Ещё одна приятная особенность: PostgreSQL поддерживает DDL в транзакциях. Можно в одной транзакции создать таблицу, вставить данные и откатить всё разом — далеко не каждая СУБД это умеет.
Блокировки: два UPDATE на одну строку
Создадим таблицу:
BEGIN; CREATE TABLE t_test1 (id int); INSERT INTO t_test1 VALUES (1); INSERT INTO t_test1 VALUES (2); COMMIT;
Теперь из двух сессий одновременно обновляем те же данные:
Сессия 1:
BEGIN ISOLATION LEVEL READ COMMITTED; UPDATE t_test1 SET id = id + 1 RETURNING *; -- не коммитим
Сессия 2:
BEGIN ISOLATION LEVEL READ COMMITTED; UPDATE t_test1 SET id = id + 1 RETURNING *; -- повисает!
Второй UPDATE ждёт завершения первой транзакции. Несмотря на то что PostgreSQL использует MVCC и позиционируется как неблокирующая база данных — блокировки на запись есть.
Таблица блокировок
# | Блокировка | Когда накладывается | Конфликтует с |
|---|---|---|---|
1 | ACCESS SHARE |
| ACCESS EXCLUSIVE |
2 | ROW SHARE |
| EXCLUSIVE, ACCESS EXCLUSIVE |
3 | ROW EXCLUSIVE |
| SHARE и выше |
4 | SHARE UPDATE EXCLUSIVE |
| SHARE UPDATE EXCLUSIVE и выше |
5 | SHARE |
| ROW EXCLUSIVE и выше |
6 | SHARE ROW EXCLUSIVE |
| ROW SHARE и выше |
7 | EXCLUSIVE | Допускает только чтение | ROW SHARE и выше |
8 | ACCESS EXCLUSIVE |
| Все |
Чем выше номер — тем строже блокировка. Две операции с конфликтующими блокировками не могут работать с одним объектом одновременно: одна из них будет ждать.
Отдельно стоит отметить разницу между CREATE INDEX (блокирует вставку/обновление/удаление) и CREATE INDEX CONCURRENTLY (строит индекс медленнее, но не блокирует DML-операции).
Read Committed: фантомное и неповторяющееся чтение
По умолчанию используется Read Committed — каждая команда в транзакции получает свежий снимок данных.
Создадим таблицу для примеров:
BEGIN; CREATE TABLE t_test2 (class int, value int); INSERT INTO t_test2 VALUES (1, 10); INSERT INTO t_test2 VALUES (1, 20); INSERT INTO t_test2 VALUES (2, 100); INSERT INTO t_test2 VALUES (2, 200); COMMIT;
Фантомное чтение
Сессия 1:
BEGIN ISOLATION LEVEL READ COMMITTED; SELECT sum(value) FROM t_test2; -- 330
Сессия 2 добавляет данные и коммитит:
INSERT INTO t_test2 VALUES (1, 30); COMMIT;
Сессия 1:
SELECT sum(value) FROM t_test2; -- 360! Появились новые строки. COMMIT;
Неповторяющееся чтение
Сессия 1:
BEGIN ISOLATION LEVEL READ COMMITTED; SELECT value FROM t_test2 WHERE class = 1 AND value = 10; -- Возвращает строку с value = 10
Сессия 2 обновляет данные и коммитит:
UPDATE t_test2 SET value = 15 WHERE class = 1 AND value = 10; COMMIT;
Сессия 1:
SELECT value FROM t_test2 WHERE class = 1 AND value = 10; -- Пусто! Строка, которую мы только что видели, изменилась. COMMIT;
Та же проблема, что и с фантомным чтением, но здесь не появляются новые строки — меняются существующие.
Repeatable Read
Если хотим избежать обеих аномалий — используем Repeatable Read:
BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT sum(value) FROM t_test2; -- 390
Параллельно другая сессия вставляет данные и коммитит. Возвращаемся:
SELECT sum(value) FROM t_test2; -- Всё ещё 390. COMMIT;
Снимок данных фиксируется на момент первого запроса в транзакции и не меняется.
Serializable: почему он падает
Самый жёсткий уровень. Имитирует последовательное выполнение транзакций.
Перекрёстные вставки
Сессия 1:
BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT sum(value) FROM t_test2 WHERE class = 1; INSERT INTO t_test2 VALUES (2, 30); COMMIT; -- OK
Сессия 2:
BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT sum(value) FROM t_test2 WHERE class = 2; INSERT INTO t_test2 VALUES (1, 30); COMMIT; -- ERROR: could not serialize access
Кто первый закоммитил — тот и молодец. Второй получает ошибку сериализации.
Ложные конфликты и предикатные блокировки
А вот интересный случай. Две транзакции работают с разными классами — логического конфликта нет:
Сессия 1: читает class=1, вставляет в class=1 Сессия 2: читает class=2, вставляет в class=2
Казалось бы, всё должно быть хорошо. Но на маленькой таблице — вторая транзакция падает.
Причина — предикатные блокировки (SSI, Serializable Snapshot Isolation). PostgreSQL хранит их на уровне страниц, а не отдельных строк. Когда данных мало и оба класса попадают на одну страницу — возникает ложный конфликт. На таблице с миллионом строк, где данные лежат на разных страницах, всё работает без проблем.
Это не баг, а особенность реализации. PostgreSQL в документации прямо говорит: транзакции уровня Repeatable Read и Serializable должны уметь повторяться.
Задачка: Алиса и Боб на дежурстве
Табличка текущей смены:
BEGIN; CREATE TABLE d_test (name text, on_call bool); INSERT INTO d_test VALUES ('Alice', true); INSERT INTO d_test VALUES ('Bob', true); INSERT INTO d_test VALUES ('Carol', false); COMMIT;
Условие: должен остаться хотя бы один дежурный. Алиса и Боб одновременно хотят уйти.
Обе сессии на Repeatable Read:
BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM d_test WHERE on_call = true; -- 2 (можно уходить) UPDATE d_test SET on_call = false WHERE name = 'Alice'; -- или 'Bob' COMMIT;
Обе транзакции видят двоих дежурных, обе успешно коммитятся. Результат: никого на дежурстве. Классическая гонка.
Решения
1. Serializable — вторая транзакция упадёт с ошибкой сериализации, её можно повторить.
2. SELECT … FOR UPDATE — блокируем выбранные строки:
SELECT count(*) FROM d_test WHERE on_call = true FOR UPDATE;
Вторая сессия подождёт завершения первой и увидит актуальные данные.
3. Атомарная операция — объединить проверку и действие в один запрос:
UPDATE d_test SET on_call = false WHERE name = 'Alice' AND (SELECT count(*) FROM d_test WHERE on_call = true) > 1;
Если на момент выполнения дежурный остался один — UPDATE просто не затронет ни одной строки. Никакого окна для гонки.
Advisory Locks
PostgreSQL позволяет блокироваться не на строке или таблице, а на произвольном числе:
BEGIN; SELECT pg_advisory_lock(15); -- делаем работу COMMIT; -- блокировка НЕ снимается! SELECT pg_advisory_unlock(15);
Важная особенность: pg_advisory_lock работает на уровне сессии, а не транзакции. Блокировка живёт до явного pg_advisory_unlock или до закрытия соединения.
Если нужна блокировка на уровне транзакции — используйте pg_advisory_xact_lock, она снимается автоматически при COMMIT/ROLLBACK.
VACUUM: сборщик мусора
При использовании MVCC PostgreSQL создаёт новые версии строк при каждом UPDATE. Старые версии (“мёртвые кортежи”) должен кто-то убирать — этим занимается VACUUM.
Эксперимент
CREATE TABLE t_test (id int) WITH (autovacuum_enabled = off); INSERT INTO t_test SELECT * FROM generate_series(1, 100000); SELECT pg_size_pretty(pg_relation_size('t_test')); -- ~3.5 MB
Обновляем все данные:
UPDATE t_test SET id = id + 1; SELECT pg_size_pretty(pg_relation_size('t_test')); -- ~7 MB (удвоилось!)
PostgreSQL пометил старые строки как удалённые и вставил новые. Размер удвоился.
VACUUM t_test; SELECT pg_size_pretty(pg_relation_size('t_test')); -- Всё ещё ~7 MB!
VACUUM не уменьшает размер таблицы — он помечает место как свободное для переиспользования. При следующих INSERT/UPDATE это место будет занято без роста файла.
Когда размер всё-таки уменьшается
DELETE FROM t_test WHERE id > 50000; VACUUM t_test; SELECT pg_size_pretty(pg_relation_size('t_test')); -- ~3.5 MB — уменьшился вдвое!
Удалили верхнюю половину данных — и размер файла реально сократился. Дело в том, что VACUUM возвращает место на диске только если освобождаемые страницы находятся в конце файла. Мы удалили строки с большими id, которые физически лежат в конце — и VACUUM смог «обрезать хвост». Если бы мы удалили строки из середины таблицы — размер файла остался бы прежним, а место просто было бы помечено как свободное для переиспользования.
Что изменилось в PostgreSQL за 5 лет
Базовые концепции транзакций и блокировок не изменились — всё описанное выше работает так же. Но ряд вещей стал лучше:
VACUUM стал значительно умнее. В PG 13 появилась параллельная обработка индексов при VACUUM. В PG 16 — пропуск страниц, не изменявшихся с последнего прохода, что радикально ускорило работу на больших таблицах. Отключать автовакуум “чтобы запускать руками ночью” — в 2026 году скорее антипаттерн.
REINDEX CONCURRENTLY(PG 14) — перестройка индексов без блокировки таблицы. Раньше для этого приходилось создавать новый индекс и удалять старый.Мониторинг блокировок стал удобнее.
pg_stat_activityиpg_locksдополнились новыми полями, а расширениеpg_wait_samplingпозволяет собирать статистику ожиданий.Advisory locks по-прежнему работают только в рамках одной базы данных. Для распределённых систем с несколькими инстансами PostgreSQL нужны внешние решения (Redis, etcd).
Во второй части разберём индексы: как PostgreSQL выбирает план выполнения, почему стоимость в EXPLAIN — это «попугаи», и когда индекс только мешает.
