В 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
Каждый
23 байта заголовка перед самими данными колонок. xmin/xmax — основные MVCC-поля, cmin/cmax — для разрешения видимости внутри одной транзакции.
Семантика каждого поля:
- xmin —
transaction idтранзакции, которая создала эту версию (черезINSERT,UPDATEилиCOPY). Никогда не меняется после записи. - xmax —
transaction 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
Теперь правила:
INSERT: создаётся новый tuple,xmin = txid_current(),xmax = 0.DELETE: не удаляет ничего физически. Только проставляетxmax = txid_current()у существующего кортежа. Данные остаются на странице — это dead tuple.UPDATE: эквивалентенDELETE + INSERT. У старого кортежа проставляетсяxmax = txid_current(); рядом создаётся новый кортеж сxmin = txid_current()иxmax = 0.
Слева направо: INSERT в T100, потом UPDATE в T200 создаёт версию v2, потом DELETE в T300 помечает v2 как мёртвую. На диске остаются все три состояния, пока VACUUM их не уберёт.
Заметь: после трёх операций над одной логической строкой на странице лежат три кортежа. И пока не пройдёт VACUUM, они занимают место — даже если ни одна транзакция их больше не увидит.
Системные колонки
Postgres даёт прочитать xmin и xmax любой строки — это псевдоколонки, как ctid. Их не видно в SELECT *, но можно явно перечислить:
Первые 5 customers с их xmin/xmax. У свежевставленных строк xmax = 0, а xmin = id транзакции, которая загрузила seed-данные.
Все строки имеют одинаковый xmin — потому что они вставились в одной транзакции (через INSERT INTO ... generate_series). xmax = 0 означает «жив, никто не пометил на удаление».
Теперь сделаем UPDATE одной строки и посмотрим, что произошло:
UPDATE одной строки: смотрим, как меняются xmin/xmax и ctid. Старый кортеж получает xmax = моя транзакция, новый — fresh xmin.
Видишь — xmin после UPDATE изменился (это уже новая версия), xmax снова 0 (никто её ещё не удалял), а ctid сдвинулся (новая запись лежит в новом месте). Старая версия с xmax = <id моей транзакции> всё ещё на странице, просто не видна — она dead для всех будущих snapshot’ов.
Где взять txid: txid_current и age
xmin/xmax — это 32-битные числа, и есть служебные функции:
Сравнение xmin текущей строки и id моей транзакции. Если я только что её вставил/обновил — должны совпасть.
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.
Чек-лист
- В 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).
- ROLLBACK не убирает кортеж с диска — он остаётся, помеченный как невалидный xmin.