Если вы когда-либо сталкивались с тем, что запросы внезапно «висят», транзакции ведут себя странно, а 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

SELECT

ACCESS EXCLUSIVE

2

ROW SHARE

SELECT FOR UPDATE/SHARE

EXCLUSIVE, ACCESS EXCLUSIVE

3

ROW EXCLUSIVE

INSERT, UPDATE, DELETE

SHARE и выше

4

SHARE UPDATE EXCLUSIVE

CREATE INDEX CONCURRENTLY, ANALYZE, VACUUM

SHARE UPDATE EXCLUSIVE и выше

5

SHARE

CREATE INDEX

ROW EXCLUSIVE и выше

6

SHARE ROW EXCLUSIVE

CREATE TRIGGER, некоторые ALTER TABLE

ROW SHARE и выше

7

EXCLUSIVE

Допускает только чтение

ROW SHARE и выше

8

ACCESS EXCLUSIVE

DROP TABLE, ALTER TABLE, VACUUM FULL

Все

Чем выше номер — тем строже блокировка. Две операции с конфликтующими блокировками не могут работать с одним объектом одновременно: одна из них будет ждать.

Отдельно стоит отметить разницу между 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 — это «попугаи», и когда индекс только мешает.