Базы данных
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-агентах используют тот же приём с версионированием контекста