Олег Ильин oo-ilin.ru
← Все статьи

MVCC в PostgreSQL: как база показывает каждому свою правду

Олег Ильин Олег Ильин · 20 окт 2026 · 22 мин

Почему SELECT в PostgreSQL не ждёт чужой UPDATE? Почему после удаления миллиона строк файл не уменьшился? Почему «забытая» транзакция может раздуть всю базу? На все три вопроса отвечает одна идея — MVCC. Разберём её на примерах, SQL и интерактивном симуляторе, где вы сами запустите две параллельные транзакции.

Актуально для PostgreSQL 18 (ветка docs/current). Связанная статья: DELETE vs TRUNCATE в PostgreSQL.

Два окна, один баланс

Дима делает свой первый платёжный сервис. В одной консоли он начинает перевод: UPDATE accounts SET balance = balance - 300 WHERE name = 'Alice'. Транзакцию он пока не закрывает — пошёл за кофе. В другой консоли Дима запускает SELECT balance FROM accounts WHERE name = 'Alice' и готовится к худшему: ведь строка заблокирована, значит, запрос зависнет.

Но ответ приходит мгновенно: 1000. Не 700 (транзакция ещё не зафиксирована) и не ошибка. Дима моргает и спрашивает себя: «Где база взяла эту тысячу, если я только что её изменил?»

Ответ: она никуда не делась. Старая версия строки лежит рядом с новой, и каждый запрос читает ту версию, которая была актуальна на момент его «снимка». Это и называется Multi-Version Concurrency Control — многоверсионное управление конкурентным доступом.

Идея MVCC: читатели не блокируют писателей

Классический подход к конкурентности — блокировки: пока один пишет, остальные ждут. Это просто, но медленно: отчёт на полчаса остановит все платежи. MVCC решает проблему иначе: данные не перезаписываются, а версионируются. Каждая транзакция видит согласованный срез базы, а конфликтуют между собой только запросы, которые пытаются изменить одну и ту же строку.

Правило PostgreSQL

Читатели никогда не блокируют писателей, а писатели никогда не блокируют читателей. Писатели блокируют только других писателей той же строки.

Цена

Старые версии строк копятся в самой таблице. Их нужно убирать (VACUUM), иначе таблицы и индексы раздуваются.

В отличие от Oracle или MySQL InnoDB, PostgreSQL хранит старые версии прямо в основном файле таблицы (heap), а не в отдельном undo-журнале. Отсюда и его сильные стороны (быстрый откат), и его главная головная боль (bloat и VACUUM).

Версия строки изнутри

Строка в таблице — это на самом деле версия строки (tuple). У каждой версии есть служебные поля. Их можно увидеть, добавив системные колонки в SELECT:

system-columns.sqlsql
CREATE TABLE accounts (
    name    text PRIMARY KEY,
    balance numeric NOT NULL
);
INSERT INTO accounts VALUES ('Alice', 1000);

SELECT ctid, xmin, xmax, name, balance FROM accounts;
--  ctid  | xmin | xmax | name  | balance
-- -------+------+------+-------+---------
--  (0,1) |  781 |    0 | Alice |    1000

-- номер текущей транзакции (в PostgreSQL 13+)
SELECT pg_current_xact_id();
xminномер транзакции, которая создала эту версию
xmaxномер транзакции, которая удалила или заменила версию (0 — никто)
ctidфизический адрес: (страница, позиция). У новой версии после UPDATE он другой
cmin / cmaxномер команды внутри транзакции: чтобы запрос не видел собственные изменения «из будущего»

Как выглядит UPDATE на самом деле

UPDATE в PostgreSQL — это «вставь новую версию и закрой старую». Нет никакого изменения «на месте».

UPDATE: новая версия и закрытие старой ДО UPDATE ctid (0,1) xmin=781 xmax=0 Alice · 1000 ПОСЛЕ UPDATE ... balance = 700 (транзакция 790) ctid (0,1) xmin=781 xmax=790 Alice · 1000 ← старая версия ctid (0,2) xmin=790 xmax=0 Alice · 700 ← новая версия ссылка Пока транзакция 790 не зафиксирована, другие транзакции видят (0,1) — «1000». После COMMIT новые снимки видят (0,2) — «700», а (0,1) становится мёртвой и ждёт VACUUM.

Снимок и правила видимости

Каждый запрос работает со снимком (snapshot): «какие транзакции на этот момент уже завершились, а какие нет». Версия строки видна, если одновременно:

  1. транзакция xmin зафиксирована и попала в снимок (или это ваша собственная транзакция);
  2. и при этом xmax либо равен 0, либо относится к откатившейся/незавершённой чужой транзакции, либо к транзакции, которой ваш снимок «ещё не видит».
snapshot.sqlsql
-- текущий снимок: xmin:xmax:список активных транзакций
SELECT pg_current_snapshot();
-- 790:795:791,793

-- читается так: всё, что меньше 790, уже завершено;
-- всё от 795 — ещё не началось; 791 и 793 выполняются и невидимы
Статус каждой транзакции (идёт, зафиксирована, откатилась) хранится отдельно от строк. Поэтому ROLLBACK в PostgreSQL мгновенный: строки не нужно «возвращать», достаточно пометить транзакцию как откатившуюся — её версии просто перестают быть видимыми.

Что делает каждая операция

Теперь пройдёмся по операциям с точки зрения версий строк. Для каждой: что происходит в таблице, зачем и когда использовать, пример из практики и цена.

INSERTдёшево

Добавляет новую версию

  • Что внутри: в страницу пишется новая версия с xmin = ваша транзакция, xmax = 0. Для видимости другим она станет доступна только после COMMIT.
  • Мёртвые версии: нет. Исключение — откат: вставленная версия остаётся в файле как мусор до VACUUM.
  • Блокировки: только ROW EXCLUSIVE на таблицу; читателям не мешает.
INSERT INTO orders (user_id, total) VALUES (42, 1990)
RETURNING id, xmin;

Когда: любое добавление данных. Для больших загрузок используйте COPY или пакетные вставки — они дешевле по накладным расходам на транзакции.

Из практики: загрузка 10 млн строк по одной в autocommit — это 10 млн транзакций и 10 млн фиксаций WAL. Тот же объём одним COPY или пачками по 5–10 тысяч строк работает на порядок быстрее.

SELECTбез блокировок

Читает версии по снимку

  • Что внутри: берётся снимок, и из всех версий выбираются только видимые ему. Таблица не меняется (за редким исключением — hint bits).
  • Блокировки: ACCESS SHARE на таблицу; блокирует только ACCESS EXCLUSIVE (DROP, TRUNCATE, VACUUM FULL и т.п.).
  • Побочный эффект: читая страницу, запрос может проставить в версиях «подсказки» (hint bits) о статусе транзакций, поэтому даже SELECT иногда делает страницы «грязными».
  • Index-only scan: индексы не хранят информацию о видимости. Чтобы не ходить в таблицу, PostgreSQL использует карту видимости (visibility map), которую обновляет VACUUM.
-- видим ли мы только живые версии? Смотрим всё через pageinspect
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT lp, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('accounts', 0));

Из практики: аналитический отчёт на полчаса спокойно работает рядом с платежами. Но за него придётся заплатить: пока транзакция открыта, VACUUM не может убрать версии, которые она ещё могла бы увидеть.

UPDATEсоздаёт мусор

Закрывает старую версию и создаёт новую

  • Что внутри: у старой версии xmax = ваша транзакция, рядом пишется новая версия. Это всегда новая версия — даже если вы присвоили то же значение (SET a = a).
  • Мёртвые версии: каждая изменённая строка оставляет одну. Чем чаще обновляете, тем больше мусора.
  • Индексы: если новая версия легла в другое место (другая страница) или изменился индексируемый столбец, во всех индексах таблицы появляются новые записи.
  • Блокировки: блокирует конкретные строки. Две транзакции, меняющие одну строку, выстраиваются в очередь.

HOT-обновления: лёгкий путь

Если обновление не меняет проиндексированных столбцов и на той же странице есть место, PostgreSQL делает Heap-Only Tuple (HOT) обновление: индексы не трогаются, а старая версия связывается с новой цепочкой внутри страницы. Такие версии потом чистятся быстро, прямо при чтении страницы.

-- сколько обновлений было «лёгкими»
SELECT relname, n_tup_upd, n_tup_hot_upd,
       round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE relname = 'accounts';

-- оставляем место на странице под будущие HOT-обновления
ALTER TABLE accounts SET (fillfactor = 80);

Когда: любое изменение существующих данных. Старайтесь менять только нужные столбцы и не индексировать то, что обновляется постоянно.

Из практики: счётчик просмотров views_count в строке статьи, который обновляется на каждый заход, — классика. 1000 заходов в минуту на одну строку — это 1000 версий в минуту. Выход: вынести счётчик в отдельную узкую таблицу, копить в Redis и сбрасывать пачками, либо писать события в append-only таблицу и агрегировать.

PostgreSQL 18: в RETURNING для INSERT, UPDATE, DELETE и MERGE можно обращаться и к старым, и к новым значениям строки через OLD и NEW — удобно для аудита изменений одной командой.

DELETEместо не освобождается

Только проставляет xmax

  • Что внутри: версия остаётся на месте, в ней проставляется xmax. Новая версия не создаётся.
  • Мёртвые версии: каждая удалённая строка — мёртвая версия до VACUUM; её индексные записи тоже.
  • Диск: файл не уменьшается. После VACUUM место внутри файла используется повторно.
DELETE FROM sessions WHERE expires_at < now() RETURNING id;

Когда: точечное удаление. Массовое удаление «всего содержимого» часто лучше делать иначе — см. статью DELETE vs TRUNCATE.

Из практики: таблица sessions каждую минуту теряет истёкшие строки. Autovacuum успевает за ней, и таблица не растёт. Но если удалить 90% строк за один раз и ждать, что размер упадёт, — ожидание не оправдается.

SELECT … FOR UPDATEблокирует строки

Явная блокировка строк без изменения данных

  • Что внутри: в xmax версии записывается ваша транзакция вместе с режимом блокировки. Новая версия не создаётся, но страница изменяется — запись идёт на диск.
  • Режимы: FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE; у каждого своя совместимость с другими.
  • Модификаторы: NOWAIT — сразу ошибка, SKIP LOCKED — пропустить занятые строки.
-- очередь задач: каждый воркер берёт свободные задачи и не мешает другим
WITH next AS (
    SELECT id FROM jobs
    WHERE status = 'new'
    ORDER BY id
    LIMIT 10
    FOR UPDATE SKIP LOCKED
)
UPDATE jobs j SET status = 'running'
FROM next WHERE j.id = next.id
RETURNING j.id;

Когда: «прочитал, подумал, обновил» без гонок; очереди задач; резервирование.

Из практики: при списании со склада сначала SELECT … FOR UPDATE строки с остатком, затем проверка и UPDATE. Без блокировки два заказа одновременно прочитают «осталось 1» и оба спишут.

INSERT … ON CONFLICT / MERGEinsert или update

«Вставь, а если есть — обнови»

  • ON CONFLICT DO UPDATE: атомарно решает «вставка или обновление» по уникальному индексу. При конфликте это обычный UPDATE — новая версия и мёртвая старая.
  • ON CONFLICT DO NOTHING: при конфликте ничего не меняет, но вставка могла занять номер sequence — в нём будут дыры.
  • MERGE (PostgreSQL 15+): условная логика WHEN MATCHED / NOT MATCHED над источником данных; не требует уникального индекса.
INSERT INTO page_views (page_id, views)
VALUES (7, 1)
ON CONFLICT (page_id)
DO UPDATE SET views = page_views.views + 1;

MERGE INTO accounts a
USING incoming i ON a.name = i.name
WHEN MATCHED THEN UPDATE SET balance = a.balance + i.delta
WHEN NOT MATCHED THEN INSERT (name, balance) VALUES (i.name, i.delta);

Из практики: синхронизация каталога товаров из внешней системы: тысячи строк, часть новых, часть изменившихся. Upsert избавляет от гонки «проверить — вставить» между воркерами.

ROLLBACKмгновенный

Просто помечает транзакцию откатившейся

  • Что внутри: ничего не перезаписывается. Статус транзакции становится «aborted», и её версии (созданные и «удалённые») перестают влиять на видимость.
  • Мёртвые версии: все версии, созданные откатившейся транзакцией, — мусор для VACUUM.
  • Сравнение: в системах с undo-журналом откат большой транзакции может длиться как сама транзакция. В PostgreSQL он почти мгновенный.

Из практики: миграция упала на середине — ROLLBACK вернул всё за миллисекунды. Но если такое происходит часто (например, повторяющиеся ошибки уникальности), в таблице копится мусор, а sequence не откатывается — id будут идти с дырами.

VACUUMуборка

Убирает то, что больше никому не нужно

  • Что внутри: находит версии, которые уже не видны ни одной активной транзакции, и освобождает место под новые версии. Чистит индексные записи, обновляет карту видимости и «замораживает» старые номера транзакций.
  • Не блокирует обычную работу (берёт SHARE UPDATE EXCLUSIVE). Файл, как правило, не уменьшает.
  • VACUUM FULL переписывает таблицу, возвращает место ОС, но берёт ACCESS EXCLUSIVE.
  • Autovacuum запускает всё это автоматически, когда мёртвых версий становится больше порога (по умолчанию 50 + 20% от размера таблицы).
VACUUM (VERBOSE, ANALYZE) accounts;

-- кто мешает убрать мусор: самые старые транзакции
SELECT pid, state, xact_start, backend_xmin, left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 5;

Из практики: VACUUM «не работает» почти всегда не потому, что сломан, а потому что кто-то держит старую транзакцию: забытая сессия в idle in transaction, долгий отчёт, неактивный слот репликации.

Симулятор: две транзакции, одна таблица

Ниже — рабочая модель MVCC. Слева сессия A, справа сессия B. Внизу — «куча» таблицы accounts: все версии строк со значениями xmin и xmax и пометкой, кто какую версию видит. Выберите готовый сценарий и жмите «Следующий шаг» или нажимайте кнопки сами. Уровень изоляции применяется при BEGIN.

Куча таблицы accounts

ctidnamebalancexminxmaxA видитB видитСостояние

Транзакции

Симулятор упрощает: нет hint bits, нет multixact и HOT, а номера транзакций выдаются при BEGIN, тогда как PostgreSQL делает это лениво — при первом изменении. Поведение видимости и ожидания на строках при этом совпадает с настоящим.

Уровни изоляции

Уровень изоляции определяет, когда берётся снимок. Это меняет то, что вы видите внутри одной транзакции.

Момент снимка при разных уровнях изоляции READ COMMITTED: снимок на каждый запрос SELECT 1 COMMIT другой сессии SELECT 2 видит чужие изменения REPEATABLE READ: один снимок на транзакцию SELECT 1 SELECT 2 тот же срез, что в начале
УровеньГрязное чтениеНеповторяющееся чтениеФантомное чтениеАномалия сериализации
READ COMMITTED (по умолчанию)нетвозможновозможновозможна
REPEATABLE READнетнетнетвозможна
SERIALIZABLEнетнетнетнет
  • READ UNCOMMITTED в PostgreSQL работает как READ COMMITTED: «грязное» чтение невозможно.
  • REPEATABLE READ в PostgreSQL строже стандарта SQL — фантомов тоже нет. Платой за это становятся ошибки could not serialize access due to concurrent update, если другая транзакция уже изменила нужную строку.
  • SERIALIZABLE использует SSI (Serializable Snapshot Isolation) и может прервать транзакцию с кодом 40001. Приложение должно уметь повторить транзакцию целиком.
isolation.sqlsql
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE name = 'Alice';
-- ... другие сессии меняют и фиксируют Alice ...
SELECT balance FROM accounts WHERE name = 'Alice'; -- то же значение
COMMIT;

SHOW default_transaction_isolation;  -- read committed

Как MVCC влияет на производительность

Что получаем

  • Высокая конкурентность: чтение и запись не блокируют друг друга.
  • Быстрый ROLLBACK: ничего не откатывается физически.
  • Согласованные чтения без явных блокировок.

Что платим

  • Bloat. Мёртвые версии занимают место в таблицах и индексах. Больше страниц — больше чтений с диска и в память, медленнее скан.
  • Write amplification. UPDATE пишет целую новую версию, а при не-HOT обновлении ещё и новые записи во всех индексах. Увеличивается WAL.
  • Нагрузка на VACUUM. Autovacuum потребляет CPU и диск. Чем чаще UPDATE и DELETE, тем он нужнее.
  • Долгие транзакции. Любая открытая транзакция «закрепляет» горизонт: всё, что удалено после её начала, нельзя убрать. Раздувается вся база, а не только ваши таблицы.
  • Wraparound. Номера транзакций 32-битные; старые версии нужно периодически «замораживать» (VACUUM FREEZE), иначе база остановит запись в целях защиты данных.

Оцените раздувание

Двигайте ползунки: сколько обновлений в секунду получает таблица и как часто их убирает autovacuum. Модель показывает, как быстро накапливается мусор.

Мусора в час
Нельзя убрать из-за транзакции
Вердикт

Модель: каждое обновление оставляет одну мёртвую версию размером строки плюс ~24 байта заголовка. Это оценка порядка величины, а не измерение.

Истории из практики

Idle in transaction

Таблица растёт, хотя данных не прибавилось

Размер базы вырос в 3 раза за неделю, строк столько же. Причина: приложение открывало транзакцию, ходило во внешний API и забывало её закрыть. Autovacuum работал, но не мог ничего убрать.

SELECT pid, now() - xact_start AS age, state, left(query, 50) AS query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY age DESC;

-- защита: обрывать забытые транзакции
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
SELECT pg_reload_conf();
Hot row

Один счётчик на всю систему

Строка stats(id=1, hits) обновляется 3000 раз в секунду. Транзакции выстраиваются в очередь на одной строке, а в таблице копятся тысячи версий одной строки.

-- вместо UPDATE одной строки: писать события...
INSERT INTO hits_log (at) VALUES (now());
-- ...и сворачивать их пачками
WITH moved AS (DELETE FROM hits_log RETURNING 1)
UPDATE stats SET hits = hits + (SELECT count(*) FROM moved);
Lost update

Два менеджера правят один заказ

Оба прочитали заказ, оба нажали «сохранить». Второй сохранил поверх первого. Решение — оптимистичная блокировка через номер версии.

UPDATE orders
SET status = 'paid', version = version + 1
WHERE id = 10 AND version = 3;
-- UPDATE 0 → кто-то успел раньше: перечитать и повторить
Отчёты

Сутки аналитики на основной базе

Отчёт в транзакции REPEATABLE READ шёл 6 часов. Всё это время autovacuum не мог убрать свежие версии. Отчёт вынесли на реплику и разбили на короткие запросы.

-- на реплике конфликты с рекавери решаются настройкой
-- hot_standby_feedback / max_standby_streaming_delay
SHOW hot_standby_feedback;

Где больно

  • Забытая транзакция. Самая частая причина раздувания. Всегда закрывайте транзакции и задайте idle_in_transaction_session_timeout.
  • Массовый UPDATE «на все строки». UPDATE big SET flag = true удвоит размер таблицы: каждая строка получит новую версию. Делайте пачками и давайте autovacuum успевать.
  • Индексы на часто меняемых столбцах. Лишают UPDATE возможности быть HOT. Индексируйте только то, что нужно для запросов.
  • Ожидание, что «DELETE освободит диск». Освободит место внутри файла только после VACUUM, ОС — только после VACUUM FULL или пересоздания таблицы.
  • Отключённый или слабый autovacuum. На горячих таблицах уменьшайте autovacuum_vacuum_scale_factor индивидуально.
  • Слоты репликации без потребителя. Удерживают горизонт и WAL так же, как забытая транзакция.
  • Нет повтора при serialization failure. На REPEATABLE READ и SERIALIZABLE ошибки 40001 — норма. Код должен повторять транзакцию.
отдельные настройки для горячей таблицыsql
ALTER TABLE sessions SET (
    autovacuum_vacuum_scale_factor = 0.02,
    autovacuum_vacuum_threshold    = 1000,
    fillfactor                     = 85
);

Сводная таблица: последствия и побочные эффекты

ОперацияЧто делает с версиямиМёртвые версииБлокировкиWAL и индексыЦенаКогда использовать
INSERTСоздаёт новую версию (xmin = ваша транзакция)Нет (при откате — да)ROW EXCLUSIVE, читателей не блокируетЗапись в WAL и во все индексыНизкаяДобавление данных; для больших объёмов — COPY
SELECTЧитает видимые по снимку версииНетACCESS SHAREНет (возможны hint bits)Низкая; долгая транзакция держит горизонтВсегда; для отчётов — короткими запросами или на реплике
UPDATEЗакрывает старую (xmax), создаёт новуюОдна на каждую изменённую строкуБлокирует строки; конкуренты за ту же строку ждутНовая версия в WAL; индексы, если не HOTСредняя–высокаяИзменение данных; меняйте только нужное, не индексируйте горячие столбцы
DELETEПроставляет xmaxОдна на каждую удалённую строкуБлокирует строкиЗапись в WAL; индексы чистит VACUUMСредняя; место не возвращаетсяТочечное удаление; массово — пачками или партиции
SELECT FOR UPDATEПишет блокировку в xmax, версию не меняетНетЯвная блокировка строк до конца транзакцииСтраница становится «грязной»Средняя; риск ожиданий и дедлоковРезервирование, очереди (SKIP LOCKED), защита от гонок
UPSERT / MERGEВставка или как UPDATEКак у UPDATE при конфликтеСтроки при конфликтеКак INSERT или UPDATEСредняяСинхронизация, счётчики, идемпотентные записи
ROLLBACKПомечает транзакцию откатившейсяВсе её версии становятся мусоромСнимает блокировкиМинимумПочти нулеваяОшибка в транзакции; но частые откаты оставляют мусор
VACUUMУдаляет невидимые версии, обновляет карту видимости, замораживает xidУбираетSHARE UPDATE EXCLUSIVE, не мешает работеЧитает и пишет таблицу и индексыФоновая нагрузкаАвтоматически; вручную после больших удалений и перед отчётами
VACUUM FULLПереписывает таблицу целикомУбирает полностьюACCESS EXCLUSIVEПолная перезапись и перестройка индексовВысокаяРедко и в окно обслуживания; рассмотрите pg_repack

Плюсы и минусы MVCC

Плюсы

  • Читатели и писатели не блокируют друг друга
  • Согласованные снимки без явных блокировок
  • Мгновенный ROLLBACK
  • Гибкие уровни изоляции, включая SSI

Минусы

  • Bloat: старые версии лежат в таблицах и индексах
  • Постоянная работа VACUUM
  • UPDATE дороже, чем «изменение на месте»
  • Долгие транзакции вредят всей базе
  • Нужны повторы при ошибках сериализации

Проверь себя

Четыре вопроса по статье. Можно пробовать, пока не нажмёте верный вариант.

Дима вернулся с кофе, нажал COMMIT — и вторая консоль, получив новый снимок, тут же показала 700. Дима открыл pg_stat_user_tables и увидел одну мёртвую версию. «Значит, мне не нужно бояться читать, пока кто-то пишет, — подумал он. — Нужно бояться забытых транзакций и огромных UPDATE». С этой мыслью он поставил idle_in_transaction_session_timeout и пошёл дальше учить PostgreSQL.

Шпаргалка

  • Каждая строка — набор версий с xmin и xmax; видимость определяет снимок.
  • UPDATE = новая версия, DELETE = проставить xmax, ROLLBACK = пометить транзакцию откатившейся.
  • Мёртвые версии убирает VACUUM; файл обычно не уменьшается.
  • Держите транзакции короткими; следите за idle in transaction.
  • Не индексируйте часто меняемые столбцы, используйте fillfactor для HOT.
  • На REPEATABLE READ и SERIALIZABLE готовьте повтор транзакции.

Все ссылки ведут в документацию PostgreSQL ветки current — на последнюю стабильную версию (на момент публикации — 18).

Читать дальше