Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → MVCC: чтение без блокировок записи

MVCC: как базы данных читают данные, не мешая записи

MVCC: версии строк, снапшоты транзакций, откатные сегменты, цена в виде bloat и отличие от блокировочных моделей.

Прямой ответ

MVCC (multi-version concurrency control, многоверсионный контроль одновременного доступа) — подход, при котором запись не блокирует чтение, а чтение не блокирует запись. Вместо изменения строки на месте база создаёт новую версию, а старые версии остаются доступными для транзакций, которые начались раньше.

Как это работает:

  1. Каждая строка имеет несколько версий. У версии есть метка транзакции, которая её создала, и метка транзакции, которая её удалила или заменила (если такая была).
  2. Транзакция видит снапшот. В начале транзакции (или каждого оператора, в зависимости от уровня изоляции) фиксируется набор активных транзакций. Строка видна, если её создала завершённая к тому моменту транзакция и ещё не удалила.
  3. Читатель не ждёт писателя. Если другая транзакция изменила строку, читатель просто берёт предыдущую видимую версию.
  4. Писатель конфликтует только с писателем. При попытке изменить одну и ту же строку вторая транзакция ждёт завершения первой, после чего либо продолжает, либо прерывается с ошибкой сериализации.

Реализации различаются местом хранения старых версий:

  • PostgreSQL хранит все версии в самой таблице; устаревшие строки помечаются и удаляются механизмом VACUUM.
  • MySQL InnoDB хранит текущую версию в таблице, а старые — в откатном сегменте (undo log).
  • Oracle использует сегменты отката, аналогично InnoDB.

Следствие выбора PostgreSQL: обновление создаёт новую строку в той же таблице, поэтому интенсивные обновления приводят к разрастанию таблицы и индексов. Это и есть bloat, который требует работы VACUUM — см. VACUUM и autovacuum.

-- видимость зависит от уровня изоляции
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 42;  -- снапшот на момент начала транзакции
-- даже если другая транзакция изменит баланс и закоммитится,
-- этот SELECT вернёт прежнее значение
COMMIT;

Практика: следствия для разработчика

1. Уровни изоляции определяют момент снимка.

Уровень Снимок Аномалии
READ COMMITTED На каждый оператор Неповторяющееся чтение возможно
REPEATABLE READ На транзакцию Фантомы в PostgreSQL исключены
SERIALIZABLE На транзакцию + контроль зависимостей Конфликты завершаются ошибкой

Практические детали и выбор уровня — в статье про транзакции и изоляцию.

2. Долгие транзакции вредят. Пока транзакция открыта, старые версии строк нельзя удалить. Одна забытая транзакция на несколько часов приводит к росту таблицы и к тому, что VACUUM не может очистить место. Мониторинг возраста самых старых транзакций — обязательная метрика.

3. UPDATE дороже, чем кажется. Обновление одной колонки в PostgreSQL перезаписывает всю строку целиком. Для широких таблиц с частыми обновлениями это означает большой объём записи и быстрый рост индексов, если индексируемые колонки не объявлены как покрываемые HOT-обновлением.

4. Сериализация может завершаться ошибкой. На уровне SERIALIZABLE конфликтующие транзакции прерываются с кодом serialization failure. Приложение обязано обрабатывать эту ошибку повтором — это нормальный сценарий, а не сбой.

5. Count(*) не бесплатный. Число строк нельзя взять из метаданных, потому что видимость зависит от транзакции: движок обязан проверить каждую строку. Для больших таблиц точный счёт — дорогая операция.

6. Гарантии долговечности — отдельный механизм. MVCC отвечает за видимость, а за сохранность при сбое — журнал предзаписи; см. WAL и общие свойства ACID.

Рекомендации:

  1. Держите транзакции короткими. Правило простое: ничего внешнего (HTTP-вызовов, ожидания пользователя) внутри транзакции.
  2. Следите за autovacuum. Для таблиц с интенсивными обновлениями стандартные пороги могут не успевать; их настраивают на уровне таблицы.
  3. Проверяйте влияние на индексы. Каждое обновление может добавлять записи в индексы. Для часто обновляемых колонок стоит пересмотреть необходимость индекса.
  4. Обрабатывайте ошибки сериализации. Повтор с экспоненциальной задержкой — стандартная практика.
  5. Не смешивайте аналитику и OLTP. Долгий аналитический запрос удерживает старые версии; для больших выборок лучше использовать реплику.

Практический сценарий: таблица выросла в три раза при стабильном числе строк

Контекст: таблица сессий занимала 12 ГБ при 8 млн строк. Ожидалось около 3 ГБ.

  1. Диагностика. Число «мёртвых» кортежей по статистике было высоким; возраст самых старых активных транзакций — более шести часов.
  2. Причина 1. Воркер очистки открывал транзакцию, затем выполнял внешний HTTP-вызов с таймаутом до пяти минут. На время вызова транзакция оставалась открытой и удерживала старые версии.
  3. Причина 2. Сессии обновлялись при каждом запросе (поле last_seen_at), что создавало новую версию строки на каждый хит.
  4. Действия. (а) Транзакцию сузили до одной операции обновления, вызов вынесли наружу; (б) last_seen_at обновили не чаще раза в минуту через кэш, а не на каждый запрос; (в) настроили autovacuum для этой таблицы с более агрессивными порогами; (г) добавили алерт на возраст старых транзакций и на долю мёртвых кортежей.
  5. Результат. После перестроения таблицы объём вернулся к 3,4 ГБ и перестал расти; задержки запросов к таблице снизились из-за меньшего числа читаемых страниц.

Вывод: MVCC переносит стоимость одновременного доступа в объём хранения и фоновую очистку. Если за этим не следить, база незаметно деградирует, хотя с точки зрения приложения «ничего не менялось».

FAQ

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

Значит ли это, что блокировок нет вообще? Нет. Конфликт писатель—писатель по одной строке требует ожидания. Также существуют явные блокировки (SELECT ... FOR UPDATE, DDL), которые работают поверх MVCC.

Чем MVCC отличается от блокировочной модели? В блокировочной модели читатель ждёт писателя или получает ошибку. В MVCC читатель всегда получает согласованные данные без ожидания. Плата — расход места на версии и необходимость фоновой очистки.

Можно ли отключить MVCC? В реляционных СУБД это не настройка, а основа модели одновременного доступа. Практический вопрос обычно не в отключении, а в настройке очистки и контроле длительности транзакций.

Источники: PostgreSQL docs (MVCC, routine vacuuming), MySQL docs (InnoDB multi-versioning) — ссылки в шапке.