Базы данных

MVCC: как БД обрабатывает параллельность

В Postgres `UPDATE accounts SET balance = balance - 100` не меняет строку. Он создаёт новую. Старая лежит рядом, помеченная как мёртвая. И так миллиарды раз в день - в каждом банке, в каждом стриминге, в каждой LLM-памяти агента.

  • **Amazon Black Friday 2014:** 6M транзакций/сек, MVCC дал линейный scale на чтение без локов
  • **Sentry 2015:** autovacuum отстал, txid wraparound почти убил прод - до катастрофы 11M транзакций
  • **Postgres под Discord:** триллионы версий строк в pg_history, autovacuum работает 24/7
  • **CockroachDB и Spanner:** distributed MVCC с HLC (hybrid logical clocks) - те же xmin/xmax, только распределённые

MVCC: писатели не блокируют читателей

Чёрная пятница 2014, Amazon. Шесть миллионов транзакций в секунду, и каждая хочет одновременно читать и писать одни и те же строки. Классические блокировки превратили бы это в пробку: один писатель - все читатели стоят. Postgres, MySQL InnoDB, Oracle, CockroachDB решают проблему одинаково - MVCC. Multi-Version Concurrency Control.

Идея до неприличия простая. UPDATE не перезаписывает строку. Он создаёт *новую версию*, а старую помечает как устаревшую с момента T. Каждая транзакция видит снимок данных на момент своего старта - и читает то, что было актуально *тогда*. Писатели и читатели больше не пересекаются на одной строке.

Это та же логика, что лежит под LoRA-адаптерами: базовые веса остаются неприкосновенны, новые версии накладываются сверху. Та же логика под Git: коммит не стирает прошлое, он добавляет новый snapshot. MVCC - это Git внутри строки таблицы.

**Ключевая инверсия:** в pessimistic concurrency читатель ждёт писателя. В MVCC читатель никогда не ждёт - он смотрит на старую версию, которая лежит рядом и видна именно ему.

Почему в MVCC SELECT не блокируется UPDATE'ом на той же строке?

xmin/xmax: метки рождения и смерти строки

Postgres хранит у каждой строки два числа: `xmin` - ID транзакции, которая её создала, и `xmax` - ID транзакции, которая её удалила или обновила. По этим двум числам база решает, видна ли строка конкретной транзакции.

Правило простое до жестокости. Строка видна транзакции T, если: `xmin` уже закоммичен и меньше или равен снимку T, а `xmax` либо пустой, либо ещё не закоммичен, либо больше снимка T. Три условия, одна операция сравнения. Та же арифметика, что в Adam смотрит на $\beta_1, \beta_2$ - три параметра решают, что усреднять.

UPDATE на самом деле - это INSERT + помеченный DELETE. Старая строка остаётся, у неё проставляется `xmax`. Новая строка получает свежий `xmin`. На диске мгновенно живут две версии. Те, кто стартовал до UPDATE, видят старую. Те, кто стартовал после COMMIT, видят новую.

**Цена видимости:** каждая строка таскает 23-байтный заголовок с xmin, xmax, флагами и указателями. Поэтому UPDATE в Postgres - это всегда INSERT новой строки, даже если меняется один байт. Та же причина, по которой LoRA добавляет матрицу, а не правит исходные веса - дёшево хранить, дорого менять.

UPDATE в Postgres физически означает:

Snapshot Isolation: каждый видит свою реальность

Когда транзакция стартует, Postgres делает снимок: список *активных* транзакций на этот момент. Этот снимок - паспорт видимости. Любая строка, чей `xmin` принадлежит закоммиченной до старта транзакции - видна. Чей `xmin` принадлежит активной или будущей - невидима.

Это и есть Snapshot Isolation - уровень изоляции, который Postgres называет REPEATABLE READ, а Oracle называет SERIALIZABLE. Названия врут, реализации одинаковы. Чтение всегда видит данные, какими они были в момент BEGIN.

Аналогия из RLHF: policy snapshot замораживается перед каждым PPO-шагом, чтобы оценивать advantage относительно стабильной базы. То же самое в MVCC: транзакция замораживает реальность и работает в ней, пока не сделает COMMIT.

**Что Snapshot Isolation не ловит:** write skew. Две транзакции читают непересекающиеся снимки, обе пишут что-то взаимоисключающее, обе коммитятся. Классический пример - два врача снимают дежурство, видя, что 'другой остаётся'. Postgres лечит это через SERIALIZABLE (SSI - Serializable Snapshot Isolation), Михаэль Кэхилл, 2008.

В чём принципиальная разница между REPEATABLE READ и READ COMMITTED в Postgres?

VACUUM: уборщик мёртвых версий

Каждый UPDATE оставляет труп старой версии. Каждый DELETE - тоже труп. Без уборки таблица раздувается. В реальном проекте на 100 GB heap половина может быть мёртвыми версиями - bloat. Запросы тормозят, потому что индекс ведёт на пустые слоты.

VACUUM проходит по heap, находит версии, чей `xmax` закоммичен и меньше любого активного снимка, и помечает их слоты свободными. Не удаляет физически - возвращает место в free space map. Следующий INSERT использует эти слоты. Аналог mark-and-sweep GC в Java или generational GC в V8.

Autovacuum - демон, который запускает VACUUM сам, ориентируясь на счётчик мёртвых кортежей. Та же логика, что в gradient checkpointing - не освобождать сразу, а ждать, пока накопится критическая масса, и выпустить разом.

**Самый частый production-крах:** autovacuum не успевает за write-load. Bloat растёт, performance падает, кто-то запускает `VACUUM FULL` в пиковое время - и блокирует таблицу на час. GitLab 2017, Sentry 2019, десятки postmortem'ов с одной и той же причиной.

Почему обычный VACUUM не возвращает место операционной системе?

Transaction ID wraparound: бомба в 32 битах

Postgres идентифицирует транзакции 32-битным счётчиком. 4.3 миллиарда значений - звучит много. Не много. На нагрузке 10 тысяч транзакций в секунду счётчик переполняется за пять дней.

При переполнении транзакция T+1 получит ID, который численно меньше всех существующих xmin. Если бы не было защиты, все строки в базе мгновенно стали бы 'из будущего' - невидимыми. База превратилась бы в кирпич за миллисекунды.

Поэтому VACUUM делает второе невидимое дело: *freezing*. Когда xmin строки старше горизонта (по умолчанию 200 миллионов), он заменяется на специальную метку FrozenXID - означает 'видна всем, всегда'. Замороженные строки уже не считаются принадлежащими какой-то конкретной транзакции.

**Sentry, 2015:** мониторинг не сработал, autovacuum отстал, до wraparound оставалось 11 миллионов транзакций. Postgres перешёл в read-only mode на проде. Скандал, постмортем, теперь параметр `autovacuum_freeze_max_age` мониторят все.

MVCC - это про производительность чтения, ничего особенного под капотом не происходит

MVCC - это компромисс: дешёвые чтения ценой раздутия диска и обязательного фонового GC

Каждый UPDATE создаёт новую версию, каждое 'обычное' чтение требует проверки видимости через xmin/xmax/snapshot. Без VACUUM база умирает от bloat, без freeze - от wraparound. MVCC не бесплатна - её стоимость отложена и оплачивается фоновыми процессами, как amortized cost в анализе алгоритмов

Что произойдёт с базой Postgres, если счётчик транзакций переполнится без своевременной заморозки?

Связанные темы

Куда MVCC ведёт дальше:

  • Транзакции и ACID — MVCC - механизм реализации Isolation без блокировок чтения
  • Блокировки — MVCC снимает блокировки чтения, но writers всё ещё блокируют writers
  • Distributed transactions — CockroachDB и Spanner расширяют MVCC до распределённого случая через HLC
  • Vector clocks — Версии xmin/xmax - частный случай логических часов внутри одной БД

Ключевые идеи

  • MVCC: писатели не блокируют читателей за счёт хранения нескольких версий строки
  • xmin/xmax - метки рождения и смерти, по ним определяется видимость для каждой транзакции
  • Snapshot Isolation - каждая транзакция видит снимок данных на момент BEGIN
  • VACUUM убирает мёртвые версии, autovacuum делает это в фоне - без него bloat убивает прод
  • TXID wraparound - бомба в 32-битном счётчике, требует обязательной заморозки старых строк

Вопросы для размышления

  • Какие операции в собственном приложении генерируют больше всего bloat и почему?
  • Какой уровень изоляции уместен для финансовых переводов и почему не подойдёт READ COMMITTED?
  • Как бы выглядел MVCC в распределённой БД, где xmin одной ноды ничего не значит для другой?

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

  • db-13-transactions — MVCC живёт внутри транзакционной модели ACID
  • db-15-locks — MVCC заменяет блокировки чтения - сравнение подходов
  • db-03-acid — Isolation - буква I в ACID, MVCC её реализует
  • os-05-sync — RCU в ядре Linux - тот же приём с версиями вместо блокировок
  • ds-08-vector-clocks — Версии строк - локальный аналог логических часов в одной БД
  • aie-15-conversation-memory — Снапшоты разговора в LLM-агентах используют тот же приём с версионированием контекста
MVCC: как БД обрабатывает параллельность

0

1

Войти