Как работает MVCC в PostgreSQL?
Каждая строка (tuple) содержит скрытые системные поля:
🟢 Junior Level
MVCC (Multi-Version Concurrency Control) — механизм, который позволяет PostgreSQL показывать каждому пользователю свою версию данных, не блокируя чтение при записи.
Простая аналогия: Представьте Google Docs. Когда вы редактируете документ, другие видят старую версию, пока вы не сохраните. При этом они могут продолжать читать документ — им не нужно ждать, пока вы закончите.
Отличие от других СУБД: Oracle использует undo-сегменты, SQL Server — tempdb. PostgreSQL хранит ВСЕ версии в той же таблице → проще, но bloat от UPDATE/DELETE.
Основная идея:
- При
UPDATEPostgreSQL не перезаписывает строку, а создаёт новую версию - При
DELETEстрока не удаляется физически, а помечается как удалённая - Каждая транзакция видит только те строки, которые существовали на момент её начала
Пример:
-- Транзакция 1 -- Транзакция 2
BEGIN; BEGIN;
SELECT * FROM users WHERE id=1; -- Видит: name='Ivan'
UPDATE users SET name='Petr'
WHERE id=1; -- Всё ещё видит: name='Ivan'!
SELECT * FROM users WHERE id=1; -- Видит: name='Petr'
COMMIT;
SELECT * FROM users WHERE id=1;
-- Теперь видит: name='Petr'
Зачем это нужно:
- Читатели не блокируют писателей
- Писатели не блокируют читателей
- Нет проблемы “грязного чтения”
🟡 Middle Level
Как это работает внутри
Каждая строка (tuple) содержит скрытые системные поля:
| Поле | Описание |
|---|---|
| xmin | ID транзакции, которая создала строку |
| xmax | ID транзакции, которая удалила строку (0 = не удалена) |
| ctid | Физический адрес строки (page_number, row_number) |
Механизм:
-- Исходное состояние
-- users таблица:
-- xmin=100, xmax=0, name='Ivan', ctid=(1,1)
-- Транзакция 101 делает UPDATE
UPDATE users SET name='Petr' WHERE id=1;
-- Новая версия строки:
-- xmin=101, xmax=0, name='Petr', ctid=(1,2)
-- Старая версия:
-- xmin=100, xmax=101, name='Ivan', ctid=(1,1) ← xmax=101 (помечена)
-- Транзакция 102 (началась до 101):
-- → Видит строку с xmin=100 (xmax=101 ещё не зафиксирован для неё)
-- → name='Ivan'
-- Транзакция 103 (началась после 101):
-- → Видит строку с xmin=101 (зафиксирована)
-- → name='Petr'
Snapshots (Снимки)
Когда транзакция начинает SELECT, она делает снимок состояния БД:
Snapshot:
xmin = 100 ← Все транзакции < 100 зафиксированы
xmax = 105 ← Все транзакции >= 105 ещё не начались
xip_list = [101, 102, 103] ← Активные транзакции
Правило видимости:
Строка видна, если:
- её xmin < snapshot.xmin ИЛИ xmin в xip_list (ещё активна)
- её xmax = 0 ИЛИ xmax >= snapshot.xmax
UPDATE = DELETE + INSERT
-- Это не обновление "на месте"!
UPDATE users SET name='Petr' WHERE id=1;
-- Фактически:
-- 1. Создаётся НОВАЯ версия строки (name='Petr')
-- 2. Старая версия помечается (xmax = текущая транзакция)
-- 3. Индексы обновляются (ссылка на новую ctid)
VACUUM: очистка мёртвых строк
-- После UPDATE/DELETE остаются "мёртвые" строки (dead tuples)
-- VACUUM удаляет их, освобождая место
-- Проверка мёртвых строк
SELECT
schemaname,
relname,
n_live_tup, -- Живые строки
n_dead_tup, -- Мёртвые строки
last_vacuum
FROM pg_stat_user_tables
WHERE relname = 'users';
-- Ручной запуск
VACUUM users;
Типичные проблемы
- Bloat (раздувание)
-- Таблица занимает в 10 раз больше места, чем должна -- Причина: много dead tuples, VACUUM не успевает -- Решение VACUUM FULL users; -- ⚠️ Блокирует таблицу! -- или pg_repack -t users; -- Без блокировки - Длинные транзакции мешают VACUUM
-- Если транзакция открыта 2 часа -- VACUUM не может удалить строки, которые она видит -- Найти длинные транзакции SELECT pid, now() - xact_start as duration, query FROM pg_stat_activity WHERE state = 'active' ORDER BY xact_start ASC; - Transaction ID Wraparound
-- XID — 32-битное число (4 миллиарда) -- Если не "замораживать" старые строки → переполнение -- Проверка возраста SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY age DESC; -- > 200 млн → срочно VACUUM! -- > 1.5 млрд → база перейдёт в READ ONLY
🔴 Senior Level
Hint Bits оптимизация
Проблема: Проверка статуса транзакции через CLOG (Commit Log) — дорогая операция (чтение с диска).
CLOG — файлы в pg_xact/, где каждая транзакция = 2 бита (IN_PROGRESS/COMMITTED/ABORTED). Это НЕ WAL. Чтение CLOG = микросекунды, но при миллионах строк → секунды.
Решение: PostgreSQL использует Hint Bits — 2 бита в заголовке строки:
Heap Tuple Header:
...
t_infomask:
HEAP_XMIN_COMMITTED ← xmin зафиксирован
HEAP_XMIN_INVALID ← xmin отменён
HEAP_XMAX_COMMITTED ← xmax зафиксирован
HEAP_XMAX_INVALID ← xmax отменён
...
Механизм:
1. Первый читатель проверяет статус xmin в CLOG
2. Проставляет Hint Bits в заголовок строки
3. Все последующие читатели проверяют Hint Bits мгновенно
→ Без обращения к CLOG!
Это называется "Hint Bits propagation" или "Lazy hinting"
Важно: Hint Bits не WAL-logged! После crash они пересчитываются заново.
Multixacts
Когда несколько транзакций блокируют одну строку (SELECT FOR SHARE):
-- Транзакция 1: SELECT FOR SHARE → блокировка
-- Транзакция 2: SELECT FOR SHARE → тоже блокировка
-- xmax не может хранить два ID!
-- Решение: MultiXact ID
-- В xmax записывается ссылка на pg_multixact
-- pg_multixact хранит список транзакций: [101, 102]
Проблема: Multixact стареет так же, как XID → нужен VACUUM.
Serializable Snapshot Isolation (SSI)
PostgreSQL реализует SERIALIZABLE без блокировок чтения:
-- Уровень SERIALIZABLE в PG ≠ SERIALIZABLE в других СУБД
-- PG использует SSI (Serializable Snapshot Isolation)
-- Механизм:
-- 1. Отслеживает SIRead locks (предикатные блокировки)
-- 2. Обнаруживает RW-conflict cycles
-- 3. Если цикл найден → отменяет одну транзакцию с ошибкой:
-- ERROR: could not serialize access due to read/write dependencies
Пример:
-- Транзакция 1 -- Транзакция 2
SET TRANSACTION ISOLATION LEVEL
SERIALIZABLE; SET TRANSACTION ISOLATION LEVEL
SERIALIZABLE;
SELECT SUM(balance) FROM accounts;
UPDATE accounts SET balance = 1000
WHERE id = 1;
UPDATE accounts SET balance = 500
WHERE id = 2;
COMMIT;
COMMIT; ← ERROR: serialization failure!
⚠️ НЕ используйте SERIALIZABLE в высоконагруженных OLTP: при частых RW-conflict транзакции постоянно откатываются. Используйте READ COMMITTED или REPEATABLE READ.
HOT (Heap Only Tuple) обновления
Heap = основное хранилище строк таблицы (не индекс). «Heap Only Tuple» = новая версия на той же странице, индексы не обновляются.
Механизм HOT:
Критическая оптимизация для UPDATE:
-- Обычный UPDATE:
-- 1. Новая строка в Heap
-- 2. Обновить ВСЕ индексы (новая ctid)
-- 3. Старую строку пометить (xmax)
-- HOT UPDATE (если выполнены условия):
-- 1. Новая строка в Heap (на той же странице!)
-- 2. Индексы НЕ обновляются
-- 3. Создается HOT-цепочка: old → new
-- Условия для HOT:
-- - Обновляемые колонки НЕ индексированы
-- - На странице Heap есть место (fillfactor < 100)
Мониторинг HOT:
SELECT
relname,
n_tup_hot_update, -- HOT обновления
n_tup_upd, -- Всего обновлений
ROUND(100.0 * n_tup_hot_update / NULLIF(n_tup_upd, 0), 2) as hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0;
-- hot_pct > 70% → отлично
-- hot_pct < 20% → проблема (слишком много индексированных обновлений)
CLOG (pg_xact)
pg_xact/ — директория с файлами по 256 КБ
Каждая транзакция = 2 бита:
00 = IN_PROGRESS
01 = COMMITTED
10 = ABORTED
11 = SUB_COMMITTED
-- 4 транзакции в 1 байте
-- 256 КБ = 1 миллиард транзакций
-- Файлы удаляются после VACUUM + Freeze
XID Wraparound: глубокий анализ
-- 32-битный XID = 4,294,967,296 транзакций
-- Сравнение XID: циклическая арифметика
-- autovacuum делает "Freezing":
-- 1. Находит строки с xmin < freeze_limit
-- 2. Заменяет xmin на FrozenXID (специальное значение)
-- 3. FrozenXID всегда считается "в прошлом"
-- «Заморозка» = замена xmin на FrozenXID, который ВСЕГДА «в прошлом».
-- Замороженная строка видна любой транзакции. Как дата «1900 год» на
-- документе — гарантированно «старше» любой текущей транзакции.
-- Мониторинг:
SELECT
datname,
age(datfrozenxid),
ROUND(100.0 * age(datfrozenxid) / 2000000000, 2) as pct_to_wraparound
FROM pg_database;
-- > 1.5 млрд = 75% → критично!
-- 2 млрд = 100% → база READ ONLY
Edge Cases
- Long-running transactions
-- На реплике: hot_standby_feedback = on -- Реплика просит мастер не удалять строки, которые она читает -- → Мастер копит dead tuples → Bloat! - TOAST и MVCC
-- Большие поля (text, bytea) хранятся в TOAST-таблице -- TOAST-строки тоже версионируются -- Но TOAST не использует MVCC для чтения! -- → Может вернуть данные удалённой строки в редких случаях - Visibility Map
-- VM: битовая карта страниц, где ВСЕ строки видимы всем -- Используется для: -- 1. Index Only Scan (проверка видимости без Heap) -- 2. VACUUM (пропуск "чистых" страниц) -- Обновляется при VACUUM
Performance Impact
| Операция | MVCC влияние |
|---|---|
| SELECT | Не блокируется, создаёт Snapshot |
| INSERT | Создаёт новую строку (xmin = текущая) |
| UPDATE | Создаёт новую + помечает старую (xmax) |
| DELETE | Помечает строку (xmax = текущая) |
| VACUUM | Удаляет строки с xmax < oldest_active_xid |
Production Experience
Реальный сценарий #1: Bloat от длинной транзакции
- Аналитик открыл транзакцию и ушёл на обед (4 часа)
- VACUUM не мог удалить dead tuples
- Таблица раздулась с 10 ГБ до 80 ГБ
- Решение:
SELECT pg_terminate_backend(pid)→ VACUUM FULL
Реальный сценарий #2: Wraparound crisis
- autovacuum был отключён “для производительности”
- age(datfrozenxid) = 1.9 млрд (95%)
- База начала предупреждать в логах
- Решение: срочный
VACUUM FREEZEна всей БД - Урок: никогда не отключайте autovacuum!
Monitoring
-- 1. Dead tuples
SELECT relname, n_dead_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
-- 2. XID age
SELECT datname, age(datfrozenxid)
FROM pg_database
ORDER BY age DESC;
-- 3. Long transactions
SELECT pid, now() - xact_start as duration, state, query
FROM pg_stat_activity
WHERE xact_start < now() - INTERVAL '1 hour'
ORDER BY xact_start;
-- 4. HOT ratio
SELECT
relname,
ROUND(100.0 * n_tup_hot_update / NULLIF(n_tup_upd, 0), 2) as hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 1000
ORDER BY hot_pct ASC;
Best Practices
- Не держите транзакции открытыми дольше необходимости
- autovacuum — НЕ отключать! Настраивайте, но не выключайте
- fillfactor = 70-80 для часто обновляемых таблиц → больше HOT
- Индексируйте осознанно — каждый индекс убивает HOT для этой колонки
- Мониторьте age(datfrozenxid) — предотвращайте wraparound
- pg_repack для борьбы с Bloat без блокировки
- hot_standby_feedback = on → мониторьте Bloat на мастере
- VACUUM FULL только в maintenance window (блокирует!)
Резюме для Senior
- MVCC = версия строки =xmin/xmax, не блокирует чтение
- Hint Bits ускоряют повторные чтения без CLOG
- HOT = оптимизация UPDATE без обновления индексов
- SSI = SERIALIZABLE без блокировок чтения (PG уникален!)
- XID Wraparound — реальная угроза → мониторьте age()
- Длинные транзакции = главная причина Bloat
- VACUUM не уменьшает файлы → используйте pg_repack
- Visibility Map критичен для Index Only Scan
🎯 Шпаргалка для интервью
Обязательно знать:
- MVCC: каждая строка имеет xmin (создана) и xmax (удалена)
- Snapshot: транзакция видит строки, существовавшие на момент начала
- UPDATE = DELETE + INSERT (новая версия строки, старая → dead tuple)
- Hint Bits: 2 бита в заголовке строки → избегаем чтения CLOG после первого читателя
- HOT (Heap Only Tuple): UPDATE без обновления индексов (если колонка не индексирована + есть место на странице)
- SSI (Serializable Snapshot Isolation): SERIALIZABLE без блокировок чтения
- XID Wraparound: 32-бит → 4 млрд транзакций → autovacuum делает Freeze
- Visibility Map: битовая карта «чистых» страниц → Index Only Scan + VACUUM skip
- CLOG (pg_xact): 2 бита на транзакцию → IN_PROGRESS/COMMITTED/ABORTED
- Длинные транзакции → VACUUM не может удалить dead tuples → Bloat
Частые уточняющие вопросы:
- «Что будет, если отключить autovacuum?» → XID Wraparound → база перейдёт в READ ONLY
- «Почему VACUUM не сжимает файлы?» → Освобождает место для новых данных, не возвращает ОС
- «Как работает HOT?» → Новая версия на той же странице → индексы не обновляются
- «Что такое hot_standby_feedback?» → Реплика просит мастер не удалять строки → Bloat на мастере
Красные флаги (НЕ говорить):
- ❌ «VACUUM сжимает файлы таблиц» (нет, нужен VACUUM FULL или pg_repack)
- ❌ «autovacuum можно отключить для производительности» (Wraparound → READ ONLY!)
- ❌ «MVCC блокирует чтение при записи» (читатели не блокируют писателей!)
- ❌ «Hint Bits WAL-logged» (нет, после crash пересчитываются)
Связанные темы:
- [[Что такое VACUUM в PostgreSQL]] → очистка dead tuples
- [[Зачем нужен ANALYZE]] → сбор статистики
- [[Для чего нужны индексы]] → HOT и Visibility Map