MVCC в PostgreSQL: как база читает без блокировок
Зачем MVCC и какую проблему он решает
Классический подход к конкурентному доступу — блокировки: читаешь строку, ставишь shared lock, пишешь — exclusive lock. Проблема в том, что читатели начинают мешать писателям и друг другу, а под нагрузкой это превращается в очередь из ожидающих транзакций.
MVCC (Multiversion Concurrency Control) решает это иначе: вместо блокировки строки при чтении база хранит несколько версий строки и каждая транзакция видит ту версию, которая была актуальна на момент старта её снапшота. Читатели не блокируют писателей, писатели не блокируют читателей. Блокировки остаются только между двумя writer'ами, которые лезут в одну и ту же строку.
Как устроены версии строк физически
В PostgreSQL каждая строка таблицы — это не одна сущность, а набор физических версий (tuples) в heap. У каждой версии есть системные поля:
xmin— id транзакции, которая создала эту версию строкиxmax— id транзакции, которая эту версию удалила/заменила (0, если версия актуальна)
Когда ты делаешь UPDATE, PostgreSQL не изменяет строку in-place. Он создаёт новую версию строки с новым xmin и проставляет xmax старой версии — старая версия помечается как «устаревшая, но может понадобиться другим транзакциям». DELETE работает так же: строка не исчезает физически, просто помечается xmax.
-- транзакция 100 обновляет строку BEGIN; -- xid = 100 UPDATE accounts SET balance = balance - 500 WHERE id = 1; -- физически в heap теперь две версии строки id=1: -- версия A: xmin=50, xmax=100 (старая, уже неактуальна для новых транзакций) -- версия B: xmin=100, xmax=0 (новая, актуальная)
Снапшот транзакции и видимость строк
Каждая транзакция при старте (в Read Committed — при старте каждого запроса, в Repeatable Read и Serializable — один раз при старте транзакции) получает снапшот: список "живых" на тот момент транзакций. Правило видимости строки для текущей транзакции примерно такое:
- строка видна, если
xminзафиксирован (committed) и произошёл раньше снапшота - строка видна, если
xmaxлибо пуст, либо принадлежит транзакции, которая на момент снапшота ещё не закоммитилась
Отсюда и разница между Read Committed и Repeatable Read: в RC снапшот пересоздаётся на каждый SELECT внутри транзакции, поэтому один и тот же запрос может вернуть разные данные. В RR снапшот фиксируется один раз — вся транзакция видит консистентную картинку, как будто база замерла в момент старта.
Мёртвые версии и зачем нужен VACUUM
Раз старые версии строк не удаляются физически при UPDATE/DELETE, они копятся в heap как мёртвые кортежи (dead tuples). Это плата за MVCC: без него строки удалялись бы сразу, но тогда пришлось бы блокировать читателей.
Чистка мёртвых версий — задача автовакуума (autovacuum). Он проходит по таблице, находит версии строк, невидимые ни для одной активной транзакции, и освобождает место для повторного использования. Если автовакуум не успевает или отключён, таблица разбухает (table bloat), индексы деградируют, а запросы начинают читать намного больше страниц, чем нужно.
pg_stat_activity и долгие xact_start.Эффект на практике: гонка при обновлении
MVCC не избавляет от конфликтов записи, он просто переносит их на уровень конкретной строки. Два транзакции, обновляющие один и тот же счёт, всё равно будут сериализованы — вторая встанет в ожидание row lock на эту строку.
-- транзакция A BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- взяла row lock -- транзакция B (другое соединение) выполняется параллельно BEGIN; UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- ждёт, пока A закоммитится
Это и есть граница применимости MVCC: он снимает конкуренцию между читателями и писателями, но не снимает её между писателями одной и той же строки — и это нормально, иначе мы потеряли бы консистентность данных.