Администрирование PostgreSQL, часть 3 - Архитектура и MVCC
Приветствую!

Третья часть курса по PostgreSQL - смотрим, как PostgreSQL устроен внутри: процессы и память, подготовленные операторы, курсоры, а также механизм MVCC, снимки данных и уровни изоляции транзакций 🐧.

Предисловие

В первой и второй частях мы говорили об инструментах - как поднять сервер и как нему подключиться. Но сегодня мы заглянуть под капот: почему PostgreSQL умеет одновременно обслуживать сотни клиентов, что происходит в оперативной памяти при выполнении запроса, и почему две транзакции могут одновременно менять базу и не видеть “грязных” данных друг друга.

Второй вопрос в этом уроке - MVCC (Multiversion Concurrency Control), многоверсионность. Тема без картинок понимается тяжело, а с живым стендом в Docker - гораздо проще: заведём две параллельные сессии psql и своими глазами увидим, как одна и та же строка выглядит по-разному для разных транзакций.

Вводные данные

ПО, используемое в статье:

ПОВерсия
PostgreSQL (образ)18
psql (в образе)18
DBeaver Community26

Стенд - тот самый контейнер postgres из первой части, запущенный и доступный на 127.0.0.1:5432.

Клиент-серверная архитектура

PostgreSQL устроен по классической клиент-серверной модели. Сервер - это управляющий процесс postmaster, который слушает порт и на каждое новое подключение форкает отдельный процесс - backend. Именно поэтому у PostgreSQL “дорогие” подключения: не поток внутри одного процесса, а полноценный процесс ОС на каждого клиента.

Убедиться в этом можно, не отходя от своего же контейнера:

BASH
cd ~/Postgres

docker compose exec postgres ps -eHf
Нажмите, чтобы развернуть и увидеть больше

Вывод будет примерно таким:

Вывод
UID          PID    PPID  C STIME TTY          TIME CMD
postgres       1       0  0 Sep08 ?        00:00:11 postgres
postgres      27       1  0 Sep08 ?        00:00:00   postgres: io worker 0
postgres      28       1  0 Sep08 ?        00:00:00   postgres: io worker 1
postgres      29       1  0 Sep08 ?        00:00:00   postgres: io worker 2
postgres      30       1  0 Sep08 ?        00:00:00   postgres: checkpointer 
postgres      31       1  0 Sep08 ?        00:00:00   postgres: background writer 
postgres      33       1  0 Sep08 ?        00:00:00   postgres: walwriter 
postgres      34       1  0 Sep08 ?        00:00:01   postgres: autovacuum launcher 
postgres      35       1  0 Sep08 ?        00:00:00   postgres: logical replication launcher
Нажмите, чтобы развернуть и увидеть больше

Заметьте - ни одного backend-процесса под конкретного клиента здесь нет, только сам postmaster (PID 1) и фоновые служебные процессы. Откроем подключение и повторим:

BASH
docker compose exec postgres psql -U ivan -d raven

SELECT pg_backend_pid();

\! ps -eHf
Нажмите, чтобы развернуть и увидеть больше

В списке появится новая строка postgres: ivan raven [local] idle - это наш backend, тот самый PID, который вернул pg_backend_pid(). Как только сессия завершится - процесс исчезнет.

Вывод
UID          PID    PPID  C STIME TTY          TIME CMD
root       52196       0  0 17:08 pts/1    00:00:00 /usr/lib/postgresql/18/bin/psql -U ivan -d raven
root       52341   52340  0 17:10 pts/1    00:00:00     ps -eHf
postgres       1       0  0 Sep08 ?        00:00:11 postgres
postgres      27       1  0 Sep08 ?        00:00:00   postgres: io worker 0
postgres      28       1  0 Sep08 ?        00:00:00   postgres: io worker 1
postgres      29       1  0 Sep08 ?        00:00:00   postgres: io worker 2
postgres      30       1  0 Sep08 ?        00:00:00   postgres: checkpointer 
postgres      31       1  0 Sep08 ?        00:00:00   postgres: background writer 
postgres      33       1  0 Sep08 ?        00:00:00   postgres: walwriter 
postgres      34       1  0 Sep08 ?        00:00:01   postgres: autovacuum launcher 
postgres      35       1  0 Sep08 ?        00:00:00   postgres: logical replication launcher 
postgres   52202       1  0 17:08 ?        00:00:00   postgres: ivan raven [local] idle
Нажмите, чтобы развернуть и увидеть больше

В DBeaver прямого доступа к ps -eHf нет (да и не нужен), но список активных backend-ов виден в разделе Sessions свойств подключения (клик правой кнопкой по соединению в ToolsSession Manager) - по сути та же информация, что в pg_stat_activity, но в табличке с кнопкой “прибить” сессию.

Память: общая и локальная

У каждого backend-процесса есть доступ к двум видам памяти:

SQL
SHOW shared_buffers;

SHOW work_mem;
Нажмите, чтобы развернуть и увидеть больше

В DBeaver все параметры сервера сразу видны в свойствах подключения, вкладка Configuration - удобный табличный вид того же самого pg_settings, который мы трогали во второй части.

Подготовленные операторы

Подготовленный оператор (PREPARE) разбирает запрос один раз и сохраняет план выполнения в локальной памяти сессии. Основная польза PREPARE - избежать повторного парсинга/планирования запроса при многократном выполнении с разными параметрами.

SQL
PREPARE get_now AS SELECT now();

EXECUTE get_now;

SELECT * FROM pg_prepared_statements;

DEALLOCATE get_now;
Нажмите, чтобы развернуть и увидеть больше

Курсоры

Курсор позволяет забирать результат запроса по частям, не вычитывая всё в память сразу:

SQL
BEGIN;

DECLARE c CURSOR FOR SELECT * FROM pg_roles;

FETCH 2 FROM c;
FETCH 2 FROM c;

CLOSE c;
COMMIT;
Нажмите, чтобы развернуть и увидеть больше

DBeaver и так работает через курсоры внутри - когда результат запроса большой, он подгружает строки порциями, размер которых настраивается в PreferencesEditorsData Editor (ResultSet fetch size). Разница лишь в том, что там это происходит автоматически, а не по явной команде FETCH.

MVCC: многоверсионность

PostgreSQL не перезаписывает строки при UPDATE - вместо этого он создаёт новую версию строки, а старую помечает как устаревшую. Каждая версия хранит два системных столбца:

Заведём демонстрационную таблицу - никакого отношения к будущей схеме raven она не имеет, просто песочница для примера:

SQL
CREATE TABLE demo_mvcc (id INT PRIMARY KEY, note TEXT);

INSERT INTO demo_mvcc VALUES (1, 'первая версия');

SELECT xmin, xmax, * FROM demo_mvcc;
Нажмите, чтобы развернуть и увидеть больше

Теперь для демо работы MVCC откроем две сессии psql в терминале:

BASH
docker compose exec postgres psql -U ivan -d raven
Нажмите, чтобы развернуть и увидеть больше

В первой сессии (назовём её A) начинаем транзакцию и меняем строку, но не коммитим:

Во второй сессии (B) смотрим на ту же строку:

Сессия B видит старую версию - первая версия, хотя A уже выполнила UPDATE.

Возвращаемся в A и коммитим:

Повторяем SELECT в сессии B - теперь она видит новую версию.

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

Уровни изоляции транзакций

Снимок данных, который видит транзакция, зависит от уровня изоляции:

УровеньПоведение
Read UncommittedОтдельно не поддерживается, работает как Read Committed
Read CommittedСнимок обновляется перед каждой командой (уровень по умолчанию)
Repeatable ReadСнимок фиксируется на начало транзакции и не меняется
SerializableПолная изоляция, возможны ошибки сериализации при конфликтах

Повторим предыдущий эксперимент, но в сессии B явно укажем REPEATABLE READ:

В Read Committed (по умолчанию) второй SELECT в сессии B увидел бы новую версию сразу после COMMIT сессии A, даже не выходя из своей транзакции - именно поэтому уровень изоляции не задаётся один раз “на весь проект”, а осознанно выбирается под задачу.

Менять уровень изоляции в DBeaver можно прямо на панели, через специальный переключатель:

Коротко про блокировки в PostgreSQL

Если два клиента одновременно пытаются изменить одну и ту же строку - MVCC здесь не спасает, придётся подождать. Повторим эксперимент с сессиями A и B, но теперь обе будут писать:

Сессия B встанет в ожидание:

Посмотреть, кто кого блокирует, можно из третьей сессии:

SQL
SELECT pid, wait_event_type, wait_event, query FROM pg_stat_activity WHERE wait_event IS NOT NULL;

SELECT pg_blocking_pids(pid), * FROM pg_stat_activity WHERE pid = <PID сессии B>;
Нажмите, чтобы развернуть и увидеть больше

Как только сессия A выполнит COMMIT или ROLLBACK - сессия B немедленно продолжит работу.

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

DDL - тоже транзакции

Отдельная приятная особенность PostgreSQL: команды CREATE, ALTER, DROP - такие же транзакционные, как и обычный INSERT. В отличие, например, от MySQL, где DDL коммитится немедленно.

Если бы вместо ROLLBACK был COMMIT - таблица бы появилась и в сессии B. Работает это и для нескольких DDL-команд подряд в одной транзакции - весь блок либо применится целиком, либо не применится вообще.

Уборка за собой

Демонстрационная таблица больше не нужна:

SQL
DROP TABLE demo_mvcc;
Нажмите, чтобы развернуть и увидеть больше

Возможные проблемы

Почти всегда причина в том, что в другом SQL-редакторе того же подключения осталась открытая транзакция без COMMIT/ROLLBACK (особенно легко забыть при выключенном Auto-commit).

Если в примере выше поменять порядок и в сессии B сначала попытаться изменить строку, которую уже держит A, а потом дать A попытаться изменить то, что держит B - получите классический deadlock, и PostgreSQL сам прервёт одну из транзакций с ошибкой deadlock detected.

Вывод
ERROR:  deadlock detected
DETAIL:  Process 49 waits for ShareLock on transaction 800; blocked by process 42.
Process 42 waits for ShareLock on transaction 797; blocked by process 49.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,8) in relation "demo_mvcc"
Нажмите, чтобы развернуть и увидеть больше

Это нормальное защитное поведение, а не баг - но при частых деадлоках стоит пересматривать порядок блокировок в коде приложения.

Послесловие

Постепенно погружаемся в недра PostgreSQL…

MVCC довольно абстрактная вещь, особенно на словах. Но в этом уроке мы наглядно увидели, как две сессии смотрят на одну строку. Возможно вы уже где-то слышали и про VACUUM - механизм уборки тех самых старых версий строк, которые мы сегодня смотрели через xmin/xmax.

Следующая тема - конфигурирование PostgreSQL: postgresql.conf, ALTER SYSTEM и то, откуда PostgreSQL на самом деле берёт действующие значения параметров. Не пропустите.

Спасибо, что читаете. Успехов в изучении PostgreSQL! 🐧

Используемые материалы

Авторские права

Автор: Иван Чёрный

Ссылка: https://r4ven.me/storage/administrirovanie-postgresql-chast-3-arhitektura-i-mvcc/

Лицензия: CC BY-NC-SA 4.0

Использование материалов блога разрешается при условии: указания авторства/источника, некоммерческого использования и сохранения лицензии.

Начать поиск

Введите ключевые слова для поиска статей

↑↓
ESC
⌘K Горячая клавиша