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
Что означают эти поля:
| Столбец | Смысл |
xmin | id транзакции, которая создала эту версию строки (вставкой или как результат UPDATE) |
xmax | id транзакции, которая удалила/заменила эту версию; 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 и карта видимости снижают цену многоверсионности, но старые версии всё равно надо потом убирать.