После UPDATE одной строки в странице лежат две версии: старая с заполненным t_xmax и новая с t_xmin той же транзакции. Какую из них увидит чужой SELECT, решает снимок транзакции и цепочка проверок в HeapTupleSatisfiesMVCC. Поведение уровней изоляции описано в статье про изоляцию PostgreSQL, здесь разбирается, как оно устроено.
Снимок 758:760:758 и три версии строки
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, 759 | false | завершились до снимка; зафиксирована или откатилась, скажет журнал фиксации (clog) |
| 758 | true: в xip | транзакция A, для B она ещё идёт |
| 760 и больше | true: не меньше xmax | начались после снимка |
HeapTupleSatisfiesMVCC на четырёх кортежах
Функция проверяет сначала создателя версии (xmin), потом удалителя (xmax):
- Создатель. Если подсказки
HEAP_XMIN_COMMITTEDнет, первым спрашивается снимок:xminв нём, значит версия не видна, и до журнала фиксации дело не доходит (строки 1063–1064). С подсказкой проверяется только снимок. - Удалитель.
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:
lp | xmin / xmax | t_infomask | какая проверка решает | видна B |
|---|---|---|---|---|
| 1 | 756 / 757 | 0500: оба бита «зафиксирована» | 756 меньше xmin снимка, дальше; xmax 757 тоже меньше 758, не в снимке, значит удаление зафиксировано до снимка | нет |
| 2 | 756 / 0 | 0900: xmin зафиксирован, xmax невалиден | xmin 756 вне снимка, xmax невалиден: строки 1086–1087, return true | да, 700 |
| 3 | 757 / 758 | 2100: xmin зафиксирован | xmin 757 вне снимка; подсказки для xmax нет, 758 в xip: строки 1129–1130, return true | да, 450 |
| 4 | 758 / 0 | 2800: 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у всех новых снимков и тем больше версий нельзя убрать.
Чек-лист для разбора видимости
- Выведи
heap_page_itemsдля нужной страницы: сравниt_xmin,t_xmaxиt_ctid, цепочка версий кончается там, гдеt_ctidуказывает сам на себя. - Снимок транзакции читай как
xmin:xmax:xipчерезpg_current_snapshot(): xid меньшеxminзавершены, начиная сxmaxещё не виден, между ними решает списокxip. - Для каждой версии пройди порядок: сначала
xminотносительно снимка, потомxmax, подсказки вt_infomaskтолько ускоряют ответ. - Состояние транзакции по номеру проверяй
pg_xact_status('<xid>'): он различаетin progress,committedиaborted, но не подскажет, видит ли её твой снимок. - Убери контейнер:
docker rm -f dba-mvcc-postgres-internals.