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 показывать транзакции; оптимистичная блокировка — отдельная логика приложения поверх неё.