Справочник DBA: базы данных изнутри читается по порядку

Статья 2.1 · раздел 2 «Транзакции и MVCC» · 8 из 109

MVCC в PostgreSQL: версии строк, снимок и HeapTupleSatisfiesMVCC

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18; расширение pageinspect; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

После UPDATE одной строки в странице лежат две версии: старая с заполненным t_xmax и новая с t_xmin той же транзакции. Какую из них увидит чужой SELECT, решает снимок транзакции и цепочка проверок в HeapTupleSatisfiesMVCC. Поведение уровней изоляции описано в статье про изоляцию PostgreSQL, здесь разбирается, как оно устроено.

DBA / 01

Снимок 758:760:758 и три версии строки

Снимок 758:760:758 и три версии строкиОсь транзакций 756, 757, 758, 759, 760. Транзакции 756, 757 и 759 зафиксированы, 758 идёт, 760 ещё не было. Снимок сессии B охватывает от 758 до 760, в списке идущих только 758. Ниже три версии строки id 1. Версия lp 1 с balance 500, xmin 756 и xmax 757 не видна: удалена до снимка. Версия lp 3 с balance 450, xmin 757 и xmax 758 видна: удаляющая транзакция 758 в списке идущих. Версия lp 4 с balance 400 и xmin 758 не видна: создавшая её транзакция 758 в списке идущих. Стрелки показывают t_ctid от старой версии к новой.756757758759760INSERTкоммитUPDATEкоммитсессия Aидёт, в xipINSERT в auditкоммитxmax снимкаещё не былоxid < 758: завершеныxid ≥ 760: не видныснимок B 758:760:758lp 1 · balance 500xmin 756 · xmax 757не виднаудалена до снимка: xmax 757 < 758t_ctid (0,3)lp 3 · balance 450xmin 757 · xmax 758виднасоздана до снимка; удалявшая 758 в xipt_ctid (0,4)lp 4 · balance 400xmin 758 · xmax 0не виднасоздавшая её 758 в xip: не законченаСтрока lp 2 (id 2, balance 700) в схему не вошла: она видна.
Сессия B с снимком 758:760:758 видит версию 450: версия 500 удалена до снимка, а версию 400 создала транзакция A, которую снимок считает незавершённой. Зелёный прямоугольник — видна, янтарные — не видны.

Опыт: две сессии и одна строка

Поднимаем PostgreSQL 18 и открываем две сессии psql: вторую команду docker exec запусти в двух окнах терминала. Первое окно — сессия A, второе — сессия B. В свежем контейнере номера xid совпадали при каждом запуске; в другом кластере они сдвинутся, а разницы между ними останутся.

docker run -d --name dba-mvcc-postgres-internals -e POSTGRES_PASSWORD=pg postgres:18
docker exec -it dba-mvcc-postgres-internals psql -U postgres

В сессии A подготовим таблицу. INSERT и UPDATE выполняются каждый в своей транзакции с автокоммитом, так что у строки id = 1 уже есть старая версия.

CREATE EXTENSION pageinspect;
CREATE TABLE accounts (id int, balance int);
CREATE TABLE audit (n int);
INSERT INTO accounts VALUES (1, 500), (2, 700);
UPDATE accounts SET balance = 450 WHERE id = 1;
SELECT pg_relation_filepath('accounts') AS file, pg_relation_size('accounts') AS bytes;
CREATE EXTENSION
CREATE TABLE
CREATE TABLE
INSERT 0 2
UPDATE 1
     file     | bytes
--------------+-------
 base/5/16430 |  8192
(1 row)

Таблица занимает одну страницу в 8 192 байта, все версии лежат в ней. Теперь в сессии A открываем транзакцию и меняем ту же строку, но не фиксируем:

BEGIN;
SELECT pg_current_xact_id();
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
BEGIN
 pg_current_xact_id
--------------------
                758
(1 row)

UPDATE 1

Транзакция A получила xid 758 и остаётся открытой. В сессии B читаем страницу напрямую через pageinspect:

SELECT lp, t_xmin, t_xmax, t_ctid, lpad(to_hex(t_infomask), 4, '0') AS infomask
FROM heap_page_items(get_raw_page('accounts', 0));
 lp | t_xmin | t_xmax | t_ctid | infomask
----+--------+--------+--------+----------
  1 |    756 |    757 | (0,3)  | 0500
  2 |    756 |      0 | (0,2)  | 0900
  3 |    757 |    758 | (0,4)  | 2100
  4 |    758 |      0 | (0,4)  | 2800
(4 rows)

Номера lp и t_ctid разобраны в статье про страницу: t_ctid — адрес в виде «страница, указатель». Здесь он связывает версии в цепочку:

  • lp = 1 — самая старая версия, 500. Её заменил UPDATE с xid 757 (t_xmax = 757), а t_ctid = (0,3) ведёт к преемнику.
  • lp = 3 — версия 450, создана транзакцией 757. Её t_xmax = 758: так сессия A отметила, что заменяет эту версию. t_ctid = (0,4) ведёт к новой.
  • lp = 4 — версия 400, t_xmin = 758: тот же xid, что вернул pg_current_xact_id() в A. t_xmax = 0, t_ctid указывает на саму себя: цепочка кончилась.
  • lp = 2 — строка id = 2, её никто не менял.

UPDATE ничего не стёр и не перезаписал. Он вставил новую версию и записал xid транзакции в t_xmax старой. Страница уже содержит версию 400 из незавершённой транзакции, поэтому «лежит в странице» не значит «видна»: это решают правила видимости.

Снимок: xmin, xmax и xip

Теперь в B сначала завершим одну транзакцию с номером больше 758, потом откроем REPEATABLE READ и прочитаем таблицу. Снимок создаётся на первом запросе:

INSERT INTO audit VALUES (1);
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts ORDER BY id;
SELECT pg_current_snapshot();
SELECT pg_xact_status('758');
INSERT 0 1
BEGIN
 id | balance
----+---------
  1 |     450
  2 |     700
(2 rows)

 pg_current_snapshot
---------------------
 758:760:758
(1 row)

 pg_xact_status
----------------
 in progress
(1 row)

B видит 450, то есть версию до A. INSERT INTO audit нужен, чтобы транзакция 759 успела завершиться до снимка. В пробном запуске без него A получила xid 757, а снимок вышел 757:757:: xid транзакции A оказался равен xmax, под правило «не меньше xmax» попала и она, и список xip остался пустым.

Снимок 758:760:758 читается как xmin:xmax:xip:

  • xmin = 758 — наименьший xid среди идущих транзакций. Это A.
  • xmax = 760 — последний завершённый xid плюс один. Завершённой оказалась транзакция 759 из audit.
  • xip = {758} — идущие транзакции между xmin и xmax.

Так их собирает GetSnapshotData. Сначала считается xmax, он же стартовое значение xmin:

	/* xmax is always latestCompletedXid + 1 */
	xmax = XidFromFullTransactionId(latest_completed);
	TransactionIdAdvance(xmax);

Затем цикл по процессам: каждый идущий xid меньше xmax попадает в xip, а наименьший становится xmin:

			if (NormalTransactionIdPrecedes(xid, xmin))
				xmin = xid;

			/* Add XID to snapshot. */
			xip[count++] = xid;

Смысл полей записан в структуре SnapshotData (snapshot.h, строки 153–154):

	TransactionId xmin;			/* all XID < xmin are visible to me */
	TransactionId xmax;			/* all XID >= xmax are invisible to me */

Идёт ли xid с точки зрения снимка, решает XidInMVCCSnapshot: границы проверяются раньше поиска в xip.

	/* Any xid < xmin is not in-progress */
	if (TransactionIdPrecedes(xid, snapshot->xmin))
		return false;
	/* Any xid >= xmax is in-progress */
	if (TransactionIdFollowsOrEquals(xid, snapshot->xmax))
		return true;

Для снимка B получается такая таблица:

xidответ XidInMVCCSnapshotчто это для B
756, 757, 759falseзавершились до снимка; зафиксирована или откатилась, скажет журнал фиксации (clog)
758true: в xipтранзакция A, для B она ещё идёт
760 и большеtrue: не меньше xmaxначались после снимка

HeapTupleSatisfiesMVCC на четырёх кортежах

Функция проверяет сначала создателя версии (xmin), потом удалителя (xmax):

  1. Создатель. Если подсказки HEAP_XMIN_COMMITTED нет, первым спрашивается снимок: xmin в нём, значит версия не видна, и до журнала фиксации дело не доходит (строки 1063–1064). С подсказкой проверяется только снимок.
  2. Удалитель. HEAP_XMAX_INVALID значит, что версию никто не удалял, и она видна. Иначе без подсказки HEAP_XMAX_COMMITTED снова первым идёт снимок: удалитель в нём, и версия видна (строки 1129–1130). Удалитель вне снимка и зафиксирован: версия не видна.

Ветка создателя целиком:

		else if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
			return false;
		else if (TransactionIdDidCommit(HeapTupleHeaderGetRawXmin(tuple)))
			SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
						HeapTupleHeaderGetRawXmin(tuple));

Проходим по строкам из опыта со снимком 758:760:758:

lpxmin / xmaxt_infomaskкакая проверка решаетвидна B
1756 / 7570500: оба бита «зафиксирована»756 меньше xmin снимка, дальше; xmax 757 тоже меньше 758, не в снимке, значит удаление зафиксировано до снимканет
2756 / 00900: xmin зафиксирован, xmax невалиденxmin 756 вне снимка, xmax невалиден: строки 1086–1087, return trueда, 700
3757 / 7582100: xmin зафиксированxmin 757 вне снимка; подсказки для xmax нет, 758 в xip: строки 1129–1130, return trueда, 450
4758 / 02800: xmax невалиденподсказки для xmin нет, 758 в xip: строки 1063–1064, return falseнет

Результат совпадает с SELECT: 450 и 700. Биты расшифрованы в htup_details.h: 0x0100 — HEAP_XMIN_COMMITTED, 0x0400 — HEAP_XMAX_COMMITTED, 0x0800 — HEAP_XMAX_INVALID, 0x2000 — HEAP_UPDATED. Подсказки на версиях 1–3 поставил сам UPDATE в A, когда искал видимую версию строки. У версии 4 xmin не помечен.

COMMIT в A ничего не меняет в странице и в снимке B

Фиксируем транзакцию A (в сессии A) и повторяем чтение в B, которая всё ещё держит REPEATABLE READ:

COMMIT;
SELECT pg_xact_status('758');
SELECT * FROM accounts ORDER BY id;
SELECT lp, t_xmin, t_xmax, t_ctid, lpad(to_hex(t_infomask), 4, '0') AS infomask
FROM heap_page_items(get_raw_page('accounts', 0));
 pg_xact_status
----------------
 committed
(1 row)

 id | balance
----+---------
  1 |     450
  2 |     700
(2 rows)

 lp | t_xmin | t_xmax | t_ctid | infomask
----+--------+--------+--------+----------
  1 |    756 |    757 | (0,3)  | 0500
  2 |    756 |      0 | (0,2)  | 0900
  3 |    757 |    758 | (0,4)  | 2100
  4 |    758 |      0 | (0,4)  | 2800
(4 rows)

Журнал фиксации уже говорит «committed», а B по-прежнему видит 450. Снимок не обращается за новостями: список xip = {758} скопирован в память B на первом запросе, и 758 в нём остаётся «идущей» до конца транзакции B. Те же проверки дают тот же ответ.

Ещё одно следствие видно в infomask: значения не изменились. У версии 4 нет HEAP_XMIN_COMMITTED, у версии 3 нет HEAP_XMAX_COMMITTED, хотя транзакция 758 зафиксирована. Причина в порядке проверок: вопрос к снимку (строки 1063 и 1129) задаётся раньше вопроса к журналу фиксации, и функция возвращает ответ до TransactionIdDidCommit, где выставляется подсказка. Подсказка появится, когда версию прочитает снимок, для которого 758 уже завершена. Закрываем транзакцию B и читаем новым снимком:

COMMIT;
SELECT * FROM accounts ORDER BY id;
SELECT pg_current_snapshot();
SELECT lp, t_xmin, t_xmax, t_ctid, lpad(to_hex(t_infomask), 4, '0') AS infomask
FROM heap_page_items(get_raw_page('accounts', 0));
SELECT lp, (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('accounts', 0)) WHERE lp IN (3, 4);
COMMIT
 id | balance
----+---------
  1 |     400
  2 |     700
(2 rows)

 pg_current_snapshot
---------------------
 771:771:
(1 row)

 lp | t_xmin | t_xmax | t_ctid | infomask
----+--------+--------+--------+----------
  1 |    756 |    757 | (0,3)  | 0500
  2 |    756 |      0 | (0,2)  | 0900
  3 |    757 |    758 | (0,4)  | 2500
  4 |    758 |      0 | (0,4)  | 2900
(4 rows)

 lp |                                        raw_flags
----+-----------------------------------------------------------------------------------------
  3 | {HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_UPDATED,HEAP_HOT_UPDATED,HEAP_ONLY_TUPLE}
  4 | {HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_UPDATED,HEAP_ONLY_TUPLE}
(2 rows)

Простой SELECT изменил страницу: у lp = 3 2100 стало 2500 (добавился 0x0400, HEAP_XMAX_COMMITTED), у lp = 4 2800 стало 2900 (добавился 0x0100, HEAP_XMIN_COMMITTED). Новый снимок 771:771: не содержит идущих транзакций, 758 меньше его xmin. Функция спросила журнал фиксации, получила «зафиксирована», записала подсказку и вернула: версия 3 не видна, версия 4 видна, результат 400.

Номер 771 вместо ожидаемых 760: пока шёл опыт, autovacuum проанализировал каталожные таблицы в template1 и потратил xid 760–770. Это видно по pg_statistic в этой базе. Если шаги выполнить в первую минуту после запуска контейнера, xmax в B будет 760, как в схеме; позже номера уйдут вперёд, а логика останется той же.

Что из этого следует

  • Старая версия живёт, пока её видит хоть один снимок. В сессии A версия 450 нужна снимку B до его конца, хотя UPDATE давно закончился. Какие версии можно убрать, решает VACUUM по самому старому снимку; про это — VACUUM и autovacuum.
  • Читатель не ждёт писателя, потому что читает другую версию. SELECT в B вернул 450, пока транзакция A с версией 400 была открыта.
  • SELECT может менять страницу. Подсказки HEAP_XMIN_COMMITTED и HEAP_XMAX_COMMITTED ставит читатель: в опыте обычный SELECT изменил t_infomask двух версий.
  • Идущая транзакция удерживает xmin снимка. Чем дольше A открыта, тем меньше xmin у всех новых снимков и тем больше версий нельзя убрать.

Чек-лист для разбора видимости

  1. Выведи heap_page_items для нужной страницы: сравни t_xmin, t_xmax и t_ctid, цепочка версий кончается там, где t_ctid указывает сам на себя.
  2. Снимок транзакции читай как xmin:xmax:xip через pg_current_snapshot(): xid меньше xmin завершены, начиная с xmax ещё не виден, между ними решает список xip.
  3. Для каждой версии пройди порядок: сначала xmin относительно снимка, потом xmax, подсказки в t_infomask только ускоряют ответ.
  4. Состояние транзакции по номеру проверяй pg_xact_status('<xid>'): он различает in progress, committed и aborted, но не подскажет, видит ли её твой снимок.
  5. Убери контейнер: docker rm -f dba-mvcc-postgres-internals.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru