MVCC и стратегии конкуренции

MVCC хранит несколько версий каждой строки, чтобы читатели не ждали писателей, а писатели — читателей.

MVCC (Multi-Version Concurrency Control, многоверсионное управление конкуренцией) — подход, при котором изменение строки создаёт её новую версию, а старая остаётся для тех, кто читает «снимок» прошлого. Так чтение не блокирует запись, а запись — чтение.

В прошлом уроке писатель ставил замок, и читатель ждал. Но в большинстве баз чтений во много раз больше, чем записей, и заставлять отчёт ждать каждый UPDATE — расточительно. PostgreSQL, Oracle и многие другие решают это через MVCC: вместо «один пишет — все ждут» — «пишущий делает новую версию, читающие спокойно видят старую». Это объясняет, почему в PostgreSQL длинный SELECT почти никого не блокирует.

Зачем это на практике

MVCC объясняет поведение, которое иначе кажется странным: почему отчёт видит согласованные данные, даже если во время его работы шли изменения; почему длинная транзакция «раздувает» таблицу; почему нужен VACUUM. А оптимистичная и пессимистичная стратегии — это два способа защититься от потерянных обновлений, между которыми вы выбираете в каждом конкретном случае исходя из частоты конфликтов.

Многоверсионность как в PostgreSQL

В PostgreSQL UPDATE не перезаписывает строку «на месте». Он создаёт новую версию строки, а старую помечает как устаревшую с указанием, какая транзакция её отменила. У каждой версии есть служебные метки — кем создана и кем удалена. Когда транзакция читает, она видит снимок (snapshot): набор версий, актуальных на момент её начала или текущего запроса.

-- упрощённо: у каждой строки есть невидимые системные поля
-- xmin = id транзакции, создавшей версию; xmax = id транзакции, удалившей её

id=1 balance=500  xmin=100 xmax=105   <- старая версия (видна транзакциям до 105)
id=1 balance=400  xmin=105 xmax=0     <- новая версия (после UPDATE транзакцией 105)

Поэтому читатель, начавший работу до коммита изменения, продолжает видеть balance=500 — старую версию, — пока его транзакция не закончится. Это и есть согласованный снимок: данные не «уплывают» под ногами.

Цена многоверсионности: раздувание и VACUUM

Старые версии не исчезают мгновенно — они занимают место, пока их может увидеть хоть одна живая транзакция. Фоновый процесс VACUUM периодически вычищает версии, которые уже никому не нужны. Отсюда практическое следствие: очень длинная транзакция мешает уборке — пока она открыта, старые версии нельзя удалить, и таблица «раздувается» (bloat). Ещё одна причина держать транзакции короткими.

Оптимистичная и пессимистичная блокировка

Это две стратегии защиты от потерянного обновления — когда двое прочитали одно значение, оба что-то посчитали и оба записали, затерев чужой результат.

ПессимистичнаяОптимистичная
Идея«конфликт вероятен — заблокирую сразу»«конфликт редок — проверю при записи»
МеханизмSELECT ... FOR UPDATE, ждём замокколонка-версия, сверяем при UPDATE
Хорошо, когдаконфликты частые, ожидание дешевле повтораконфликты редкие, блокировки дороги
Плататранзакции ждут друг другаиногда приходится повторять операцию

Пессимистичная: захватить заранее

Знакомый по прошлому уроку приём: блокируем строку FOR UPDATE и спокойно меняем, зная, что никто не вклинится.

BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- замок: остальные ждут
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

Оптимистичная: колонка-версия

Добавляем к строке колонку version (или updated_at). Читаем строку вместе с версией, а при записи требуем, чтобы версия не изменилась, и заодно её увеличиваем. Если за это время кто-то успел обновить строку, версия уже другая — наш UPDATE затронет 0 строк, и мы поймём, что произошёл конфликт.

CREATE TABLE accounts (
  id INTEGER PRIMARY KEY,
  balance INTEGER NOT NULL,
  version INTEGER NOT NULL DEFAULT 0
);
INSERT INTO accounts (id, balance, version) VALUES (1, 500, 7);

-- прочитали: balance=500, version=7. Списываем 100, но только если версия всё ещё 7:
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 7;

SELECT id, balance, version FROM accounts;

Вывод:

1|400|8

Затронута 1 строка — конфликта не было, версия стала 8. Если бы параллельная транзакция уже подняла версию до 8, наш WHERE version = 7 не нашёл бы строку: 0 обновлений — сигнал «перечитай свежие данные и попробуй снова». Никаких блокировок при этом не ставится.

Когда что выбирать

  • Пессимистичная — когда за один и тот же ресурс реально дерутся (популярный товар на распродаже, общий счётчик): дешевле один раз подождать, чем десять раз повторять.
  • Оптимистичная — когда конфликты редки (правка своего профиля, документа): блокировки были бы накладными расходами впустую, а редкий повтор дёшев. Это типичный выбор для веб-приложений и REST API.

Как это работает под капотом

MVCC и снимки — фундамент уровней изоляции из второго урока. Когда вы выбираете REPEATABLE READ в PostgreSQL, транзакция фиксирует снимок в начале и до конца видит один и тот же набор версий строк — отсюда и «повторяемость» чтения. READ COMMITTED берёт новый снимок на каждый запрос, поэтому видит чужие коммиты, случившиеся между запросами. То есть «уровень изоляции» на практике — это правило, какой снимок версий показывать транзакции. Оптимистичная блокировка живёт уровнем выше — это уже логика приложения поверх обычного UPDATE, и она прекрасно работает в связке с любым уровнем изоляции.

Частые ошибки

  • Держать транзакцию открытой «про запас». При MVCC это блокирует VACUUM и раздувает таблицы старыми версиями — деградация на ровном месте.
  • Оптимистичная блокировка без проверки числа затронутых строк. Если не смотреть, что UPDATE вернул 0 строк, конфликт пройдёт незамеченным, и изменение молча потеряется.
  • Считать, что MVCC отменяет блокировки. Чтение не блокирует запись, но два UPDATE одной строки всё равно сериализуются: второй ждёт коммита первого.
  • Выбирать стратегию наугад. Оптимистичная под высоким конфликтом превращается в бесконечные повторы; пессимистичная под низким — в лишние ожидания. Смотрите на реальную частоту конфликтов.

Итоги

  • MVCC хранит несколько версий строки: UPDATE создаёт новую версию, старая живёт для читающих снимок прошлого — чтение не блокирует запись.
  • Старые версии убирает VACUUM; длинные транзакции мешают уборке и раздувают таблицы.
  • Пессимистичная блокировка (FOR UPDATE) — захватить ресурс заранее; хороша при частых конфликтах.
  • Оптимистичная блокировка (колонка-версия, WHERE version = N) — проверить конфликт при записи; хороша при редких конфликтах, без блокировок.
  • Уровни изоляции — это правило, какой снимок версий MVCC показывать транзакции; оптимистичная блокировка — отдельная логика приложения поверх неё.
Проверьте себя
1. Как ведёт себя UPDATE строки при MVCC (как в PostgreSQL)?
AПерезаписывает строку на месте, старое значение теряется сразу
BСоздаёт новую версию строки, а старую помечает устаревшей для тех, кто читает старый снимок
CБлокирует всю таблицу до конца транзакции
DОткладывает изменение до COMMIT, ничего не делая до него
2. В чём суть оптимистичной блокировки через колонку version?
AЗаранее заблокировать строку через SELECT FOR UPDATE
BПолностью запретить параллельные изменения таблицы
CПри UPDATE требовать, чтобы version не изменилась (WHERE version = N), и увеличивать её; 0 затронутых строк означает конфликт
DХранить все версии строки вечно и никогда их не удалять
3. Почему при MVCC длинные транзакции вредны для базы?
AОни автоматически повышают уровень изоляции до SERIALIZABLE
BОни мешают VACUUM убирать старые версии строк, и таблицы раздуваются
CОни отключают журнал упреждающей записи (WAL)
DОни переводят все блокировки в табличные