Ментальная модель 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, который в конце дропаем:
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 читает второй раз — в той же транзакции:
-- 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:
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-02-mvcc-mental-model T=db-resetМеханика в одной транзакции (make run — основной демо этого юнита):
make lecture L=05-transactions-and-mvcc/05-02-mvcc-mental-model── Свежая строка: одна версия, 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)Теперь две сессии. В первом терминале запусти читателя — он остановится на подсказке:
make lecture L=05-transactions-and-mvcc/05-02-mvcc-mental-model T=session-aВо втором терминале — писателя; он отработает целиком:
make lecture L=05-transactions-and-mvcc/05-02-mvcc-mental-model T=session-bВернись в первый терминал и нажми Enter. Сложенные вместе, шаги дают такую картину (xmin — id транзакции, у тебя числа будут другие; важны их отношения):
Терминал 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), тоже на двух сессиях. Снимок, который ты увидел здесь, — общий язык для всего модуля.