Learning Platform
Урок 05.01 · 24 мин
Продвинутый
MVCCxminxmaxtuple headertransactioninfomask

В Fundamentals мы говорили: UPDATE меняет строку. Это полезная абстракция, но физически неверно. PostgreSQL никогда не меняет существующий heap tuple в месте — он пишет новую версию рядом и помечает старую как мёртвую. Это и есть MVCC: Multi-Version Concurrency Control.

В этом уроке разбираем основу основ — как версии помечаются на уровне одного кортежа.

Зачем нужен MVCC

Представь две сессии, работающие одновременно:

  • T1 делает SELECT total_cents FROM orders WHERE id = 42 (читает).
  • T2 делает UPDATE orders SET total_cents = total_cents + 100 WHERE id = 42 (пишет).

В классической lock-based базе (DB2, до недавнего времени MySQL MyISAM) T1 берёт shared lock, T2 ждёт; либо T2 берёт exclusive lock, T1 ждёт. Высокий уровень одновременных запросов = очередь на блокировках.

MVCC решает это иначе: читатели не блокируют писателей, писатели не блокируют читателей. T1 видит «старое» значение total_cents = 500, потому что её snapshot был сделан до UPDATE. T2 пишет «новое» значение 600 в новый кортеж. Обе работают параллельно без ожидания.

Цена этого подхода — нужно хранить несколько версий одной логической строки и уметь решать, какая версия видна каждой транзакции. Эту задачу решают четыре поля в заголовке каждого heap tuple.

Tuple header: xmin, xmax, cmin, cmax

Каждый

heap tuple
в PostgreSQL начинается с 23-байтного заголовка, и первые четыре числа в нём — это MVCC-метки:

HeapTupleHeader (упрощённо)

23 байта заголовка перед самими данными колонок. xmin/xmax — основные MVCC-поля, cmin/cmax — для разрешения видимости внутри одной транзакции.

HeapTupleHeader23 байта
xmin4 байта — txid создателя
xmax4 байта — txid удалившего
cmin/cmax/xvac4 байта (union)
ctid6 байт — self-pointer
infomask + infomask24 байта — флаги
t_hoff1 байт
данные колонокrow data + null bitmap + alignment padding

Семантика каждого поля:

  • xmintransaction id транзакции, которая создала эту версию (через INSERT, UPDATE или COPY). Никогда не меняется после записи.
  • xmaxtransaction id транзакции, которая удалила или обновила эту версию. Если xmax = 0 (InvalidTransactionId) — версия пока что «жива».
  • cmin / cmax — command id внутри транзакции. Нужны, чтобы внутри одной транзакции отличать «то, что я уже видел в SELECT» от «то, что я только что вставил» в одном и том же statement. В обычной БД на эти поля смотреть почти не приходится.
  • infomask — битовая маска флагов: HEAP_XMIN_COMMITTED, HEAP_XMAX_COMMITTED, HEAP_XMIN_INVALID, HEAP_HOT_UPDATED, и т.д. Эти биты кэшируют статус транзакций, чтобы не лезть каждый раз в pg_xact (см. ниже).

INSERT / UPDATE / DELETE в терминах xmin/xmax

Теперь правила:

  1. INSERT: создаётся новый tuple, xmin = txid_current(), xmax = 0.
  2. DELETE: не удаляет ничего физически. Только проставляет xmax = txid_current() у существующего кортежа. Данные остаются на странице — это dead tuple.
  3. UPDATE: эквивалентен DELETE + INSERT. У старого кортежа проставляется xmax = txid_current(); рядом создаётся новый кортеж с xmin = txid_current() и xmax = 0.
Жизненный цикл одной логической строки

Слева направо: INSERT в T100, потом UPDATE в T200 создаёт версию v2, потом DELETE в T300 помечает v2 как мёртвую. На диске остаются все три состояния, пока VACUUM их не уберёт.

v1 (после INSERT в T100)xmin=100, xmax=0
данные(id=42, amount=500)
v1 (после UPDATE в T200)xmin=100, xmax=200
v2 (новая в T200)xmin=200, xmax=0
v1 (всё ещё лежит)xmin=100, xmax=200
v2 (после DELETE в T300)xmin=200, xmax=300
итогна странице 3 версии — две dead (xmax committed), одна не существовала до VACUUM

Заметь: после трёх операций над одной логической строкой на странице лежат три кортежа. И пока не пройдёт VACUUM, они занимают место — даже если ни одна транзакция их больше не увидит.

Системные колонки

Postgres даёт прочитать xmin и xmax любой строки — это псевдоколонки, как ctid. Их не видно в SELECT *, но можно явно перечислить:

Первые 5 customers с их xmin/xmax. У свежевставленных строк xmax = 0, а xmin = id транзакции, которая загрузила seed-данные.

PostgreSQL

Все строки имеют одинаковый xmin — потому что они вставились в одной транзакции (через INSERT INTO ... generate_series). xmax = 0 означает «жив, никто не пометил на удаление».

Теперь сделаем UPDATE одной строки и посмотрим, что произошло:

UPDATE одной строки: смотрим, как меняются xmin/xmax и ctid. Старый кортеж получает xmax = моя транзакция, новый — fresh xmin.

PostgreSQL

Видишь — xmin после UPDATE изменился (это уже новая версия), xmax снова 0 (никто её ещё не удалял), а ctid сдвинулся (новая запись лежит в новом месте). Старая версия с xmax = <id моей транзакции> всё ещё на странице, просто не видна — она dead для всех будущих snapshot’ов.

Где взять txid: txid_current и age

xmin/xmax — это 32-битные числа, и есть служебные функции:

Сравнение xmin текущей строки и id моей транзакции. Если я только что её вставил/обновил — должны совпасть.

PostgreSQL

txid_current() возвращает 64-битный extended xid (с epoch’ом для wraparound, но в нашей маленькой БД это просто число). При сравнении с xmin важно: ROLLBACK не откатывает изменения с диска. Кортеж с xmin = my_txid останется лежать, но его infomask отметится как HEAP_XMIN_INVALID — и snapshot’ы будут видеть, что транзакция «не закоммитилась».

infomask и pg_xact: где живёт статус транзакции

Раз xmin/xmax — это просто номер, как узнать, закоммитилась ли эта транзакция, аборт’нулась, или ещё в процессе? Postgres хранит статус каждой транзакции в файле pg_xact (раньше — pg_clog): 2 бита на транзакцию (IN_PROGRESS, COMMITTED, ABORTED, SUB_COMMITTED).

Но лезть в pg_xact для каждого читаемого кортежа — дорого. Поэтому при первом успешном решении (статус известен) Postgres проставляет в infomask кэширующие биты прямо в tuple:

  • HEAP_XMIN_COMMITTED — мы уже проверяли, xmin закоммитился; в следующий раз не лезем в pg_xact.
  • HEAP_XMIN_INVALID — xmin аборт’нулся.
  • Аналогично для XMAX_COMMITTED / XMAX_INVALID.

Это объясняет важный эффект: первое чтение страницы после большой транзакции записывает саму страницу (hint bits) — даже если кажется, что мы только читаем. Это называется hint bit update и иногда виден как неожиданная I/O-активность после batch INSERT.

Нюансы: транзакция ≠ statement

Часто путают:

  • xid (transaction id) — у одной транзакции. Постоянен от BEGIN до COMMIT/ROLLBACK.
  • cid (command id) — у одного statement’а внутри транзакции. Растёт после каждой команды.

Внутри одной транзакции cmin/cmax нужны, чтобы корректно работала такая ситуация:

BEGIN;
INSERT INTO t VALUES (1);   -- cmin = 0
SELECT * FROM t WHERE ...;  -- видит свой insert (cmin = 0 < cur_cid = 1)
UPDATE t SET x = 2 WHERE ...;
SELECT * FROM t WHERE ...;
COMMIT;

Если бы не было cmin/cmax, внутри транзакции мы могли бы либо «видеть себя» неконсистентно (например, в одном statement обработать одну и ту же строку дважды), либо вообще не видеть только что вставленных строк. Это тонкости spec’и SQL для уровней изоляции, на которые в обычной разработке ты редко смотришь, но в curl-edge-кейсах (триггеры, savepoint’ы) cmin/cmax играют свою роль.

В следующем уроке посмотрим, как транзакция решает, какой кортеж ей виден, — введём понятие snapshot.

Проверка знанийKnowledge check
После последовательности INSERT (T100) → UPDATE (T200) → UPDATE (T300) → DELETE (T400) одной логической строки, сколько кортежей физически лежит на heap-страницах до VACUUM? Какие у них xmin/xmax?
ОтветAnswer
Три кортежа: v1 [xmin=100, xmax=200], v2 [xmin=200, xmax=300], v3 [xmin=300, xmax=400]. Каждый UPDATE — это INSERT нового кортежа и проставление xmax на старом. Финальный DELETE проставляет xmax на самой свежей версии. Все три — dead tuples для будущих snapshot'ов, но физически остаются на странице до VACUUM. Это и есть write amplification, которую мы будем пытаться уменьшить через HOT updates в уроке 4.

Чек-лист

  • В PostgreSQL никто не меняет heap tuple в месте. Любое изменение — это создание новой версии.
  • Каждый tuple имеет 4 MVCC-поля: xmin (создатель), xmax (удалитель/обновитель), cmin/cmax (command id внутри транзакции).
  • INSERT ставит xmin = my_txid, xmax = 0. DELETE ставит xmax = my_txid у существующего. UPDATE = DELETE старого + INSERT нового.
  • xmin/xmax/ctid доступны как псевдоколонки в любой таблице.
  • infomask кэширует статус транзакций (committed / aborted), чтобы не читать pg_xact повторно. Первое чтение после транзакции может писать на диск (hint bits).
ACID на пальцах: что именно обещает СУБД Синхронизация: race conditions, mutex, atomic
  • ROLLBACK не убирает кортеж с диска — он остаётся, помеченный как невалидный xmin.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что физически происходит в heap-файле при выполнении UPDATE одной строки в PostgreSQL?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5