PostgreSQL
MVCC: xmin, xmax, tuple versioning
SELECT читает 1000 строк. В это время UPDATE меняет 500 из них. SELECT не видит изменений и не ждёт. UPDATE не ждёт SELECT. Оба работают параллельно без единой блокировки. Это MVCC - фундамент высокой конкурентности PostgreSQL.
- **Instagram** (Postgres до миграции на Cassandra) обрабатывал 25M+ READ/сек параллельно с непрерывными writes на таблице media - без MVCC такая конкурентность потребовала бы read locks и уронила throughput на 80%
- **Notion** при массовых background migrations (изменение тысяч блоков) держит production reads незатронутыми - MVCC даёт пользователям старые версии блоков пока migration не закоммитит
- **Heroku Postgres** мониторит n_dead_tup через pg_stat_user_tables: при резком росте (>20% от live) - признак отложенного autovacuum, что говорит о нарастающем MVCC bloat
Tuple versioning - строка как список версий
В PG UPDATE не перезаписывает строку на месте. Вместо этого старая версия (tuple) помечается как удалённая, а новая вставляется рядом - в том же heap-файле. Обе версии физически существуют одновременно. Это позволяет разным транзакциям видеть разные версии одной строки без блокировок чтения.
После UPDATE строки в PG сколько физических версий этой строки существует в heap до VACUUM?
xmin и xmax - паспорт версии строки
Каждый heap tuple содержит два системных поля: xmin - transaction ID, который создал эту версию (INSERT или UPDATE создающий), xmax - transaction ID, который удалил эту версию (DELETE или UPDATE удаляющий). Если xmax = 0, строка ещё жива. Видимость строки для транзакции определяется: создавший (xmin) закоммитил AND (удаляющий (xmax) ещё не закоммитил OR xmax = 0).
Transaction IDs в PG - 32-битные счётчики. Когда txid достигает 2^32, происходит wraparound: старые txid могут стать 'в будущем' от точки зрения текущих. VACUUM FREEZE помечает старые строки специальным FrozenTransactionId чтобы они всегда были видны независимо от wraparound.
Строка имеет xmin=500, xmax=600. Транзакция T700 с snapshot (xmin=550, xmax=700) читает эту строку. Видна ли она?
Transaction snapshots - что видит транзакция
Snapshot - это описание состояния базы: `xmin:xmax:xip`. xmin = самый старый активный txid. xmax = следующий ещё не выданный txid. xip = список активных (in-progress) txid между xmin и xmax. Транзакция с txid < snapshot.xmin и закоммиченная - видна. Транзакция с txid >= snapshot.xmax - невидна (ещё не началась).
pg_export_snapshot() экспортирует snapshot как строку, которую другая транзакция может импортировать через SET TRANSACTION SNAPSHOT '...'. Это позволяет двум транзакциям работать с одинаковым видом данных - полезно для параллельного дампа или репликации.
Snapshot '100:105:101,103'. Видна ли транзакция с txid=102?
pg_xact (CLOG) - регистр статусов транзакций
pg_xact (ранее CLOG - Commit Log) - это bitmap, хранящий статус каждой транзакции: in-progress, committed, aborted, sub-committed. Каждая транзакция занимает 2 бита. Файлы лежат в `$PGDATA/pg_xact/`, каждый файл - 256 KB, покрывает 1M транзакций. VACUUM периодически удаляет старые файлы.
Hint bits - оптимизация: при первом обращении к tuple PG может прочитать pg_xact и записать результат прямо в заголовок tuple (t_informask). После этого повторный lookup в pg_xact не нужен. Hint bits объясняют почему SELECT может порождать dirty pages в shared_buffers.
Почему SELECT запрос может помечать страницы в shared_buffers как 'dirty' (изменённые)?
Visibility check - алгоритм видимости
При чтении heap tuple PG выполняет HeapTupleSatisfiesVisibility - проверку по snapshot. Алгоритм: проверить xmin закоммичен и xmin < snapshot.xmax и xmin не в xip, затем проверить что xmax = 0 или xmax не закоммичен или xmax > snapshot.xmin. Это происходит для каждого tuple при Seq Scan - один из аргументов в пользу индексов.
Visibility map - побитовый файл `$PGDATA/base/{dboid}/{relfilenode}_vm`. Один бит на страницу. Если бит = 1, все tuple на странице видимы любой транзакции - VACUUM не трогает страницу, Index Only Scan не идёт в heap.
MVCC блокирует читателей пока пишущая транзакция не закоммитит
Читатели в PG никогда не блокируют писателей и наоборот. Каждая транзакция работает со своим snapshot - старые tuple версии видны читателям пока MVCC это требует.
Именно в этом ценность MVCC: высокая конкурентность чтения-записи без read locks. Плата - dead tuples накапливаются и VACUUM нужен для их очистки.
Index Only Scan показал Heap Fetches: 0. Что это означает?
Ключевые идеи
- UPDATE не перезаписывает строку - создаёт новый tuple с xmin=txid, помечает старый xmax=txid; обе версии в heap до VACUUM
- Snapshot = xmin:xmax:xip - описание 'что закоммичено до этого момента'; видимость каждого tuple проверяется по snapshot через pg_xact
- Visibility map позволяет Index Only Scan пропускать heap-lookup для all-visible страниц - Heap Fetches: 0 в EXPLAIN ANALYZE
Связанные темы
MVCC - фундамент для нескольких других механизмов PG:
- VACUUM и сборка мусора — VACUUM удаляет dead tuples (старые MVCC-версии) которые больше не нужны ни одному snapshot - без VACUUM heap разрастается до бесконечности
- Уровни изоляции транзакций — Read Committed берёт snapshot на каждый оператор, Repeatable Read - один на транзакцию. Алгоритм видимости один и тот же, разница только в том когда snapshot фиксируется
- Индексы и Index Only Scan — Visibility map (часть MVCC) позволяет Index Only Scan не обращаться к heap - ключевая оптимизация для covering indexes
Вопросы для размышления
- Если транзакция живёт 1 час (например аналитический запрос), какие tuple версии PG не может удалить VACUUM за это время? Как это влияет на bloat?
- Hint bits записываются SELECT-ом в heap страницы. Почему это не нарушает гарантию 'читатели не блокируют писателей'?
- txid wraparound - что произойдёт если не делать VACUUM FREEZE и транзакционный счётчик переполнится?