MAATRIX / Блог / MVCC: почему две транзакции одновременно видят разные версии одной строки

MVCC: почему две транзакции одновременно видят разные версии одной строки

MAATRIX

Открываете два окна psql, в одном обновляете строку и коммитите, а в другом — она как будто не менялась. Никакой ошибки, никакого предупреждения, просто два клиента видят разные значения одного и того же id. Первая мысль — реплика отстала или закешировался старый результат. На деле это база работает штатно: так устроен MVCC (Multi-Version Concurrency Control), механизм, на котором держится параллельная работа с данными почти во всех современных СУБД.

Проблема, которую решает MVCC

Самый простой способ не дать читателю увидеть половину незакоммиченной записи — заблокировать таблицу или строку на время записи. Пока транзакция пишет, все остальные, кто хочет её прочитать, ждут. Это надёжно, но убивает параллелизм: аналитический отчёт, читающий таблицу заказов, встанет в очередь за каждым UPDATE, а под нагрузкой очередь читателей растёт быстрее, чем база успевает её разгребать.

Ранние СУБД так и делали — блокировка чтения на запись и записи на чтение. Проблема вскрылась быстро: в системе, где параллельно идут сотни коротких транзакций, даже секундная блокировка чтения превращается в лавину ожиданий. Решение, которое стало стандартом де-факто (PostgreSQL, MySQL/InnoDB, Oracle, SQL Server в режиме RCSI), — не блокировать читателей вообще. Вместо этого база хранит несколько версий одной и той же строки одновременно, и каждая транзакция получает ту версию, которая была актуальна для неё, не мешая остальным.

Отсюда и название: Multi-Version — множество версий одной строки сосуществуют в таблице параллельно, и Concurrency Control — способ управлять тем, кто какую версию видит, без блокировок на чтение.

Что на самом деле хранится в таблице

Строка в PostgreSQL — это не просто набор столбцов. У каждой физической версии строки есть служебные поля, которые обычно скрыты от SELECT *, но их можно запросить явно:

SELECT xmin, xmax, ctid, id, balance
FROM accounts
WHERE id = 42;
  • xmin — номер транзакции, которая создала эту версию строки (вставила её или создала как результат UPDATE).
  • xmax — номер транзакции, которая эту версию строки "закрыла" (удалила или заменила новым UPDATE). Пока строка актуальна, xmax равен нулю.
  • ctid — физический адрес версии строки на странице (номер страницы, номер слота).

Когда вы делаете UPDATE accounts SET balance = balance - 100 WHERE id = 42, PostgreSQL не переписывает данные на месте. Он:

  1. Помечает старую версию строки как закрытую — проставляет ей xmax = <номер вашей транзакции>.
  2. Создаёт новую физическую версию строки с новым значением balance, новым ctid и xmin = <номер вашей транзакции>.
  3. Старая версия остаётся на странице — она никуда физически не делась, просто помечена как невидимая для будущих транзакций.

Так одна логическая строка (один id) в моменте может существовать как несколько физических версий на диске. Какую из них увидит конкретный запрос — решает не факт существования строки, а правило видимости, привязанное к снапшоту транзакции.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Снапшот: что именно "видит" транзакция

Когда транзакция начинается (в PostgreSQL — по умолчанию при первом операторе, если не считать SERIALIZABLE/REPEATABLE READ, где это происходит в момент первого SELECT), она фиксирует снапшот — по сути список: какие транзакции уже завершены и закоммичены, а какие ещё выполняются или были отменены.

Дальше при чтении любой версии строки применяется простое правило:

  • Версия видима, если её xmin принадлежит транзакции, которая на момент снапшота уже закоммичена, и при этом xmax либо не проставлен, либо принадлежит транзакции, которая на момент снапшота ещё не закоммичена (или откачена).
  • Версия невидима, если её создала транзакция, ещё не завершённая на момент снапшота, или если её уже "закрыла" транзакция, завершённая раньше снапшота.

Отсюда и эффект из первого абзаца. Транзакция A открылась в 12:00:00 и зафиксировала снапшот. Транзакция B открылась в 12:00:01, обновила строку и закоммитилась в 12:00:02. Транзакция A всё ещё делает SELECT в 12:00:05 — и видит старую версию строки, потому что её снапшот был зафиксирован до коммита B. Это не баг и не задержка репликации — так и должно работать, пока транзакция A не завершится и не откроет новую.

Важный нюанс: сам снапшот — это не копия данных. Это компактная структура (список "открытых" на тот момент ID транзакций плюс границы), которая просто участвует в проверке видимости каждой строки при чтении. Копирования таблицы не происходит — физически на диске всегда только фактические версии строк, разбросанные по разным транзакциям.

Почему это даёт настоящий параллелизм

Прямое следствие модели "снапшот + версии" — читатели никогда не блокируют писателей, а писатели никогда не блокируют читателей. SELECT не ставит блокировку на строку в принципе — ему это не нужно, он просто выбирает подходящую по правилам видимости версию. Параллельно идущий UPDATE создаёт новую версию, не трогая ту, которую сейчас читает кто-то другой.

Это резко отличается от модели с блокировками чтения, где долгий отчётный SELECT мог бы держать таблицу заблокированной для записи, пока не завершится. В MVCC долгий аналитический запрос просто продолжает видеть свой снапшот — консистентный "срез" базы на момент старта — сколько бы параллельных изменений ни происходило вокруг.

Блокировки в PostgreSQL никуда не делись — они нужны для другого класса конфликтов: двух UPDATE на одну и ту же строку, которые физически не могут применяться одновременно (см. отдельный разбор — почему дедлоки случаются каждый вторник). Но чтение из этой борьбы полностью выведено — и это главный практический выигрыш MVCC.

Уровни изоляции — это настройка того, когда обновляется снапшот

MVCC определяет механизм версионирования, но то, насколько "свежим" будет видимый снапшот, зависит от уровня изоляции транзакции:

Уровень изоляцииКогда фиксируется снапшотЧто видно
Read Committed (по умолчанию в PostgreSQL)Заново перед каждым операторомКаждый SELECT внутри транзакции видит все данные, закоммиченные до его старта — даже если другой SELECT минутой раньше в той же транзакции видел другое
Repeatable ReadОдин раз, при первом запросе транзакцииВсе SELECT внутри транзакции видят один и тот же снапшот от начала до конца, сколько бы коммитов ни произошло вокруг
SerializableКак Repeatable Read, плюс дополнительная проверка конфликтов сериализацииТо же, что Repeatable Read, но база дополнительно отслеживает зависимости между транзакциями и откатывает одну из них с ошибкой could not serialize access, если результат параллельного выполнения не эквивалентен последовательному

Именно поэтому "два окна видят разное" может проявляться по-разному в зависимости от уровня. На Read Committed транзакция A, сделав SELECT дважды с паузой, во второй раз уже увидит изменения B, если B успела закоммититься между двумя SELECT. На Repeatable Read — не увидит ни разу, пока сама не закоммитится и не откроет новую транзакцию.

Проверить и выставить уровень:

SHOW default_transaction_isolation;

BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 42;
-- параллельно кто-то делает UPDATE и COMMIT
SELECT balance FROM accounts WHERE id = 42; -- увидите то же значение, что и в первый раз
COMMIT;

Цена MVCC: мёртвые версии строк никуда не деваются сами

У версионирования есть обратная сторона. Старая версия строки после UPDATE или DELETE физически остаётся на странице — база не может удалить её немедленно, потому что где-то может быть ещё открытая транзакция со старым снапшотом, для которой именно эта версия строки является актуальной. PostgreSQL называет такие версии dead tuples — "мёртвые кортежи": логически удалённые, физически ещё лежащие на диске.

Если таблица активно обновляется, dead tuples накапливаются быстрее, чем кажется — таблица с интенсивными UPDATE может физически расти в объёме, даже если количество живых строк в ней не меняется. Проверить масштаб проблемы:

SELECT relname, n_live_tup, n_dead_tup,
       round(n_dead_tup::numeric / GREATEST(n_live_tup, 1), 3) AS dead_ratio
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

За уборку мёртвых версий отвечает VACUUM — процесс, который проходит по таблице, находит версии строк, невидимые уже ни для одной активной транзакции, и помечает занимаемое ими место как свободное для повторного использования (не обязательно возвращая место операционной системе — это делает только VACUUM FULL, который блокирует таблицу целиком и потому используется точечно, а не в рутинном режиме). В штатной эксплуатации этим занимается фоновый autovacuum, срабатывающий по порогам из autovacuum_vacuum_scale_factor и autovacuum_vacuum_threshold.

Если autovacuum не успевает за темпом обновлений — например, из-за долго открытой транзакции, которая держит снапшот и не даёт очистить строки, актуальные для неё, — dead tuples копятся, таблица раздувается (bloat), а планировщик запросов начинает читать больше страниц, чем реально нужно для живых данных. Это ровно тот сценарий, который разобран в статье про распухание базы при удалении и в разборе медленного VACUUM — стоит прочитать их вместе с этой, если вы уже видите растущий n_dead_tup.

Отдельно стоит горизонт транзакций по ID: xmin/xmax — это 32-битные счётчики, и при их исчерпании (wraparound) без вмешательства VACUUM база теоретически может перестать различать "старое" и "новое". На практике PostgreSQL заранее защищается от этого автоматическим агрессивным VACUUM FREEZE задолго до реального исчерпания, но если долго держать autovacuum выключенным вручную, до предупреждений в логе лучше не доводить.

MySQL/InnoDB и другие СУБД: тот же принцип, другая реализация

Если вы работаете и с MySQL, механизм видимости у InnoDB устроен иначе физически, но по смыслу решает ту же задачу. InnoDB хранит не отдельные версии строк на странице, а undo-логи: текущая строка на странице всегда одна, а прошлые версии восстанавливаются на лету из undo-сегмента, когда транзакции со старым снапшотом нужно прочитать "как было". Отсюда и разная цена: в PostgreSQL растёт объём самой таблицы (dead tuples), в InnoDB — объём undo-логов и, в критичных случаях, ibdata/undo tablespace, если долгая транзакция держит снапшот открытым слишком долго ("history list length" в InnoDB — прямой аналог n_dead_tup).

Если вы недавно перешли с MySQL на PostgreSQL или наоборот, эта разница в физическом устройстве — частый источник путаницы с тем, "куда девается место". Разбор конкретных отличий поведения — в статье про подводные камни при переходе с MySQL на PostgreSQL.

Сам принцип — снапшот на старте (или на каждый оператор), проверка видимости по номерам транзакций, отложенная физическая уборка — общий для всех MVCC-СУБД, поэтому если вы разобрались в модели PostgreSQL, читать план поведения InnoDB, Oracle или SQL Server в режиме read committed snapshot isolation становится сильно проще: меняются названия полей и детали реализации, а не сама идея.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Значит ли "разные версии видны параллельно", что данные могут быть неконсистентны?

Нет — каждая отдельная транзакция всегда видит консистентный снапшот: набор данных, каким он был на зафиксированный момент времени, без "половинчатых" изменений от чужих незакоммиченных транзакций. Разные транзакции видят разные снапшоты, но каждый снапшот сам по себе целостен.

Можно ли отключить MVCC и вернуться к простым блокировкам чтения?

В PostgreSQL — нет, это фундаментальная часть архитектуры хранения, а не переключаемая опция. Единственное, чем можно управлять, — уровнем изоляции транзакции и явными блокировками (SELECT ... FOR UPDATE, LOCK TABLE) для конкретных операций, где нужна более строгая гарантия, чем даёт снапшот.

Почему VACUUM FULL требует блокировки, если обычный VACUUM — нет?

Обычный VACUUM помечает место мёртвых версий как свободное для повторного использования внутри той же таблицы, не трогая физический порядок строк, и может идти параллельно с чтением и записью. VACUUM FULL физически пересобирает таблицу в новый файл, чтобы реально вернуть место операционной системе, а для этого ему нужна эксклюзивная блокировка на всё время операции.

Как понять, что причина "тормозов" — это именно накопленные мёртвые версии, а не что-то другое?

Смотрите n_dead_tup и dead_ratio из pg_stat_user_tables (запрос выше) и pg_stat_activity на предмет долгих открытых транзакций (state != 'idle' и xact_start в прошлом на часы) — именно они чаще всего мешают autovacuum дочистить строки, которые формально уже никому не нужны.

Влияет ли количество версий строки на скорость обычного SELECT?

Да — если строку обновляют часто, а старые версии не убираются вовремя, SELECT вынужден пропускать (проверять видимость и отбрасывать) больше мёртвых версий на странице, прежде чем найти нужную. Это один из механизмов, из-за которых bloat напрямую превращается в деградацию latency, а не только в лишнее место на диске.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →