PostgreSQL CookbookТранзакцииМентальная модель MVCC
0 / 63 (0%)

Ментальная модель MVCC

В Brew готовят отчёт по выручке — длинный аналитический SELECT, который читает заказы за месяц несколько секунд. Ровно в этот момент менеджер через админку поднимает цену капучино. Вопрос, от которого зависит, доверяешь ты своей БД или нет: что увидит отчёт — старую цену, новую или, не дай бог, половину строк по-старому, половину по-новому? И почему отчёт при этом не встал колом, заблокированный чужим UPDATE?

Ответ на оба вопроса — один механизм: MVCC, multiversion concurrency control. Это не настройка и не фича, которую включают, — это то, как Postgres вообще хранит строки и решает, кому какую версию показать. Понимать его нужно не админу, а тебе: на нём стоят транзакции, изоляция, блокировки и весь модуль 05. Здесь — самая суть, на двух наблюдаемых эффектах.

Это escape-hatch-юнит: нам нужны системные колонки (ctid, xmin, xmax) и две живые транзакции рядом. sqlc тут не помощник — урок ведётся psql-скриптами напрямую. Это первый такой юнит в курсе, и он задаёт конвенцию: когда уроку нужен интерактив, системные колонки или конкурентные сессии — пишем .sql под psql, а не query.sql под кодоген.

Снимок вместо блокировки

Наивная модель строки — «ячейка, в которую пишут новое значение поверх старого». В Postgres это не так. Строка физически — это версия (tuple), и у неё есть скрытые системные колонки:

  • xmin — id транзакции, которая эту версию создала;
  • xmax — id транзакции, которая эту версию сменила или удалила (0, пока версия живая);
  • ctid — физический адрес версии: (номер_страницы, смещение).

UPDATE не трогает старую версию на месте. Он помечает её мёртвой (ставит xmax) и дописывает новую версию строки — с новым ctid и новым xmin. Старая версия ещё какое-то время лежит на странице как «мёртвый кортеж», пока её не уберёт VACUUM.

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

Что показывает наш код

Урок — в двух psql-скриптах. Первый, demo.sql, показывает механику в одной транзакции: что UPDATE физически делает с версией строки. Чтобы ctid/xmin были чистыми (не запачканными прошлыми транзакциями над базовыми таблицами), работаем на отдельном лабораторном столе mvcc_lab, который в конце дропаем:

sql
INSERT INTO mvcc_lab VALUES (2, 450);
 
BEGIN;
  CREATE TEMP TABLE _before AS SELECT ctid AS c, xmin AS x FROM mvcc_lab WHERE id = 2;
  UPDATE mvcc_lab SET price = price + 50 WHERE id = 2;   -- новая версия, не перезапись
  CREATE TEMP TABLE _after  AS SELECT ctid AS c, xmin AS x FROM mvcc_lab WHERE id = 2;
  SELECT (b.c <> a.c) AS ctid_changed, (b.x <> a.x) AS xmin_changed FROM _before b, _after a;
COMMIT;

ctid_changed и xmin_changed оба t — после UPDATE строка с тем же id=2 лежит уже по другому физическому адресу и числится за другой (текущей) транзакцией. Это и есть «новая версия вместо перезаписи», увиденное руками.

Второй сюжет — снимок между двумя транзакциями. session-a.sql (читатель под REPEATABLE READ) и session-b.sql (писатель) запускаются в двух терминалах. A берёт снимок, B меняет цену и коммитит, A читает второй раз — в той же транзакции:

sql
-- session-a.sql
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT base_price, xmin FROM drinks WHERE id = 2;   -- A1
\prompt '...запусти session-b, потом Enter...' _
SELECT base_price, xmin FROM drinks WHERE id = 2;   -- A2: тот же снимок
COMMIT;
SELECT base_price, xmin FROM drinks WHERE id = 2;   -- A3: новый снимок

Подсказка \prompt держит транзакцию A открытой, пока ты не вернёшься, — поэтому порядок шагов задан жёстко, без гонки. REPEATABLE READ фиксирует снимок на всю транзакцию: A2 обязан показать то же, что A1.

Запуск

Подними песочницу и накати схему Brew:

sh
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-02-mvcc-mental-model T=db-reset

Механика в одной транзакции (make run — основной демо этого юнита):

sh
make lecture L=05-transactions-and-mvcc/05-02-mvcc-mental-model
plaintext
── Свежая строка: одна версия, ctid = физический адрес ───────────────
 id | price | ctid  | superseded 
----+-------+-------+------------
  2 |   450 | (0,1) | f
(1 row)
 
 
── UPDATE написал НОВУЮ версию строки (внутри той же транзакции) ──────
 ctid_changed | xmin_changed 
--------------+--------------
 t            | t
(1 row)
 
 
── Итог: одна логическая строка id=2 — но уже вторая физическая версия 
 id | price | ctid  | superseded 
----+-------+-------+------------
  2 |   500 | (0,2) | f
(1 row)

Теперь две сессии. В первом терминале запусти читателя — он остановится на подсказке:

sh
make lecture L=05-transactions-and-mvcc/05-02-mvcc-mental-model T=session-a

Во втором терминале — писателя; он отработает целиком:

sh
make lecture L=05-transactions-and-mvcc/05-02-mvcc-mental-model T=session-b

Вернись в первый терминал и нажми Enter. Сложенные вместе, шаги дают такую картину (xmin — id транзакции, у тебя числа будут другие; важны их отношения):

plaintext
Терминал A (читатель, REPEATABLE READ)        Терминал B (писатель)
─────────────────────────────────────────     ──────────────────────────────
A1  base_price = 450,  xmin = 856
                                               B1  UPDATE → 500,  xmin = 863
                                               B2  COMMIT
A2  base_price = 450,  xmin = 856   ← снимок A прежний: коммит B не виден
    COMMIT
A3  base_price = 500,  xmin = 863   ← новый снимок: версия B теперь видима

A2 в открытой транзакции читает старую цену с прежним xmin, хотя B уже закоммитил, — снимок A взят раньше его коммита. После COMMIT сам A берёт новый снимок, и в A3 появляются и новая цена, и новый xmin (это уже версия, созданная транзакцией B). После демо верни меню: make db-reset.

Заборчик: упрощение и где оно кусается

Мы показали xmin/xmax/ctid напрямую и сказали «старая версия полежит и уберётся VACUUM». В проде за этим стоит целый класс проблем, которыми занимается твой DBA, но провоцирует их код:

  • Мёртвые версии — это bloat: таблица и индексы пухнут, пока autovacuum не вычистит мёртвые кортежи. Частые UPDATE по одной строке плодят версии быстрее, чем кажется.
  • VACUUM может убрать версию, только если её уже никто не видит. Долгая открытая транзакция (та самая idle in transaction, или забытый BEGIN в коде) держит горизонт видимости и не даёт чистить мёртвые кортежи по всей базе — даже по таблицам, которых она не трогала.

Практический вывод: транзакции должны быть короткими, а BEGIN без скорого COMMIT/ROLLBACK — это баг, а не стиль. Системные колонки руками ты в приложении не читаешь; знать про них надо, чтобы понимать, почему так.

Что забрать с собой

  • В Postgres строка — это версия с системными колонками xmin/xmax/ctid; UPDATE пишет новую версию, а не перезаписывает старую.
  • Каждая транзакция видит свой снимок; видимость решается по xmin/xmax. Поэтому читатели не блокируют писателей и наоборот — они смотрят на разные версии.
  • REPEATABLE READ фиксирует снимок на всю транзакцию: чужой коммит, пришедший после снимка, внутри транзакции не виден.
  • Цена модели — мёртвые версии и bloat; держи транзакции короткими, иначе VACUUM не сможет прибраться.
  • Конвенция курса: когда нужен интерактив, системные колонки или две сессии — это escape-hatch-юнит на psql, а не query.sql + sqlc.

Соседний юнит 05-01 «Транзакции и ACID» даёт фундамент под этим: BEGIN/COMMIT/ROLLBACK и атомарность на примере перевода баланса. А 05-03 идёт дальше в конкурентность — блокировки строк и потерянные обновления (FOR UPDATE, SKIP LOCKED), тоже на двух сессиях. Снимок, который ты увидел здесь, — общий язык для всего модуля.

·Модуль 06

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Транзакции / Ментальная модель MVCC