MVCC — версионирование вместо блокировок
Предпосылки: ACID, страницы и кортежи, WAL.
← Буферный кеш | Аномалии транзакций →
Isolation гарантирует: параллельные транзакции не видят промежуточных состояний друг друга. Но реализация через блокировки чтения убивает производительность при высоком параллелизме.
Блокировки не масштабируются
Интернет-магазин электроники: 1000 пользователей одновременно листают каталог, 50 checkout-транзакций в секунду обновляют остатки товаров. Классический подход — читатель блокирует строку, пока читает. Checkout обновляет товар — блокирует строку на время транзакции (~100 мс). Если этот товар — популярный ноутбук, сотни пользователей ждут в очереди, чтобы просто увидеть его страницу. Время ответа взлетает с 50 мс до нескольких секунд.
Соотношение 1000 читателей к 50 писателям показывает: проблема не в записи. Проблема в том, что чтения сериализуются из-за блокировок, хотя читатели не меняют данные.
MVCC — версионирование вместо блокировок чтения
MVCC (Multi-Version Concurrency Control) решает эту проблему: вместо одной копии строки PostgreSQL хранит несколько версий. Каждый читатель видит ту версию, которая была актуальна на момент его «старта», не мешая писателям. Читатели не блокируют писателей, писатели не блокируют читателей. Блокируются только писатели между собой при записи в одну строку.
В сценарии магазина: пока checkout обновляет остаток ноутбука, 1000 пользователей продолжают видеть предыдущую версию строки с прежним stock. Никто не ждёт.
Три вопроса видимости
Чтобы версионирование работало, при каждом чтении tuple нужно ответить на три вопроса:
- КТО создал/удалил эту версию? (какая транзакция)
- ЗАВЕРШИЛАСЬ ли эта транзакция? (committed, aborted, или ещё выполняется)
- КОГДА она завершилась относительно меня? (до или после моего «старта»)
Без ответа на любой из вопросов MVCC не работает. PostgreSQL использует три структуры — каждая отвечает на свой вопрос:
flowchart TB T["<b>Tuple</b> (версия строки)<br>xmin = 120<br>xmax = 0<br>data = stock=10"] C["<b>CLOG</b> (pg_xact/)<br>XID 120 = ?"] S["<b>Snapshot</b> (в памяти транзакции)<br>xmin, xmax, xip"] T -->|"КТО создал/удалил?"| C C -->|"ЗАВЕРШИЛАСЬ ли? COMMITTED / ABORTED / IN_PROGRESS"| S S -->|"КОГДА относительно меня? До моего старта или после?"| Result["Видима / Невидима"]
Компонент 1: xmin/xmax — КТО создал и удалил версию
Каждая транзакция получает уникальный номер — XID (Transaction ID). Это 32-битный счётчик. Почему именно счётчик, а не timestamp? Timestamp ненадёжен: две транзакции могут начаться в одну микросекунду, часы на серверах расходятся. Счётчик прост и однозначен: транзакция 100 точно началась раньше 150.
Заголовок кортежа содержит два поля для MVCC: xmin хранит XID транзакции, создавшей эту версию строки, а xmax — XID транзакции, которая «удалила» версию (0, если никто не удалял и версия актуальна).
Вернёмся к магазину. Транзакция T100 добавила ноутбук в каталог (INSERT). Позже checkout-транзакция T150 обновила остаток:
UPDATE products SET stock = stock - 1 WHERE id = 42;
До UPDATE:
Tuple1 [xmin=100, xmax=0, stock=10]
После UPDATE (транзакция 150):
Tuple1 [xmin=100, xmax=150, stock=10] <- старая версия, «удалена» T150
Tuple2 [xmin=150, xmax=0, stock=9] <- новая версияUPDATE не меняет существующий tuple — он создаёт новую версию и помечает старую, записывая xmax=150. DELETE работает так же: ставит xmax, не создавая новую версию.
Проблема 32-битного счётчика: 2³² ≈ 4 миллиарда транзакций. Магазин с 50 checkout/сек и тысячами SELECT’ов расходует сотни XID в секунду. Когда счётчик переполняется — transaction ID wraparound: PostgreSQL начинает неправильно определять «раньше/позже», и данные «исчезают». Защита — «заморозка» (freezing) старых транзакций, одна из причин, почему VACUUM критически важен.
Компонент 2: CLOG — ЗАВЕРШИЛАСЬ ли транзакция
xmin=100 говорит КТО создал. Но закоммитилась ли транзакция 100? Если откатилась — версии «никогда не было».
Почему не хранить статус прямо в tuple? При COMMIT пришлось бы обновить ВСЕ затронутые строки. Checkout обновил 3 товара на разных страницах — 3 random write, терпимо. Но транзакция пакетного UPDATE, затронувшая миллион строк на разных страницах — миллион random write. HDD даёт ~100 IOPS (см. страницы и кортежи): миллион операций при 100 IOPS = 10,000 секунд ≈ 3 часа на один COMMIT. Плюс проблема атомарности: что если сервер упал посередине обновления флагов?
Решение: CLOG (Commit Log) — централизованная битовая карта в файлах pg_xact/. Каждая транзакция занимает 2 бита: IN_PROGRESS (00) — выполняется, COMMITTED (01) — закоммичена, ABORTED (10) — откатилась. При COMMIT — изменить 2 бита + fsync. Быстро и атомарно.
Компонент 3: Snapshot — КОГДА завершилась относительно меня
CLOG отвечает на вопрос «закоммичена ли?», но не на вопрос «когда именно?». А для изоляции это критично.
Покупатель Alice открывает страницу ноутбука (транзакция T115). Пока она читает характеристики, Carol оформляет checkout (T120) и покупает последний экземпляр — stock обновляется с 1 до 0, T120 коммитится. Alice нажимает «Обновить».
Timeline:
T=1 T=2 T=3
| | |
v v v
Alice стартует Carol (T120) Alice читает
(XID=115) делает COMMIT tuple с xmin=120Мир Alice «заморожен» на момент T=1 — она должна видеть данные, как они были тогда. T120 закоммитилась в T=2 — после старта Alice. CLOG говорит COMMITTED, но Alice не должна видеть stock=0 — этой версии не было в её мире.
Snapshot фиксирует состояние на момент старта транзакции, запоминая, какие транзакции были активны (не завершены) в момент его создания. Snapshot содержит три границы: xmin отмечает, что все транзакции с меньшим ID гарантированно завершились до старта; xmax — следующий XID, который будет назначен (транзакций с таким или большим ID ещё не существовало); xip[] — массив ID транзакций между xmin и xmax, которые были активны на момент создания snapshot.
Как три компонента работают вместе
Полная проверка видимости. Alice (snapshot: xmin=100, xmax=118, xip=[105, 110]) читает tuple с xmin=120 (stock=0 от Carol):
Шаг 1 (xmin/xmax): Кто создал версию?
Транзакция 120
Шаг 2 (Snapshot): Существовала ли транзакция 120 на момент старта Alice?
120 >= snapshot.xmax (118) -> НЕТ, ещё не существовала
-> НЕВИДИМ. Стоп.Alice видит старую версию (stock=1) — ту, которая существовала до checkout’а Carol. Мир Alice консистентен.
Другой пример — tuple с xmin=107 (обновление описания товара менеджером):
Шаг 1 (xmin/xmax): Кто создал версию?
Транзакция 107
Шаг 2 (Snapshot): Существовала ли?
107 < snapshot.xmax (118) -> ДА
Шаг 3 (Snapshot): Была ли активна на момент старта Alice?
107 в xip[]? -> НЕТ, значит уже завершилась ДО старта Alice
Шаг 4 (CLOG): Как завершилась?
COMMITTED -> ВИДИМ
ABORTED -> НЕВИДИМ (откатилась)Когда создаётся snapshot — вопрос политики
Snapshot можно создавать с разной частотой, и от этого зависит поведение транзакции.
Первый вариант — новый snapshot перед каждым оператором. Каждый SELECT видит свежие закоммиченные данные. Между двумя SELECT в одной транзакции можно увидеть изменения от других транзакций: Alice делает первый SELECT и видит stock=1, Carol коммитит checkout, Alice делает второй SELECT и видит stock=0.
Второй вариант — один snapshot на всю транзакцию. Все операторы видят одну «замороженную» картину мира. Даже если Carol закоммитит между двумя SELECT’ами Alice — Alice по-прежнему видит stock=1.
Это и есть разные уровни изоляции — политики использования snapshot. Каждый вариант даёт определённые гарантии и допускает определённые аномалии.
Hint bits — кэширование статуса CLOG
При каждой проверке видимости PostgreSQL обращается к CLOG. Каталог магазина — 100,000 товаров. Sequential scan по категории читает тысячи tuple, каждый требует обращения к CLOG. Если CLOG не в буферном кэше — random I/O к диску.
После первой проверки результат кэшируется прямо в заголовке tuple (поле t_infomask):
HEAP_XMIN_COMMITTED — создатель точно закоммичен
HEAP_XMIN_INVALID — создатель откатился
HEAP_XMAX_COMMITTED — удалитель точно закоммичен
HEAP_XMAX_INVALID — удалитель откатилсяСледующая транзакция видит hint bits и не ходит в CLOG.
Побочный эффект: SELECT может порождать запись. Установка hint bits меняет страницу, она становится dirty и в итоге должна быть записана на диск.
Цена MVCC: dead tuples
MVCC позволил 1000 читателям работать параллельно с 50 писателями без ожидания. Но за это приходится платить.
Старые версии строк не исчезают после UPDATE — они остаются на странице с заполненным xmax. PostgreSQL не может удалить их сразу: вдруг какая-то транзакция ещё видит эту версию через свой snapshot. Dead tuple — версия, невидимая ни одной активной транзакции. Мусор.
В магазине 50 checkout/сек, каждый обновляет 2–3 товара — это 100–150 dead tuples в секунду, ~500,000 за час. Если не убирать, таблица разбухает (bloat): sequential scan читает все страницы, включая набитые мёртвыми версиями. Индексы продолжают ссылаться на мусор. Запрос «все ноутбуки в наличии» читает 10,000 страниц вместо 3,000 — latency растёт с 50 мс до 150 мс.
VACUUM — процесс очистки dead tuples, обязательная часть эксплуатации PostgreSQL. Он освобождает место и продвигает relfrozenxid (защита от wraparound).
Snapshot решает вопрос видимости, но не все проблемы параллелизма. Два checkout’а одновременно читают stock=10, оба вычисляют «10 - 1 = 9», оба записывают 9. Должно остаться 8, осталось 9 — одна покупка потеряна. Snapshot показал каждой транзакции консистентную картину, но их комбинация дала некорректный результат. Аномалии транзакций описывают эти сценарии и как уровни изоляции защищают от них.
Sources
- PostgreSQL Documentation (пример: v16): MVCC и Transaction Isolation. https://www.postgresql.org/docs/16/mvcc.html, https://www.postgresql.org/docs/16/transaction-iso.html
- PostgreSQL Documentation (пример: v16): VACUUM и wraparound. https://www.postgresql.org/docs/16/routine-vacuuming.html