Почему SELECT в PostgreSQL не ждёт чужой UPDATE? Почему после удаления миллиона строк файл не уменьшился? Почему «забытая» транзакция может раздуть всю базу? На все три вопроса отвечает одна идея — MVCC. Разберём её на примерах, SQL и интерактивном симуляторе, где вы сами запустите две параллельные транзакции.
Два окна, один баланс
Дима делает свой первый платёжный сервис. В одной консоли он начинает перевод: 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), иначе таблицы и индексы раздуваются.
Версия строки изнутри
Строка в таблице — это на самом деле версия строки (tuple). У каждой версии есть служебные поля. Их можно увидеть, добавив системные колонки в SELECT:
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();Как выглядит UPDATE на самом деле
UPDATE в PostgreSQL — это «вставь новую версию и закрой старую». Нет никакого изменения «на месте».
Снимок и правила видимости
Каждый запрос работает со снимком (snapshot): «какие транзакции на этот момент уже завершились, а какие нет». Версия строки видна, если одновременно:
- транзакция
xminзафиксирована и попала в снимок (или это ваша собственная транзакция); - и при этом
xmaxлибо равен 0, либо относится к откатившейся/незавершённой чужой транзакции, либо к транзакции, которой ваш снимок «ещё не видит».
-- текущий снимок: xmin:xmax:список активных транзакций
SELECT pg_current_snapshot();
-- 790:795:791,793
-- читается так: всё, что меньше 790, уже завершено;
-- всё от 795 — ещё не началось; 791 и 793 выполняются и невидимыROLLBACK в PostgreSQL мгновенный: строки не нужно «возвращать», достаточно пометить транзакцию как откатившуюся — её версии просто перестают быть видимыми.Что делает каждая операция
Теперь пройдёмся по операциям с точки зрения версий строк. Для каждой: что происходит в таблице, зачем и когда использовать, пример из практики и цена.
Добавляет новую версию
- Что внутри: в страницу пишется новая версия с
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 тысяч строк работает на порядок быстрее.
Читает версии по снимку
- Что внутри: берётся снимок, и из всех версий выбираются только видимые ему. Таблица не меняется (за редким исключением — 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 не может убрать версии, которые она ещё могла бы увидеть.
Закрывает старую версию и создаёт новую
- Что внутри: у старой версии
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 — удобно для аудита изменений одной командой.
Только проставляет xmax
- Что внутри: версия остаётся на месте, в ней проставляется
xmax. Новая версия не создаётся. - Мёртвые версии: каждая удалённая строка — мёртвая версия до VACUUM; её индексные записи тоже.
- Диск: файл не уменьшается. После VACUUM место внутри файла используется повторно.
DELETE FROM sessions WHERE expires_at < now() RETURNING id;Когда: точечное удаление. Массовое удаление «всего содержимого» часто лучше делать иначе — см. статью DELETE vs TRUNCATE.
Из практики: таблица sessions каждую минуту теряет истёкшие строки. Autovacuum успевает за ней, и таблица не растёт. Но если удалить 90% строк за один раз и ждать, что размер упадёт, — ожидание не оправдается.
Явная блокировка строк без изменения данных
- Что внутри: в
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» и оба спишут.
«Вставь, а если есть — обнови»
- 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 избавляет от гонки «проверить — вставить» между воркерами.
Просто помечает транзакцию откатившейся
- Что внутри: ничего не перезаписывается. Статус транзакции становится «aborted», и её версии (созданные и «удалённые») перестают влиять на видимость.
- Мёртвые версии: все версии, созданные откатившейся транзакцией, — мусор для VACUUM.
- Сравнение: в системах с undo-журналом откат большой транзакции может длиться как сама транзакция. В PostgreSQL он почти мгновенный.
Из практики: миграция упала на середине — ROLLBACK вернул всё за миллисекунды. Но если такое происходит часто (например, повторяющиеся ошибки уникальности), в таблице копится мусор, а sequence не откатывается — id будут идти с дырами.
Убирает то, что больше никому не нужно
- Что внутри: находит версии, которые уже не видны ни одной активной транзакции, и освобождает место под новые версии. Чистит индексные записи, обновляет карту видимости и «замораживает» старые номера транзакций.
- Не блокирует обычную работу (берёт
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
| ctid | name | balance | xmin | xmax | A видит | B видит | Состояние |
|---|
Транзакции
Уровни изоляции
Уровень изоляции определяет, когда берётся снимок. Это меняет то, что вы видите внутри одной транзакции.
| Уровень | Грязное чтение | Неповторяющееся чтение | Фантомное чтение | Аномалия сериализации |
|---|---|---|---|---|
| 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. Приложение должно уметь повторить транзакцию целиком.
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. Модель показывает, как быстро накапливается мусор.
Истории из практики
Таблица растёт, хотя данных не прибавилось
Размер базы вырос в 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();Один счётчик на всю систему
Строка 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);Два менеджера правят один заказ
Оба прочитали заказ, оба нажали «сохранить». Второй сохранил поверх первого. Решение — оптимистичная блокировка через номер версии.
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 — норма. Код должен повторять транзакцию.
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).
- Concurrency Control — раздел про MVCC целиком
- MVCC: Introduction — основная идея
- Transaction Isolation — уровни изоляции и аномалии
- Explicit Locking — блокировки таблиц и строк
- System Columns —
xmin,xmax,ctid - Routine Vacuuming — VACUUM, autovacuum, wraparound
- VACUUM — команда и опции
- Automatic Vacuuming (настройки) — пороги и параметры autovacuum
- Heap-Only Tuples (HOT) — лёгкие обновления
- Database Page Layout — устройство страницы и заголовка версии
- Visibility Map — карта видимости и index-only scan
- INSERT — включая
ON CONFLICT - UPDATE —
RETURNING,OLDиNEWв версии 18 - DELETE — синтаксис и
RETURNING - MERGE — условные вставка, обновление, удаление
- SELECT: блокирующие предложения —
FOR UPDATE,NOWAIT,SKIP LOCKED - SET TRANSACTION — выбор уровня изоляции
- System Information Functions —
pg_current_xact_id,pg_current_snapshot - Cumulative Statistics System —
pg_stat_user_tables,pg_stat_activity - Client Connection Defaults —
idle_in_transaction_session_timeout,lock_timeout - pageinspect — заглянуть внутрь страницы таблицы
- pgstattuple — измерить bloat
- Storage Parameters —
fillfactorи параметры autovacuum на таблицу - Hot Standby — запросы на репликах и конфликты с VACUUM