MVCC (Multi-Version Concurrency Control) concept page. Каждая строка хранит несколько версий (xmin/xmax). Транзакции читают консистентный snapshot на момент start. Postgres, Oracle UNDO, SQL Server RCSI, MySQL InnoDB UNDO log, Spanner timestamp KV. Цена: bloat, VACUUM/cleanup overhead, txid wraparound risk при long-running txn. 4 сценария: stable snapshot read, write skew under SI, autovacuum cleanup + bloat от long-running txn, txid wraparound катастрофа. ADR: MVCC vs locking-based READ COMMITTED (Postgres vs SQL Server до RCSI).</description> <parameter name="name">MVCC: Multi-Version Concurrency Control
Классическая проблема OLTP: десятки/тысячи параллельных транзакций читают и пишут одни и те же строки. Альтернативы две.
Locking (классический SQL Server до 2005, MySQL MyISAM, любой "учебный" DB): читатель берёт shared lock, писатель — exclusive. Читатели блокируют писателей, писатели блокируют читателей. 80% production-инцидентов формата "query висит 5 минут" родом отсюда: длинный SELECT держит S-lock → UPDATE стоит в очереди → за ним стоит всё приложение.
MVCC (Postgres, Oracle, MySQL InnoDB, SQL Server с RCSI=ON, Spanner, CockroachDB): каждая строка хранит несколько версий. Каждая транзакция видит snapshot базы на момент своего start. Читатели вообще не блокируют писателей. Цена — bloat, VACUUM, потенциальный TXID wraparound и подкласс аномалий (write skew).
MVCC выиграл индустрию для read-heavy OLTP. Понимать его обязательно — особенно потому, что 90% реальных Postgres-инцидентов это либо idle in transaction killing autovacuum, либо bloat, либо wraparound-алерт в 3 ночи.
"Каждый row — это immutable версия, помеченная
xmin(кем создана) иxmax(кем удалена / заменена). Транзакция T со своим snapshot видит row, еслиxminуже закоммичен ДО неё, аxmaxлибо отсутствует, либо закоммичен ПОСЛЕ. Читатели никогда не пишут в данные — только смотрят на них через snapshot-фильтр. Writers никогда не модифицируют существующие версии — только добавляют новые."
Аналогия: вместо одной доски, которую все стирают по очереди, у каждого писателя своя страничка с подписью "от такого-то". Читатель приходит со списком "я доверяю подписям 1..100" и берёт самую свежую страничку из доверенных. Уборщик (VACUUM) периодически выкидывает странички, которые больше никому не нужны.
Группа Postgres (MVCC) содержит три ключевых компонента:
xid новым транзакциям, фиксирует snapshot (множество committed xids на момент BEGIN), хранит clog (committed log).xmin (xid создателя), xmax (xid удалившего/заменившего), плюс пользовательские поля.Три клиента — T1 Reader (долгий SELECT), T2 Writer A, T3 Writer B — все ходят через Tx Manager. Это не сетевая топология, а роли в concurrency-протоколе: видно, что писатели и читатели проходят через один и тот же компонент, который фильтрует видимость по snapshot.
snapshot-stable-read — happy path MVCC. T1 начинает долгий SELECT в t=0, фиксирует snapshot {xmax_known=100}. Параллельно T2 апдейтит ту же строку: heap получает старую версию с xmax=101 плюс новую версию с xmin=101. T1 продолжает scan и натыкается на эту строку — видит ДВЕ версии и выбирает старую (xmin=80 ≤ 100, xmax=101 > snapshot ⇒ visible). Главное: T1 не остановился ни на миллисекунду, T2 успел закоммитить и уйти. Это и есть "readers don't block writers".
write-skew-under-SI — тёмная сторона. Postgres REPEATABLE READ = snapshot isolation. SI прекрасна для одиночных аномалий, но НЕ серилизуема. Классика: бизнес-инвариант "хотя бы один доктор on-call". T2 проверяет count(*) WHERE on_call=true → видит 2, решает увести Alice. T3 параллельно делает ТО ЖЕ — видит свой snapshot, тоже 2, уводит Bob. Оба коммитят разные строки → конфликта на row-level нет → SI пропускает оба коммита → инвариант сломан, on_call=0. Лечение: либо SERIALIZABLE (SSI — Postgres детектит конфликт через predicate locks), либо явный SELECT FOR UPDATE на predicate row.
vacuum-cleanup — почему dead tuples не лежат вечно и почему idle in transaction это P1. T2/T3 апдейтят 20K строк → 20K dead tuples. Autovacuum просыпается по n_dead_tup / n_live_tup > scale_factor, проверяет каждую: xmax committed && xmax < oldest_active_xid ⇒ reclaim. Всё хорошо. Но потом T1 делает BEGIN; SELECT * FROM huge_table и забывает COMMIT на 30 минут. Параллельно T2 апдейтит 1M строк. Autovacuum хочет почистить — но oldest_active_xid=100 (T1 всё ещё жив, мог бы видеть эти версии), и ни одна dead tuple не может быть удалена. Bloat растёт до десятков ГБ, диск кончается, perf падает. Алерт: pg_stat_activity WHERE state='idle in transaction' AND age(xact_start) > 5min.
txid-wraparound — самый страшный сценарий Postgres. xid — 32-bit, ~4B значений. Postgres использует modular arithmetic: если diff > 2B, xid считается "из будущего" → невидим. Чтобы старые tuples не "пропали из будущего", autovacuum периодически делает FREEZE (пишет в xmin специальное значение FrozenXID, что значит "видимо всегда"). Если autovacuum заблокирован (см. предыдущий сценарий) или явно выключен на горячей таблице — wraparound приближается. На 1M xids до катастрофы Postgres переходит в single-user mode, отказывается принимать команды, и единственный способ вернуть базу — postgres --single, VACUUM FREEZE, рестарт. Несколько публичных post-mortem'ов крупных компаний именно про это (Sentry 2015, Mailchimp).
| Размерность | MVCC (Postgres / InnoDB / RCSI) | Locking RC (классика) |
|---|---|---|
| Reader vs writer | Не блокируют друг друга | Взаимно блокируют (S/X locks) |
| Snapshot isolation | Бесплатно (REPEATABLE READ) | Нет; либо RR через держание locks |
| Read-heavy workload | Отлично (отчёты не вешают writers) | Плохо (длинный SELECT = очередь) |
| Write-heavy | Хорошо, но нужен autovacuum-tuning | Хорошо (нет bloat) |
| Storage overhead | Bloat от dead tuples, +VACUUM I/O | Минимальный |
| Аномалии | Write skew под SI (нужен SERIALIZABLE) | Lost updates, phantom reads, deadlocks |
| Операционная сложность | Мониторинг bloat, n_dead_tup, wraparound | Мониторинг lock waits, deadlocks |
| Long-running txn | Фризит VACUUM → bloat растёт | Держит locks → блокирует всех |
ADR-001: MVCC (Postgres) для нового OLTP. Lock-free reads — главный аргумент: read-heavy aналитика и отчёты не повесят production writes. Цена — операционная дисциплина: алерты на idle in transaction > 5min, мониторинг pg_stat_user_tables.n_dead_tup, pg_stat_database.datfrozenxid (алерт на age > 1.5B), per-table autovacuum-tuning на горячих таблицах. Для extreme-write-heavy партиционированных таблиц (log/audit) — партиционирование с DROP PARTITION вместо DELETE+VACUUM, либо pg_repack. Чисто lock-based RC оправдан только в legacy SQL Server 2000 без выбора.
ORA-01555 snapshot too old — знаменитая ошибка, когда UNDO перезаписан до того, как долгая транзакция успела прочитать старую версию.READ_COMMITTED_SNAPSHOT (RCSI): MVCC через version store в tempdb. Современная рекомендация — включать RCSI по умолчанию.BEGIN; ... wait 30 min ...; COMMIT — single most common Postgres P1. Любая транзакция, висящая дольше нескольких минут, фризит autovacuum → bloat. Особенно опасно с connection pooler'ами (PgBouncer), где приложение может "забыть" про транзакцию.SERIALIZABLE, либо SELECT ... FOR UPDATE на predicate.DROP PARTITION, либо batched delete с COMMIT между батчами.hot_standby_feedback — replication conflict, либо отмена query, либо bloat на primary.SELECT FOR UPDATE всех строк подряд "на всякий случай". Уничтожает преимущество lock-free reads, возвращает к locking RC проблемам.MVCC — это про многоверсионность ради concurrency. Не нужно, когда: