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 и транзакционный счётчик переполнится?

Связанные уроки

  • db-03-acid
MVCC: xmin, xmax, tuple versioning

0

1

Войти