MVCC изнутри: версии строк, xmin/xmax

В PostgreSQL изменение строки не переписывает её на месте, а создаёт новую физическую версию — и за видимостью версий стоят два скрытых столбца, xmin и xmax.

MVCC в PostgreSQL — это многоверсионность, где каждая версия строки (кортеж, tuple) физически отдельна и помечена номерами транзакций в системных полях xmin и xmax. Транзакция видит ту версию, которую разрешает её снимок (snapshot).

Общую идею MVCC — «чтение не ждёт запись» — мы уже разбирали на уровне концепции. Этот урок про реализацию именно в PostgreSQL: где физически лежат версии, как сервер по двум числам решает, видеть ли вам строку, и почему «обновление» строки на самом деле порождает её копию. Понимание этого механизма объясняет половину «странностей» PostgreSQL — от раздувания таблиц до того, почему длинный SELECT не мешает параллельным UPDATE.

Зачем это на практике

Без модели xmin/xmax многое выглядит магией: почему UPDATE одной строки замедляет таблицу со временем; почему count(*) бывает не мгновенным; почему длинная транзакция «держит» старые данные. Когда вы понимаете, что каждая версия — это отдельный кортеж с метками транзакций, становятся очевидны и причины bloat, и роль VACUUM, и поведение уровней изоляции. Это база, на которой стоят следующие три урока раздела.

Строка — это не одна запись, а цепочка версий

В PostgreSQL у каждого кортежа есть служебный заголовок с системными столбцами. Их можно увидеть, явно перечислив в SELECT: они не показываются по *, но всегда существуют.

SELECT xmin, xmax, ctid, id, balance FROM accounts WHERE id = 1;

 xmin | xmax | ctid  | id | balance
------+------+-------+----+---------
  742 |    0 | (0,1) |  1 |     500

Что означают эти поля:

СтолбецСмысл
xminid транзакции, которая создала эту версию строки (вставкой или как результат UPDATE)
xmaxid транзакции, которая удалила/заменила эту версию; 0 — версия ещё «живая»
ctidфизический адрес версии: (номер страницы, номер строки в странице)

Теперь сделаем UPDATE и посмотрим, что произошло физически. В PostgreSQL UPDATE — это «пометить старую версию удалённой + вставить новую».

-- транзакция 743 меняет баланс
UPDATE accounts SET balance = 400 WHERE id = 1;

-- что стало с версиями (концептуально):
 xmin | xmax | ctid  | balance
------+------+-------+--------
  742 |  743 | (0,1) |    500   <- старая версия: помечена удалённой транзакцией 743
  743 |    0 | (0,2) |    400   <- новая версия: создана транзакцией 743, ещё живая

Старая версия не стирается. Она остаётся на странице с проставленным xmax = 743 — как «надгробие». Новая версия живёт по другому физическому адресу (ctid изменился). Поэтому в PostgreSQL UPDATE по затратам ближе к INSERT, чем к «правке на месте».

Снимок: как сервер решает, что вам видно

Когда транзакция начинает читать, PostgreSQL фиксирует снимок (snapshot) — слепок того, какие транзакции на этот момент уже зафиксированы, какие ещё идут, и какой номер получит следующая. Видимость каждой версии строки сервер вычисляет по простому правилу, сверяя xmin/xmax со снимком:

  • версия видна, если транзакция в её xmin уже зафиксирована к моменту снимка (строка «уже существовала»),
  • и её xmax либо 0, либо принадлежит транзакции, которая ещё не зафиксирована (строка «ещё не удалена» с точки зрения снимка).

Отсюда вся согласованность чтения. Транзакция, начавшая работу до коммита 743, видит старую версию (balance = 500): для её снимка транзакция 743 ещё «не случилась», значит xmax = 743 игнорируется, а новая версия с xmin = 743 не видна. Транзакция, начавшаяся после, видит новую (balance = 400). Обе работают одновременно и не мешают друг другу — потому что читают разные физические версии одной логической строки.

txid и счётчик транзакций

Номер транзакции (xid) выдаётся монотонно. Текущий можно посмотреть так:

SELECT pg_current_xact_id();   -- идентификатор текущей транзакции (PG 13+)
SELECT txid_current();         -- то же в старых версиях

Именно сравнение этих номеров со снимком и есть «движок» видимости. Никаких блокировок для чтения при этом не берётся — поэтому SELECT почти никогда не ждёт.

Почему читатели не блокируют писателей

В системах без MVCC писатель ставит замок, читатель ждёт (или наоборот). В PostgreSQL читателю замок на строку не нужен: ему достаточно найти подходящую по снимку версию кортежа, а она существует независимо от того, создаёт ли кто-то рядом новую. Писатель тоже не ждёт читателей — он просто добавляет новую версию. Конкурируют между собой только два писателя одной строки: второй UPDATE подождёт коммита первого (об этом — в уроке про блокировки). Итог: «много читающих + пишущие» — бесконфликтный сценарий, и это прямое следствие хранения версий.

Как это работает под капотом

Версии строк лежат в страницах по 8 КБ. Внутри страницы каждая версия — это элемент с собственным ctid. Когда UPDATE создаёт новую версию на той же странице и обновляются только неиндексированные столбцы, срабатывает оптимизация HOT (Heap-Only Tuple): новая версия не требует добавления записей во все индексы, а связывается со старой цепочкой прямо в куче. Это снижает нагрузку на индексы и замедляет раздувание. Если же места на странице нет или менялся индексированный столбец, новая версия уезжает на другую страницу, и индексы приходится обновлять. Видимость версий, помимо xmin/xmax, ускоряет карта видимости (visibility map): для страниц, где все версии видны всем, PostgreSQL может пропускать обращение к куче (это основа index-only scan).

Частые ошибки

  • Считать, что UPDATE «правит на месте». В PostgreSQL это всегда новая версия плюс надгробие старой — отсюда рост таблицы при частых обновлениях.
  • Ожидать, что count(*) мгновенный. Из-за MVCC точное число живых строк нельзя взять из одного счётчика — версии надо проверить на видимость; на больших таблицах это стоит времени.
  • Держать долгую транзакцию открытой. Её снимок заставляет сервер сохранять старые версии «на всякий случай» — они не могут быть переиспользованы, пока жив этот снимок.
  • Путать ctid с первичным ключом. ctid — физический адрес версии, он меняется при каждом UPDATE и после VACUUM FULL; на него нельзя ссылаться как на стабильный идентификатор.

Итоги

  • В PostgreSQL логическая строка — это цепочка физических версий (кортежей); UPDATE создаёт новую версию, старую помечает удалённой.
  • Скрытые столбцы xmin (создавшая транзакция) и xmax (удалившая транзакция) плюс снимок определяют, какую версию увидит транзакция.
  • Версия видна, если её xmin уже зафиксирован к моменту снимка, а xmax равен 0 или принадлежит ещё не зафиксированной транзакции.
  • Читатели не берут замков и не блокируют писателей; конкурируют лишь два писателя одной строки.
  • Оптимизация HOT и карта видимости снижают цену многоверсионности, но старые версии всё равно надо потом убирать.
Проверьте себя
1. Что хранят скрытые столбцы xmin и xmax кортежа в PostgreSQL?
AМинимальное и максимальное значение строки в индексе
Bid транзакции, создавшей версию (xmin), и id транзакции, удалившей/заменившей её (xmax)
CВремя создания и время последнего чтения строки
DРазмер строки в байтах до и после сжатия
2. Что физически происходит при UPDATE одной строки в PostgreSQL?
AЗначение перезаписывается на месте, старое сразу стирается
BСоздаётся новая версия строки, а старая помечается удалённой (проставляется её xmax)
CВся таблица блокируется до конца транзакции
DИзменение записывается только в индекс, куча не трогается
3. Почему в PostgreSQL длинный SELECT обычно не блокирует параллельные UPDATE?
ASELECT автоматически повышает уровень изоляции до SERIALIZABLE
BЧитатель находит подходящую по снимку версию строки и не берёт на неё блокировку, а писатель просто добавляет новую версию
CPostgreSQL запрещает UPDATE, пока идёт любой SELECT
DSELECT копирует всю таблицу в память и работает с копией