A B C D E F G H I J K L M N O P Q R S T V W Y Z А Б В Г Е И К Л М О П С Т Ц

MVCC

MVCC

 

MVCC (Multiversion Concurrency Control, многоверсионное управление конкурентным доступом) это способ организовать параллельную работу транзакций в СУБД, при котором изменение строки не перезаписывает её, а создаёт новую версию. Каждая транзакция читает согласованный снимок данных, поэтому чтение не ждёт запись, а запись не ждёт чтение. MVCC используют PostgreSQL, Greenplum (MPP-СУБД на основе PostgreSQL), движок InnoDB в MySQL и Oracle Database.

Ниже разобрано, как MVCC устроен в PostgreSQL 18 и чем за него платят. Сценарии практики воспроизведены на PostgreSQL 18.6, InnoDB и Oracle описаны по официальной документации.

 

Что такое MVCC и какую задачу решает многоверсионность

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

MVCC снимает конфликт иначе. Пишущая транзакция кладёт рядом новую версию строки и оставляет старую, пока та кому-то нужна, а читающая выбирает версию, которую положено видеть её снимку. В введении в MVCC из документации PostgreSQL 18 это сформулировано так, что чтение никогда не блокирует запись, а запись никогда не блокирует чтение. Две транзакции, меняющие одну строку, по-прежнему ждут друг друга.

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

 

Как устроены версии строк в PostgreSQL

PostgreSQL хранит версии строк (tuples, кортежи) прямо в файле таблицы, который называется кучей (heap). Обновлённая версия ложится на ту же или на другую страницу размером 8 КБ, отдельного хранилища для прошлых состояний нет.

 

Системные поля xmin, xmax и ctid

У каждой версии есть заголовок со служебными полями, и три из них можно выбрать обычным запросом как системные колонки:

  • xmin. Идентификатор транзакции (XID, transaction ID), которая создала версию вставкой или обновлением.
  • xmax. XID транзакции, которая удалила или заменила версию либо заблокировала строку. Ноль означает, что версию никто не трогал.
  • ctid. Физический адрес самой версии, пара из номера страницы и номера слота на ней. Ссылку на следующую версию хранит другое поле заголовка, t_ctid, его видно только через pageinspect.

По этим полям восстанавливается история строки.

Ещё в заголовке лежат биты-подсказки (hint bits). Статус транзакции хранится в структуре pg_xact, и первый выяснивший его процесс записывает ответ в версию, чтобы следующие не ходили в pg_xact снова.

 

Снимок данных и правила видимости

Снимок (snapshot) это не копия данных, а короткая запись о том, какие транзакции к его моменту уже завершились. Функция pg_current_snapshot() показывает его тремя полями:

  • xmin снимка. Наименьший XID среди ещё активных транзакций. Всё, что меньше, к моменту снимка завершено, зафиксировано или откачено.
  • xmax снимка. Наибольший XID среди завершённых плюс один. Транзакции с номером от xmax и выше для снимка ещё не завершились и невидимы.
  • Список активных. XID между двумя границами, которые в момент снимка ещё работали.

По ним видно, завершилась ли любая транзакция с точки зрения снимка.

Проверка видимости версии строки по снимку: версия видна, если транзакция xmin зафиксирована и попала в снимок, а транзакция xmax отсутствует, откатилась или не попала в снимок

Версия видна снимку, если транзакция xmin для него зафиксирована, а xmax пуст либо принадлежит транзакции, которая откатилась или для снимка ещё не завершена. Собственные изменения транзакция видит сразу, а полная проверка учитывает ещё номера команд внутри транзакции.

Транзакция, которая только читает, XID не получает вообще, номер выдаётся при первой записи. Поэтому отчёты не расходуют счётчик транзакций, о пределах которого речь пойдёт в разделе про заморозку.

Greenplum для инженеров данных и аналитиков данных

Код курса
GPDE
Ближайшая дата курса
11 января, 2027
Продолжительность
24 ак.часов
Стоимость обучения
76 800

Принцип работы, что происходит при INSERT, UPDATE и DELETE

INSERT создаёт версию с xmin текущей транзакции и пустым xmax. DELETE ничего не стирает, а записывает свой XID в xmax существующей версии, и при откате удаления версия остаётся видимой.

UPDATE в PostgreSQL работает как пара из удаления и вставки. Старая версия получает xmax обновляющей транзакции, рядом появляется новая с тем же XID в xmin, а поле t_ctid в заголовке старой версии указывает на новую. Так у строки образуется цепочка версий.

Версии одной строки в PostgreSQL: UPDATE оставляет старую версию с заполненным xmax и создаёт новую с xmin текущей транзакции, поле t_ctid старой версии указывает на новую

Момент взятия снимка зависит от уровня изоляции. В Read Committed, уровне по умолчанию, новый снимок получает каждый оператор, в Repeatable Read снимок берётся первым оператором и живёт до конца транзакции. Serializable строится поверх тех же снимков и добавляет отслеживание зависимостей между транзакциями (SSI, Serializable Snapshot Isolation).

Если строку уже изменила незавершённая транзакция, второй UPDATE ждёт её завершения. Когда та откатилась, UPDATE проходит, а когда зафиксировала изменение, в Repeatable Read второй получает ошибку сериализации, и транзакцию приходится повторять.

 

HOT-обновления и мёртвые версии

Чтобы UPDATE не добавлял запись в каждый индекс таблицы, PostgreSQL применяет оптимизацию HOT (Heap-Only Tuple, версия только в куче). По документации она срабатывает при двух условиях:

  • Индексы не затронуты. UPDATE не меняет ни одной колонки, на которую ссылаются индексы таблицы. Суммирующие индексы, в ядре это только BRIN, не в счёт.
  • На странице есть место. Новая версия помещается на ту же страницу, где лежит старая.

При HOT индекс продолжает указывать на начало цепочки, а до актуальной версии запрос доходит по полю t_ctid внутри страницы.

Промежуточные версии цепочки удаляются без VACUUM, при обычной работе со страницей (pruning), и начальный слот превращается в перенаправление (redirect) на живую версию. Шанс на HOT повышает пониженный fillfactor, то есть доля страницы, которую заполняет вставка.

Версия, которую не видит ни одна транзакция, называется мёртвой (dead tuple). Мёртвые версии копятся в таблице и, при обновлениях вне HOT, в индексах, и без очистки таблица раздувается (bloat).

 

VACUUM, горизонт удаления и заморозка XID

Мёртвые версии убирает VACUUM. Он удаляет версии, которые не видны ни одной транзакции, чистит ссылки на них в индексах и помечает место для новых версий. Файл при этом обычно не уменьшается. Сжать таблицу умеет VACUUM FULL, но он переписывает её в новый файл под блокировкой ACCESS EXCLUSIVE, и всё это время таблица недоступна даже на чтение.

Фоновый autovacuum запускает VACUUM сам, когда мёртвых версий больше порога, по умолчанию 50 строк плюс 20% таблицы, а в PostgreSQL 18 порог ограничен сверху параметром autovacuum_vacuum_max_threshold. Та же механика работает в Greenplum, её разбирает статья Сборка мусора и очистка таблиц в Greenplum с командой VACUUM.

VACUUM не удалит версию, пока её может увидеть самая старая из открытых транзакций. Эта граница называется горизонтом удаления, в отчёте VACUUM это removable cutoff. Горизонт держат долгая транзакция, сессия idle in transaction, забытая подготовленная транзакция и отставший слот репликации, а мёртвые версии за ним значатся в отчёте как dead but not yet removable.

Горизонт удаления VACUUM: долгая открытая транзакция держит самый старый xmin, и мёртвые версии новее него VACUUM пропускает как ещё нужные

Вторая обязанность VACUUM это заморозка. XID в PostgreSQL 32-битный и сравнивается по модулю 2^32, поэтому для любой транзакции около двух миллиардов номеров лежат в прошлом, а остальные в будущем. После оборота счётчика (wraparound) старая версия оказалась бы в будущем и пропала бы из выборок. Заморозка (freezing) помечает старые версии как видимые всем, не меняя xmin.

Версии старше vacuum_freeze_min_age, по умолчанию 50 млн транзакций, VACUUM замораживает попутно. Таблицу, у которой возраст relfrozenxid превысил autovacuum_freeze_max_age, по умолчанию 200 млн, autovacuum обрабатывает принудительно, даже если он выключен. Примерно за 40 млн транзакций до оборота сервер пишет предупреждения, а когда остаётся меньше 3 млн, перестаёт выдавать новые XID. В PostgreSQL 18 обычный VACUUM научился заранее замораживать часть полностью видимых страниц, интенсивность задаёт параметр vacuum_max_eager_freeze_failure_rate.

 

MVCC в PostgreSQL, InnoDB и Oracle

PostgreSQL держит старые версии в самой таблице. InnoDB, движок хранения MySQL, и Oracle Database оставляют в таблице актуальное состояние строки, а прошлое восстанавливают из данных отмены (undo).

Критерий PostgreSQL InnoDB (MySQL) Oracle Database
Где лежат старые версии в куче таблицы рядом с актуальной в undo-журнале, в сегменте отката (rollback segment) undo-табличного пространства в undo-сегментах
Как найти нужную версию по xmin, xmax и снимку скрытые поля строки DB_TRX_ID и DB_ROLL_PTR, указатель на запись undo по SCN (System Change Number) на момент начала запроса
Когда берётся снимок Read Committed на каждый оператор, Repeatable Read на транзакцию Repeatable Read (по умолчанию) первым согласованным чтением, Read Committed на каждое чтение Read Committed (по умолчанию) на момент открытия оператора
Кто убирает старое VACUUM и autovacuum фоновая очистка purge undo перезаписывается новыми транзакциями, минимальный срок хранения задаёт undo_retention
Цена долгих транзакций и запросов мёртвые версии и раздувание таблиц и индексов рост сегмента отката и undo-табличного пространства ошибка ORA-01555 snapshot too old у долгого запроса

В PostgreSQL долгий отчёт не упадёт, но удерживает горизонт, и таблицы растут. В Oracle тот же отчёт может упасть с ORA-01555, потому что нужные ему записи undo уже перезаписаны, зато таблица не раздувается. Документация MySQL советует регулярно фиксировать даже читающие транзакции, иначе InnoDB не освободит undo-журнал.

Архитектура Данных

Код курса
ARMG
Ближайшая дата курса
21 декабря, 2026
Продолжительность
24 ак.часов
Стоимость обучения
76 800

Когда MVCC помогает и где он мешает

MVCC выигрывает на смешанной нагрузке, где OLTP-приложение пишет, а отчёты и выгрузки читают ту же базу. На снимках построен и pg_dump, который делает согласованную копию, не блокируя ни читателей, ни писателей. Мешает механизм там, где версии рождаются быстрее, чем их убирают:

  • Горячие строки. Счётчик, который обновляют сотни раз в секунду, оставляет за собой сотни мёртвых версий в секунду.
  • Долгие транзакции. Отчёт на час или сессия idle in transaction держат горизонт удаления для всей базы.
  • Очереди в таблице. Паттерн «вставить задачу, взять, удалить» превращает таблицу в поток мёртвых версий, через которые продирается выборка из начала очереди.

Помогают настройка autovacuum для отдельных таблиц, пониженный fillfactor и лимит idle_in_transaction_session_timeout.

 

Практика, версии строк и VACUUM на PostgreSQL 18

По традиции весь код используемый в статье выкладываем на наш GitHub репозиторий

MVCC в PostgreSQL from airflow import DAG from airflow.operators.bash import BashOperator from datetime import datetime with DAG( dag_id="spark_submit_demo", start_date=datetime(2025, 1, 1), schedule="@daily", catchup=False ) as dag: run = BashOperator( task_id="run_job", bash_command="spark-submit app.py" ) GitHub code example MVCC в PostgreSQL

Стенд это PostgreSQL 18.6 в Docker Compose и драйвер psycopg 3.3.8 на Python 3.12 под Ubuntu 24.04, пять сценариев прошли за 4 секунды.

 

Стенд и запуск

Сервер описан файлом docker-compose.yml, порт открыт только на локальном интерфейсе.

# postgres 18.6 в Docker, прогнано на стенде 2026-11-06: один сервер для демо MVCC
services:
  pg:
    image: postgres:18.6
    container_name: mvcc-pg
    mem_limit: 1g
    environment:
      POSTGRES_USER: demo
      POSTGRES_PASSWORD: demo
      POSTGRES_DB: mvcc
    ports:
      - "127.0.0.1:55436:5432"
    volumes:
      - pgdata:/var/lib/postgresql
      # SQL-сценарии видны внутри контейнера в /demo/sql
      - ./sql:/demo/sql:ro
    healthcheck:
      # -h 127.0.0.1: на время initdb временный сервер слушает только сокет, по TCP готов уже рабочий
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U demo -d mvcc"]
      interval: 2s
      timeout: 3s
      retries: 30

volumes:
  pgdata:

Драйвер для сценариев с двумя сессиями ставится в виртуальное окружение по файлу requirements.txt.

# psycopg 3.3.8 с бинарной libpq, прогнано на стенде 2026-11-06
psycopg[binary]==3.3.8

Скрипт mvcc_demo.sh гоняет SQL-сценарии через psql в контейнере, а сценарии с двумя сессиями через snapshot_demo.py.

#!/usr/bin/env bash
# postgres 18.6 в Docker, psycopg 3.3.8, прогнано на стенде 2026-11-06
# Пять сценариев MVCC по очереди: ./mvcc_demo.sh all
# или по одному: ./mvcc_demo.sh versions|snapshot|hot|horizon|freeze
set -uo pipefail
cd "$(dirname "$0")" || exit 1

# -e печатает каждый запрос перед результатом, 2>&1 внутри контейнера держит ошибки рядом с запросом
psql_file() {
  docker compose exec -T pg sh -c "psql -U demo -d mvcc -X -e -v ON_ERROR_STOP=1 -f /demo/sql/$1 2>&1"
}
PY=${PY:-.venv/bin/python}

versions() { psql_file 01_versions.sql; }
snapshot() { "$PY" snapshot_demo.py snapshot; }
hot()      { psql_file 03_hot.sql; }
horizon()  { "$PY" snapshot_demo.py horizon; }
freeze()   { psql_file 05_freeze.sql; }

case ${1:-all} in
  versions|snapshot|hot|horizon|freeze) "$1" ;;
  all)
    rc=0
    echo "=== 1. Версии строки: xmin, xmax, ctid ===";              versions || rc=1
    echo; echo "=== 2. Снимок: чтение и запись не ждут друг друга ==="; snapshot || rc=1
    echo; echo "=== 3. HOT-обновления ===";                            hot      || rc=1
    echo; echo "=== 4. Горизонт удаления и VACUUM ===";                horizon  || rc=1
    echo; echo "=== 5. Возраст XID и заморозка ===";                   freeze   || rc=1
    exit $rc ;;
  *) echo "использование: $0 all|versions|snapshot|hot|horizon|freeze" >&2; exit 2 ;;
esac

Запуск из папки с кодом, последняя команда удаляет контейнер вместе с томом.

# PostgreSQL 18.6, Docker Compose 2.40.3, Python 3.12.3, проверено на стенде 2026-11-06
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
docker compose up -d --wait
./mvcc_demo.sh all
docker compose down -v

 

Версии строки в pageinspect

Сценарий 01_versions.sql проводит одну строку через INSERT, UPDATE и два DELETE и смотрит на страницу функциями расширения pageinspect. Автовакуум на демо-таблицах выключен, чтобы мёртвые версии не исчезали между шагами.

-- postgres 18.6 в Docker, pageinspect из contrib, прогнано на стенде 2026-11-06
-- Версии одной строки: что INSERT, UPDATE и DELETE оставляют на странице таблицы.
CREATE EXTENSION IF NOT EXISTS pageinspect;
DROP TABLE IF EXISTS accounts CASCADE;
-- автовакуум выключен, чтобы мёртвые версии не исчезли между шагами
CREATE TABLE accounts (
    id      int PRIMARY KEY,
    owner   text NOT NULL,
    balance numeric(12,2) NOT NULL
) WITH (autovacuum_enabled = off);

-- страница 0 таблицы глазами pageinspect: указатель, xmin, xmax, ссылка на следующую версию, флаги
\set page_items 'SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid, f.raw_flags FROM heap_page_items(get_raw_page(''accounts'', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp'

\echo '--- 1.1 INSERT: одна версия, xmin = транзакция вставки, xmax = 0'
BEGIN;
SELECT pg_current_xact_id() AS insert_xid;
INSERT INTO accounts VALUES (1, 'alice', 100);
COMMIT;
SELECT xmin, xmax, ctid, id, balance FROM accounts;

\echo '--- 1.2 UPDATE: старая версия получает xmax, новая версия ложится рядом'
BEGIN;
SELECT pg_current_xact_id() AS update_xid;
UPDATE accounts SET balance = 70 WHERE id = 1;
COMMIT;
-- страница сразу после коммита, до первого чтения строки
:page_items;
-- запрос видит только одну версию
SELECT xmin, xmax, ctid, id, balance FROM accounts;
-- после чтения у версий появились подсказки XMIN_COMMITTED и XMAX_COMMITTED
:page_items;

\echo '--- 1.3 DELETE с откатом: xmax проставлен, но строка видна, потому что транзакция xmax отменена'
BEGIN;
SELECT pg_current_xact_id() AS rolled_back_delete_xid \gset
\echo rolled_back_delete_xid = :rolled_back_delete_xid
DELETE FROM accounts WHERE id = 1;
ROLLBACK;
SELECT xmin, xmax, ctid, id, balance FROM accounts;
SELECT pg_xact_status(:'rolled_back_delete_xid'::xid8) AS delete_status;

\echo '--- 1.4 DELETE с коммитом: строка не видна запросу, но версия осталась на странице'
BEGIN;
SELECT pg_current_xact_id() AS delete_xid;
DELETE FROM accounts WHERE id = 1;
COMMIT;
SELECT count(*) AS visible_rows FROM accounts;
:page_items;
# вывод прогона на стенде 2026-11-06, PostgreSQL 18.6, фрагмент run_output.txt
--- 1.1 INSERT: одна версия, xmin = транзакция вставки, xmax = 0
BEGIN;
BEGIN
SELECT pg_current_xact_id() AS insert_xid;
 insert_xid 
------------
        756
(1 row)

INSERT INTO accounts VALUES (1, 'alice', 100);
INSERT 0 1
COMMIT;
COMMIT
SELECT xmin, xmax, ctid, id, balance FROM accounts;
 xmin | xmax | ctid  | id | balance 
------+------+-------+----+---------
  756 |    0 | (0,1) |  1 |  100.00
(1 row)

--- 1.2 UPDATE: старая версия получает xmax, новая версия ложится рядом
BEGIN;
BEGIN
SELECT pg_current_xact_id() AS update_xid;
 update_xid 
------------
        757
(1 row)

UPDATE accounts SET balance = 70 WHERE id = 1;
UPDATE 1
COMMIT;
COMMIT
SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid, f.raw_flags FROM heap_page_items(get_raw_page('accounts', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp;
 lp | lp_flags | t_xmin | t_xmax | t_ctid |                             raw_flags                             
----+----------+--------+--------+--------+-------------------------------------------------------------------
  1 |        1 |    756 |    757 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_HOT_UPDATED}
  2 |        1 |    757 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMAX_INVALID,HEAP_UPDATED,HEAP_ONLY_TUPLE}
(2 rows)

SELECT xmin, xmax, ctid, id, balance FROM accounts;
 xmin | xmax | ctid  | id | balance 
------+------+-------+----+---------
  757 |    0 | (0,2) |  1 |   70.00
(1 row)

SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid, f.raw_flags FROM heap_page_items(get_raw_page('accounts', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp;
 lp | lp_flags | t_xmin | t_xmax | t_ctid |                                       raw_flags                                       
----+----------+--------+--------+--------+---------------------------------------------------------------------------------------
  1 |        1 |    756 |    757 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_HOT_UPDATED}
  2 |        1 |    757 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_UPDATED,HEAP_ONLY_TUPLE}
(2 rows)

--- 1.3 DELETE с откатом: xmax проставлен, но строка видна, потому что транзакция xmax отменена
BEGIN;
BEGIN
SELECT pg_current_xact_id() AS rolled_back_delete_xid 
rolled_back_delete_xid = 758
DELETE FROM accounts WHERE id = 1;
DELETE 1
ROLLBACK;
ROLLBACK
SELECT xmin, xmax, ctid, id, balance FROM accounts;
 xmin | xmax | ctid  | id | balance 
------+------+-------+----+---------
  757 |  758 | (0,2) |  1 |   70.00
(1 row)

SELECT pg_xact_status('758'::xid8) AS delete_status;
 delete_status 
---------------
 aborted
(1 row)

--- 1.4 DELETE с коммитом: строка не видна запросу, но версия осталась на странице
BEGIN;
BEGIN
SELECT pg_current_xact_id() AS delete_xid;
 delete_xid 
------------
        759
(1 row)

DELETE FROM accounts WHERE id = 1;
DELETE 1
COMMIT;
COMMIT
SELECT count(*) AS visible_rows FROM accounts;
 visible_rows 
--------------
            0
(1 row)

SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid, f.raw_flags FROM heap_page_items(get_raw_page('accounts', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp;
 lp | lp_flags | t_xmin | t_xmax | t_ctid |                                                 raw_flags                                                 
----+----------+--------+--------+--------+-----------------------------------------------------------------------------------------------------------
  1 |        1 |    756 |    757 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_HOT_UPDATED}
  2 |        1 |    757 |    759 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_UPDATED,HEAP_KEYS_UPDATED,HEAP_ONLY_TUPLE}
(2 rows)

После UPDATE на странице две версии, а запрос видит одну. Слот 1 закрыт транзакцией 757 и ссылается на слот 2. Флаги HEAP_HOT_UPDATED и HEAP_ONLY_TUPLE означают HOT, потому что balance не в индексе. HEAP_XMAX_COMMITTED у слота 1 и HEAP_XMIN_COMMITTED у слота 2 поставил первый SELECT, это биты-подсказки. Откаченный DELETE оставил xmax 758, но строка видна, потому что транзакция 758 в статусе aborted. После DELETE с коммитом строк не видно, а версии лежат на странице.

 

Снимок и неблокирующее чтение на двух сессиях

Сценариям со снимком и горизонтом нужны две сессии с явным чередованием шагов. Скрипт snapshot_demo.py открывает три соединения psycopg и печатает каждый запрос с результатом.

# psycopg 3.3.8, postgres 18.6 в Docker, прогнано на стенде 2026-11-06
"""Снимок данных и горизонт удаления в PostgreSQL на двух сессиях с явным чередованием шагов.

Шаги выполняются строго по очереди, поэтому результат не зависит от таймингов.

    python snapshot_demo.py snapshot   # чтение не ждёт запись, RR держит снимок, RC берёт новый
    python snapshot_demo.py horizon    # открытая транзакция не даёт VACUUM удалить мёртвые версии
    python snapshot_demo.py all        # оба сценария

Адрес сервера берётся из MVCC_DSN, по умолчанию локальный контейнер из docker-compose.yml.
"""
import os
import sys
import time

import psycopg

sys.stdout.reconfigure(line_buffering=True)

DSN = os.environ.get(
    "MVCC_DSN", "host=127.0.0.1 port=55436 dbname=mvcc user=demo password=demo"
)


def fmt(value):
    """Decimal, xid8 и снимки печатаем строкой, как в psql."""
    return value if isinstance(value, (int, bool)) or value is None else str(value)


class Session:
    """Одна сессия PostgreSQL. autocommit=True, чтобы BEGIN и COMMIT были видны явно."""

    def __init__(self, name):
        self.name = name
        self.conn = psycopg.connect(DSN, autocommit=True)
        self.notices = []
        # VACUUM (VERBOSE) отдаёт отчёт сообщениями уровня INFO, а не строками результата
        self.conn.add_notice_handler(lambda d: self.notices.append(d.message_primary))

    def run(self, sql, timed=False):
        print(f"[{self.name}] {sql}")
        t = time.perf_counter()
        cur = self.conn.execute(sql)
        ms = (time.perf_counter() - t) * 1000
        rows = [tuple(fmt(v) for v in row) for row in cur.fetchall()] if cur.description else None
        if rows is not None:
            print(f"      -> {rows}")
        if timed:
            print(f"      (выполнено за {ms:.1f} мс)")
        return rows

    def close(self):
        self.conn.close()


def snapshot(admin, a, b):
    admin.run("DROP TABLE IF EXISTS accounts CASCADE")
    admin.run(
        "CREATE TABLE accounts (id int PRIMARY KEY, owner text NOT NULL, "
        "balance numeric(12,2) NOT NULL) WITH (autovacuum_enabled = off)"
    )
    admin.run("INSERT INTO accounts VALUES (1, 'alice', 100)")

    print("\n=== 2.1 REPEATABLE READ: снимок берётся первым запросом и живёт до конца транзакции ===")
    a.run("BEGIN ISOLATION LEVEL REPEATABLE READ")
    a.run("SELECT balance FROM accounts WHERE id = 1")
    a.run("SELECT pg_current_snapshot()")
    # читающая транзакция XID не получает, номер выдаётся только при первой записи
    a.run("SELECT pg_current_xact_id_if_assigned()")
    b.run("BEGIN")
    # запись не ждёт читателя, у которого открыта транзакция на той же строке
    b.run("UPDATE accounts SET balance = 70 WHERE id = 1", timed=True)
    b.run("SELECT pg_current_xact_id()")
    # чтение не ждёт писателя, который держит блокировку строки
    a.run("SELECT balance FROM accounts WHERE id = 1", timed=True)
    b.run("COMMIT")
    # B зафиксирована, но её XID не меньше xmax снимка A: для A её изменения в будущем
    a.run("SELECT balance FROM accounts WHERE id = 1")
    a.run("SELECT pg_current_snapshot()")
    a.run("COMMIT")
    a.run("SELECT balance FROM accounts WHERE id = 1")

    print("\n=== 2.2 READ COMMITTED: новый снимок на каждый оператор ===")
    b.run("BEGIN")
    b.run("UPDATE accounts SET balance = 40 WHERE id = 1")
    b.run("SELECT pg_current_xact_id()")
    # посторонняя транзакция начинается после B и сразу фиксируется: xmax снимка уходит за B
    admin.run("SELECT pg_current_xact_id()")
    a.run("BEGIN ISOLATION LEVEL READ COMMITTED")
    # B в списке активных транзакций снимка (третье поле), её версию A не видит
    a.run("SELECT balance, pg_current_snapshot() FROM accounts WHERE id = 1")
    b.run("COMMIT")
    # та же транзакция A, следующий оператор: новый снимок, B в нём уже зафиксирована
    a.run("SELECT balance, pg_current_snapshot() FROM accounts WHERE id = 1")
    a.run("COMMIT")


VACUUM_LINES = ("tuples:", "removable cutoff:", "pages:")


def vacuum_verbose(s, table):
    s.notices.clear()
    s.run(f"VACUUM (VERBOSE) {table}")
    # из длинного отчёта оставляем строки про версии, горизонт и страницы самой таблицы,
    # отчёт по её TOAST-таблице пропускаем
    for msg in s.notices:
        lines = msg.splitlines()
        if not lines or f'.public.{table}"' not in lines[0] or len(lines) == 1:
            continue
        for line in lines[1:]:
            if line.strip().startswith(VACUUM_LINES):
                print(f"      INFO {line.strip()}")


def table_state(s, table):
    # статистика сессии уходит в общую память, когда сессия простаивает: просим сделать это сразу
    s.run("SELECT pg_stat_force_next_flush()")
    s.run(
        "SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_relation_size(relid)) AS size "
        f"FROM pg_stat_user_tables WHERE relname = '{table}'"
    )


def horizon(admin, h, w):
    admin.run("DROP TABLE IF EXISTS events CASCADE")
    admin.run(
        "CREATE TABLE events (id int PRIMARY KEY, payload text NOT NULL) "
        "WITH (autovacuum_enabled = off)"
    )
    admin.run("INSERT INTO events SELECT g, md5(g::text) FROM generate_series(1, 100000) g")
    # без сброса счётчики INSERT дойдут до общей памяти после VACUUM и удвоят n_live_tup
    admin.run("SELECT pg_stat_force_next_flush()")
    admin.run("VACUUM events")
    table_state(admin, "events")

    print("\n=== 4.1 открытая транзакция H держит снимок, W переписывает всю таблицу ===")
    h.run("BEGIN ISOLATION LEVEL REPEATABLE READ")
    h.run("SELECT count(*) FROM events")
    w.run("UPDATE events SET payload = upper(payload)")
    # так выглядит виновник в мониторинге: простаивает внутри транзакции и держит backend_xmin
    admin.run(
        "SELECT application_name, state, backend_xmin, "
        "age(backend_xmin) AS xmin_age FROM pg_stat_activity "
        "WHERE backend_xmin IS NOT NULL AND pid <> pg_backend_pid() ORDER BY backend_start"
    )
    vacuum_verbose(w, "events")
    table_state(admin, "events")

    print("\n=== 4.2 H завершилась: тот же VACUUM удаляет мёртвые версии ===")
    h.run("COMMIT")
    vacuum_verbose(w, "events")
    # место внутри файла освободилось для новых версий, но сам файл не уменьшился
    table_state(admin, "events")

    print("\n=== 4.3 VACUUM FULL переписывает таблицу в новый файл ===")
    admin.run("SELECT pg_relation_filenode('events')")
    admin.run("VACUUM FULL events")
    admin.run("SELECT pg_relation_filenode('events'), pg_size_pretty(pg_relation_size('events'))")


SCENARIOS = {"snapshot": snapshot, "horizon": horizon}


def main(names):
    if names == ["all"]:
        names = list(SCENARIOS)
    unknown = [n for n in names if n not in SCENARIOS]
    if not names or unknown:
        sys.exit(f"использование: {sys.argv[0]} all|snapshot|horizon")
    for name in names:
        sessions = [Session("admin"), Session("A" if name == "snapshot" else "H"),
                    Session("B" if name == "snapshot" else "W")]
        # имя сессии видно в pg_stat_activity.application_name
        for s in sessions:
            s.conn.execute(f"SET application_name = 'session_{s.name}'")
        try:
            SCENARIOS[name](*sessions)
        finally:
            for s in sessions:
                s.close()


if __name__ == "__main__":
    main(sys.argv[1:])
# вывод прогона на стенде 2026-11-06, PostgreSQL 18.6, psycopg 3.3.8, фрагмент run_output.txt
=== 2.1 REPEATABLE READ: снимок берётся первым запросом и живёт до конца транзакции ===
[A] BEGIN ISOLATION LEVEL REPEATABLE READ
[A] SELECT balance FROM accounts WHERE id = 1
      -> [('100.00',)]
[A] SELECT pg_current_snapshot()
      -> [('763:763:',)]
[A] SELECT pg_current_xact_id_if_assigned()
      -> [(None,)]
[B] BEGIN
[B] UPDATE accounts SET balance = 70 WHERE id = 1
      (выполнено за 1.1 мс)
[B] SELECT pg_current_xact_id()
      -> [('763',)]
[A] SELECT balance FROM accounts WHERE id = 1
      -> [('100.00',)]
      (выполнено за 0.3 мс)
[B] COMMIT
[A] SELECT balance FROM accounts WHERE id = 1
      -> [('100.00',)]
[A] SELECT pg_current_snapshot()
      -> [('763:763:',)]
[A] COMMIT
[A] SELECT balance FROM accounts WHERE id = 1
      -> [('70.00',)]

=== 2.2 READ COMMITTED: новый снимок на каждый оператор ===
[B] BEGIN
[B] UPDATE accounts SET balance = 40 WHERE id = 1
[B] SELECT pg_current_xact_id()
      -> [('764',)]
[admin] SELECT pg_current_xact_id()
      -> [('765',)]
[A] BEGIN ISOLATION LEVEL READ COMMITTED
[A] SELECT balance, pg_current_snapshot() FROM accounts WHERE id = 1
      -> [('70.00', '764:766:764')]
[B] COMMIT
[A] SELECT balance, pg_current_snapshot() FROM accounts WHERE id = 1
      -> [('40.00', '766:766:')]
[A] COMMIT

В Repeatable Read у сессии A снимок с xmin и xmax 763 и нет своего XID, потому что она только читает. UPDATE в B занял 1.1 мс, чтение в A 0.3 мс, никто никого не ждал. После коммита B сессия A видит 100.00, потому что XID 763 не меньше xmax её снимка. В Read Committed транзакция B с XID 764 стоит в списке активных, и A видит 70.00, а следующий оператор A получил новый снимок и увидел 40.00.

 

HOT-обновление против обычного

Сценарий 03_hot.sql строит цепочку HOT, смотрит на неё до и после VACUUM и сравнивает таблицы с разным fillfactor.

-- postgres 18.6 в Docker, pageinspect из contrib, прогнано на стенде 2026-11-06
-- HOT-обновление: новая версия на той же странице, индекс не трогается.
CREATE EXTENSION IF NOT EXISTS pageinspect;
DROP TABLE IF EXISTS hot_demo, ff100, ff90 CASCADE;

\echo '--- 3.1 цепочка HOT на одной странице'
CREATE TABLE hot_demo (
    id  int PRIMARY KEY,
    sku text NOT NULL,
    qty int NOT NULL
) WITH (autovacuum_enabled = off);
CREATE INDEX hot_demo_sku ON hot_demo (sku);
INSERT INTO hot_demo VALUES (1, 'A-1', 10), (2, 'B-2', 20);
-- qty не входит ни в один индекс: три обновления подряд, каждое отдельной транзакцией
UPDATE hot_demo SET qty = 11 WHERE id = 1;
UPDATE hot_demo SET qty = 12 WHERE id = 1;
UPDATE hot_demo SET qty = 13 WHERE id = 1;
-- sku проиндексирована: такое обновление HOT быть не может
UPDATE hot_demo SET sku = 'B-2x' WHERE id = 2;
SELECT ctid, * FROM hot_demo ORDER BY id;

\set page_items 'SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid, f.raw_flags FROM heap_page_items(get_raw_page(''hot_demo'', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp'
:page_items;
-- первичный ключ: запись на строку id=1 одна и указывает на начало цепочки,
-- на строку id=2 две, потому что обновлённая версия получила свою запись в каждом индексе
SELECT itemoffset, ctid, data FROM bt_page_items('hot_demo_pkey', 1);

\echo '--- 3.2 после VACUUM: начало цепочки становится перенаправлением (lp_flags 2) на живую версию'
VACUUM hot_demo;
:page_items;

\echo '--- 3.3 fillfactor: HOT нужно свободное место на той же странице'
-- одинаковые таблицы, разница только в fillfactor
CREATE TABLE ff100 (id int PRIMARY KEY, qty int NOT NULL, pad text NOT NULL)
    WITH (autovacuum_enabled = off, fillfactor = 100);
CREATE TABLE ff90  (id int PRIMARY KEY, qty int NOT NULL, pad text NOT NULL)
    WITH (autovacuum_enabled = off, fillfactor = 90);
INSERT INTO ff100 SELECT g, 0, repeat('x', 60) FROM generate_series(1, 2000) g;
INSERT INTO ff90  SELECT g, 0, repeat('x', 60) FROM generate_series(1, 2000) g;
SELECT 'ff100' AS tbl, pg_relation_size('ff100') / 8192 AS pages,
       (SELECT upper - lower FROM page_header(get_raw_page('ff100', 0))) AS page0_free_bytes
UNION ALL
SELECT 'ff90', pg_relation_size('ff90') / 8192,
       (SELECT upper - lower FROM page_header(get_raw_page('ff90', 0)));
-- пять строк с первой страницы, неиндексированная колонка
UPDATE ff100 SET qty = qty + 1 WHERE id <= 5;
UPDATE ff90  SET qty = qty + 1 WHERE id <= 5;
SELECT 'ff100' AS tbl, array_agg(ctid ORDER BY id) AS new_ctids FROM ff100 WHERE id <= 5
UNION ALL
SELECT 'ff90', array_agg(ctid ORDER BY id) FROM ff90 WHERE id <= 5;

\echo '--- 3.4 счётчики обновлений в pg_stat_user_tables'
-- статистика сессии уходит в общую память, когда сессия простаивает: просим сделать это сразу
SELECT pg_stat_force_next_flush();
SELECT relname, n_tup_upd, n_tup_hot_upd, n_tup_newpage_upd
FROM pg_stat_user_tables WHERE relname IN ('hot_demo', 'ff100', 'ff90') ORDER BY relname;
# вывод прогона на стенде 2026-11-06, PostgreSQL 18.6, фрагмент run_output.txt
SELECT ctid, * FROM hot_demo ORDER BY id;
 ctid  | id | sku  | qty 
-------+----+------+-----
 (0,5) |  1 | A-1  |  13
 (0,6) |  2 | B-2x |  20
(2 rows)

SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid, f.raw_flags FROM heap_page_items(get_raw_page('hot_demo', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp;
 lp | lp_flags | t_xmin | t_xmax | t_ctid |                                                raw_flags                                                 
----+----------+--------+--------+--------+----------------------------------------------------------------------------------------------------------
  1 |        1 |    768 |    769 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_HOT_UPDATED}
  2 |        1 |    768 |    772 | (0,6)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED}
  3 |        1 |    769 |    770 | (0,4)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_UPDATED,HEAP_HOT_UPDATED,HEAP_ONLY_TUPLE}
  4 |        1 |    770 |    771 | (0,5)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_UPDATED,HEAP_HOT_UPDATED,HEAP_ONLY_TUPLE}
  5 |        1 |    771 |      0 | (0,5)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_UPDATED,HEAP_ONLY_TUPLE}
  6 |        1 |    772 |      0 | (0,6)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_UPDATED}
(6 rows)

SELECT itemoffset, ctid, data FROM bt_page_items('hot_demo_pkey', 1);
 itemoffset | ctid  |          data           
------------+-------+-------------------------
          1 | (0,1) | 01 00 00 00 00 00 00 00
          2 | (0,2) | 02 00 00 00 00 00 00 00
          3 | (0,6) | 02 00 00 00 00 00 00 00
(3 rows)

--- 3.2 после VACUUM: начало цепочки становится перенаправлением (lp_flags 2) на живую версию
VACUUM hot_demo;
VACUUM
SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid, f.raw_flags FROM heap_page_items(get_raw_page('hot_demo', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp;
 lp | lp_flags | t_xmin | t_xmax | t_ctid |                                       raw_flags                                       
----+----------+--------+--------+--------+---------------------------------------------------------------------------------------
  1 |        2 |        |        |        | 
  2 |        0 |        |        |        | 
  3 |        0 |        |        |        | 
  4 |        0 |        |        |        | 
  5 |        1 |    771 |      0 | (0,5)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_UPDATED,HEAP_ONLY_TUPLE}
  6 |        1 |    772 |      0 | (0,6)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_UPDATED}
(6 rows)
SELECT 'ff100' AS tbl, pg_relation_size('ff100') / 8192 AS pages,
       (SELECT upper - lower FROM page_header(get_raw_page('ff100', 0))) AS page0_free_bytes
UNION ALL
SELECT 'ff90', pg_relation_size('ff90') / 8192,
       (SELECT upper - lower FROM page_header(get_raw_page('ff90', 0)));
  tbl  | pages | page0_free_bytes 
-------+-------+------------------
 ff100 |    25 |               68
 ff90  |    28 |              868
(2 rows)

UPDATE ff100 SET qty = qty + 1 WHERE id <= 5;
UPDATE 5
UPDATE ff90  SET qty = qty + 1 WHERE id <= 5;
UPDATE 5
SELECT 'ff100' AS tbl, array_agg(ctid ORDER BY id) AS new_ctids FROM ff100 WHERE id <= 5
UNION ALL
SELECT 'ff90', array_agg(ctid ORDER BY id) FROM ff90 WHERE id <= 5;
  tbl  |                      new_ctids                      
-------+-----------------------------------------------------
 ff100 | {"(24,57)","(24,58)","(24,59)","(24,60)","(24,61)"}
 ff90  | {"(0,74)","(0,75)","(0,76)","(0,77)","(0,78)"}
(2 rows)

--- 3.4 счётчики обновлений в pg_stat_user_tables
SELECT pg_stat_force_next_flush();
 pg_stat_force_next_flush 
--------------------------
 
(1 row)

SELECT relname, n_tup_upd, n_tup_hot_upd, n_tup_newpage_upd
FROM pg_stat_user_tables WHERE relname IN ('hot_demo', 'ff100', 'ff90') ORDER BY relname;
 relname  | n_tup_upd | n_tup_hot_upd | n_tup_newpage_upd 
----------+-----------+---------------+-------------------
 ff100    |         5 |             0 |                 5
 ff90     |         5 |             5 |                 0
 hot_demo |         4 |             3 |                 0
(3 rows)

Три обновления qty у строки 1 сложились в цепочку слотов 1, 3, 4 и 5, а первичный ключ знает только адрес (0,1). Обновление проиндексированной sku у строки 2 HOT не стало, и версия в слоте 6 получила свою запись в первичном ключе, хотя id не менялся. После VACUUM слот 1 стал перенаправлением с lp_flags 2. В ff100 на первой странице было 68 свободных байт, и версии уехали на последнюю страницу, а в ff90 запаса в 868 байт хватило для HOT.

 

Долгая транзакция держит мёртвые версии

Сценарий horizon держит открытой транзакцию H, пока сессия W переписывает 100 000 строк, и запускает VACUUM (VERBOSE) до и после коммита H.

# вывод прогона на стенде 2026-11-06, PostgreSQL 18.6, psycopg 3.3.8, фрагмент run_output.txt
=== 4.1 открытая транзакция H держит снимок, W переписывает всю таблицу ===
[H] BEGIN ISOLATION LEVEL REPEATABLE READ
[H] SELECT count(*) FROM events
      -> [(100000,)]
[W] UPDATE events SET payload = upper(payload)
[admin] SELECT application_name, state, backend_xmin, age(backend_xmin) AS xmin_age FROM pg_stat_activity WHERE backend_xmin IS NOT NULL AND pid <> pg_backend_pid() ORDER BY backend_start
      -> [('session_H', 'idle in transaction', '781', 1)]
[W] VACUUM (VERBOSE) events
      INFO pages: 0 removed, 1667 remain, 1667 scanned (100.00% of total), 0 eagerly scanned
      INFO tuples: 0 removed, 200000 remain, 100000 are dead but not yet removable
      INFO removable cutoff: 781, which was 1 XIDs old when operation ended
[admin] SELECT pg_stat_force_next_flush()
      -> [('',)]
[admin] SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_relation_size(relid)) AS size FROM pg_stat_user_tables WHERE relname = 'events'
      -> [(100000, 100000, '13 MB')]

=== 4.2 H завершилась: тот же VACUUM удаляет мёртвые версии ===
[H] COMMIT
[W] VACUUM (VERBOSE) events
      INFO pages: 0 removed, 1667 remain, 1667 scanned (100.00% of total), 0 eagerly scanned
      INFO tuples: 100000 removed, 100000 remain, 0 are dead but not yet removable
      INFO removable cutoff: 782, which was 0 XIDs old when operation ended
[admin] SELECT pg_stat_force_next_flush()
      -> [('',)]
[admin] SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_relation_size(relid)) AS size FROM pg_stat_user_tables WHERE relname = 'events'
      -> [(100000, 0, '13 MB')]

=== 4.3 VACUUM FULL переписывает таблицу в новый файл ===
[admin] SELECT pg_relation_filenode('events')
      -> [(16482,)]
[admin] VACUUM FULL events
[admin] SELECT pg_relation_filenode('events'), pg_size_pretty(pg_relation_size('events'))
      -> [(16491, '6672 kB')]

Пока H открыта, VACUUM нашёл 100 000 мёртвых версий и не удалил ни одной, removable cutoff стоит на 781, на backend_xmin сессии H в состоянии idle in transaction. После коммита H тот же VACUUM удалил все версии, но файл остался размером 13 MB, и вернул место только VACUUM FULL с новым файлом на 6672 kB. Поля n_live_tup и n_dead_tup это оценки. Без pg_stat_force_next_flush() на одном из прогонов n_live_tup показал 200 000 вместо 100 000.

 

Возраст XID и заморозка

Сценарий 05_freeze.sql показывает пороги на сервере, старит таблицу тысячей транзакций и замораживает её.

-- postgres 18.6 в Docker, pageinspect и pg_visibility из contrib, прогнано на стенде 2026-11-06
-- Возраст XID и заморозка: что делает VACUUM (FREEZE) со старыми версиями.
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE EXTENSION IF NOT EXISTS pg_visibility;
DROP TABLE IF EXISTS freeze_demo CASCADE;

\echo '--- 5.1 пороги заморозки и автовакуума на этом сервере (значения по умолчанию)'
SELECT name, setting, unit FROM pg_settings
WHERE name IN ('autovacuum_freeze_max_age', 'vacuum_freeze_min_age', 'vacuum_freeze_table_age',
               'vacuum_failsafe_age', 'autovacuum_vacuum_threshold',
               'autovacuum_vacuum_scale_factor', 'autovacuum_vacuum_max_threshold',
               'autovacuum_vacuum_insert_threshold', 'vacuum_max_eager_freeze_failure_rate')
ORDER BY name;

\echo '--- 5.2 таблица и тысяча транзакций после неё, каждая со своим XID'
CREATE TABLE freeze_demo (id int PRIMARY KEY, v text) WITH (autovacuum_enabled = off);
INSERT INTO freeze_demo SELECT g, 'row ' || g FROM generate_series(1, 1000) g;
DO $$
BEGIN
    FOR i IN 1..1000 LOOP
        PERFORM pg_current_xact_id();
        COMMIT;
    END LOOP;
END $$;
SELECT pg_current_xact_id() AS current_xid,
       c.relfrozenxid, age(c.relfrozenxid) AS table_xid_age,
       (SELECT age(datfrozenxid) FROM pg_database WHERE datname = current_database()) AS db_xid_age
FROM pg_class c WHERE c.relname = 'freeze_demo';
SELECT min(xmin::text::bigint) AS rows_xmin, max(age(xmin)) AS rows_xmin_age FROM freeze_demo;

\set page_items 'SELECT lp, t_xmin, f.combined_flags, f.raw_flags FROM heap_page_items(get_raw_page(''freeze_demo'', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp LIMIT 2'
:page_items;
SELECT * FROM pg_visibility_map_summary('freeze_demo');
-- транзакции выше получили XID, но в WAL ничего не писали: их коммит сбрасывается на диск
-- фоном, в пределах wal_writer_delay (200 мс). VACUUM сразу после них замораживает версии,
-- но страницы all-visible не помечает (замечено на прогоне), поэтому ждём секунду
SELECT pg_sleep(1);

\echo '--- 5.3 VACUUM (FREEZE): xmin у версий остаётся прежним, но помечен замороженным'
VACUUM (FREEZE) freeze_demo;
SELECT c.relfrozenxid, age(c.relfrozenxid) AS table_xid_age
FROM pg_class c WHERE c.relname = 'freeze_demo';
:page_items;
SELECT * FROM pg_visibility_map_summary('freeze_demo');

\echo '--- 5.4 возраст базы и запас до принудительного автовакуума против wraparound'
SELECT datname, age(datfrozenxid) AS xid_age,
       current_setting('autovacuum_freeze_max_age')::bigint - age(datfrozenxid) AS left_to_forced_autovacuum
FROM pg_database WHERE datname = current_database();
# вывод прогона на стенде 2026-11-06, PostgreSQL 18.6, фрагмент run_output.txt
--- 5.1 пороги заморозки и автовакуума на этом сервере (значения по умолчанию)
SELECT name, setting, unit FROM pg_settings
WHERE name IN ('autovacuum_freeze_max_age', 'vacuum_freeze_min_age', 'vacuum_freeze_table_age',
               'vacuum_failsafe_age', 'autovacuum_vacuum_threshold',
               'autovacuum_vacuum_scale_factor', 'autovacuum_vacuum_max_threshold',
               'autovacuum_vacuum_insert_threshold', 'vacuum_max_eager_freeze_failure_rate')
ORDER BY name;
                 name                 |  setting   | unit 
--------------------------------------+------------+------
 autovacuum_freeze_max_age            | 200000000  | 
 autovacuum_vacuum_insert_threshold   | 1000       | 
 autovacuum_vacuum_max_threshold      | 100000000  | 
 autovacuum_vacuum_scale_factor       | 0.2        | 
 autovacuum_vacuum_threshold          | 50         | 
 vacuum_failsafe_age                  | 1600000000 | 
 vacuum_freeze_min_age                | 50000000   | 
 vacuum_freeze_table_age              | 150000000  | 
 vacuum_max_eager_freeze_failure_rate | 0.03       | 
(9 rows)
SELECT pg_current_xact_id() AS current_xid,
       c.relfrozenxid, age(c.relfrozenxid) AS table_xid_age,
       (SELECT age(datfrozenxid) FROM pg_database WHERE datname = current_database()) AS db_xid_age
FROM pg_class c WHERE c.relname = 'freeze_demo';
 current_xid | relfrozenxid | table_xid_age | db_xid_age 
-------------+--------------+---------------+------------
        1786 |          784 |          1002 |       1041
(1 row)

SELECT min(xmin::text::bigint) AS rows_xmin, max(age(xmin)) AS rows_xmin_age FROM freeze_demo;
 rows_xmin | rows_xmin_age 
-----------+---------------
       785 |          1002
(1 row)

SELECT lp, t_xmin, f.combined_flags, f.raw_flags FROM heap_page_items(get_raw_page('freeze_demo', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp LIMIT 2;
 lp | t_xmin | combined_flags |              raw_flags               
----+--------+----------------+--------------------------------------
  1 |    785 | {}             | {HEAP_HASVARWIDTH,HEAP_XMAX_INVALID}
  2 |    785 | {}             | {HEAP_HASVARWIDTH,HEAP_XMAX_INVALID}
(2 rows)

SELECT * FROM pg_visibility_map_summary('freeze_demo');
 all_visible | all_frozen 
-------------+------------
           0 |          0
(1 row)

SELECT pg_sleep(1);
 pg_sleep 
----------
 
(1 row)

--- 5.3 VACUUM (FREEZE): xmin у версий остаётся прежним, но помечен замороженным
VACUUM (FREEZE) freeze_demo;
VACUUM
SELECT c.relfrozenxid, age(c.relfrozenxid) AS table_xid_age
FROM pg_class c WHERE c.relname = 'freeze_demo';
 relfrozenxid | table_xid_age 
--------------+---------------
         1787 |             0
(1 row)

SELECT lp, t_xmin, f.combined_flags, f.raw_flags FROM heap_page_items(get_raw_page('freeze_demo', 0)) h, LATERAL heap_tuple_infomask_flags(h.t_infomask, h.t_infomask2) f ORDER BY lp LIMIT 2;
 lp | t_xmin |   combined_flags   |                                 raw_flags                                  
----+--------+--------------------+----------------------------------------------------------------------------
  1 |    785 | {HEAP_XMIN_FROZEN} | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
  2 |    785 | {HEAP_XMIN_FROZEN} | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}
(2 rows)

SELECT * FROM pg_visibility_map_summary('freeze_demo');
 all_visible | all_frozen 
-------------+------------
           6 |          6
(1 row)

--- 5.4 возраст базы и запас до принудительного автовакуума против wraparound
SELECT datname, age(datfrozenxid) AS xid_age,
       current_setting('autovacuum_freeze_max_age')::bigint - age(datfrozenxid) AS left_to_forced_autovacuum
FROM pg_database WHERE datname = current_database();
 datname | xid_age | left_to_forced_autovacuum 
---------+---------+---------------------------
 mvcc    |    1042 |                 199998958
(1 row)

После тысячи пустых транзакций возраст таблицы 1002. VACUUM (FREEZE) передвинул relfrozenxid на 1787, xmin у версий остался 785, а флаг HEAP_XMIN_FROZEN складывается из битов HEAP_XMIN_COMMITTED и HEAP_XMIN_INVALID. Без паузы в секунду перед VACUUM на прогоне версии замораживались, но страницы в карте видимости не отмечались, причину по исходному коду мы не проверяли.

 

Заключение

MVCC меняет блокировки на версии. Читатели и писатели не ждут друг друга, а платой становятся мёртвые версии, которые убирает VACUUM, и его работа упирается в горизонт самой старой открытой транзакции. Поэтому MVCC в PostgreSQL на практике сводится к коротким транзакциям, HOT-обновлениям и наблюдению за возрастом XID. Та же модель версий работает в Greenplum, её эксплуатацию разбирают на курсе по Greenplum для инженеров данных.

 

Референсные ссылки