Главная › Блог › MVCC в PostgreSQL: как база читает без блокировок

MVCC в PostgreSQL: как база читает без блокировок

Зачем MVCC и какую проблему он решает

Классический подход к конкурентному доступу — блокировки: читаешь строку, ставишь shared lock, пишешь — exclusive lock. Проблема в том, что читатели начинают мешать писателям и друг другу, а под нагрузкой это превращается в очередь из ожидающих транзакций.

MVCC (Multiversion Concurrency Control) решает это иначе: вместо блокировки строки при чтении база хранит несколько версий строки и каждая транзакция видит ту версию, которая была актуальна на момент старта её снапшота. Читатели не блокируют писателей, писатели не блокируют читателей. Блокировки остаются только между двумя writer'ами, которые лезут в одну и ту же строку.

Представь Google Docs с историей версий. Ты открыл документ в 14:00 — и видишь версию на 14:00, даже если кто-то продолжает редактировать документ после тебя. Твой коллега, открывший документ в 14:05, увидит уже другую версию. Никто не ждёт друг друга, у каждого своя «фотография» состояния.

Как устроены версии строк физически

В 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 снапшот фиксируется один раз — вся транзакция видит консистентную картинку, как будто база замерла в момент старта.

Почему SELECT не блокирует UPDATE в PostgreSQL?
Потому что SELECT читает уже существующую версию строки по своему снапшоту и ему не нужно дожидаться писателя. UPDATE создаёт новую версию строки, не трогая ту, что видит читатель. Конфликт возникает только когда два writer'а пытаются изменить одну и ту же строку — тогда второй ждёт через row-level lock, пока первый закоммитится или сделает rollback.

Мёртвые версии и зачем нужен VACUUM

Раз старые версии строк не удаляются физически при UPDATE/DELETE, они копятся в heap как мёртвые кортежи (dead tuples). Это плата за MVCC: без него строки удалялись бы сразу, но тогда пришлось бы блокировать читателей.

Чистка мёртвых версий — задача автовакуума (autovacuum). Он проходит по таблице, находит версии строк, невидимые ни для одной активной транзакции, и освобождает место для повторного использования. Если автовакуум не успевает или отключён, таблица разбухает (table bloat), индексы деградируют, а запросы начинают читать намного больше страниц, чем нужно.

Классическая боль в проде: долгая транзакция (забытый открытый BEGIN, зависшее соединение) держит старый снапшот живым. Автовакуум не может почистить версии строк, которые теоретически ещё видны этой старой транзакции — и таблица начинает расти бесконтрольно, хотя нагрузка на запись не менялась. Диагностируется через 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: он снимает конкуренцию между читателями и писателями, но не снимает её между писателями одной и той же строки — и это нормально, иначе мы потеряли бы консистентность данных.

Ещё разборы