7. MVCC. Multi Version Concurrency Control
Введение: Почему блокировки — не единственный путь
Классическая модель конкурентного доступа, целиком построенная на блокировках, упирается в жесткий математический предел: читатели неизбежно блокируют писателей, а писатели парализуют читателей. В реальных высоконагруженных веб-сервисах это превращает базу данных в бутылочное горлышко, где до 80% времени процессорных ядер тратится не на полезную обработку данных, а на холостые циклы ожидания спинлоков, системные вызовы futex и бесконечные переключения контекста потоков ОС.
Архитектура Multi-Version Concurrency Control (MVCC) предлагает элегантный выход из этого тупика. Вместо того чтобы выстраивать читателей и писателей в единую очередь, СУБД сохраняет в памяти и на диске несколько последовательных версий одной и той же строки. Каждая транзакция видит собственный непротиворечивый моментальный снимок (snapshot) состояния базы данных на момент своего старта.
Главный закон MVCC формулируется просто: читатели никогда не ждут писателей, а писатели никогда не блокируют читателей.
Для инженера уровня Senior/Lead MVCC — это не просто «магия базы данных». Это фундаментальный архитектурный паттерн, который диктует:
- Как планировать долгоживущие транзакции, чтобы не «убить» сборку мусора в СУБД.
- Почему
COUNT(*)или полное сканирование таблицы могут работать в десятки раз медленнее ожидаемого. - Как проектировать Go-приложения, чтобы не накапливать невидимые версии строк и не раздувать диски.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Транзакция T1: BEGIN"]:::entry --> B["Чтение версии v1"]
C["Транзакция T2: UPDATE"]:::process --> D["Создание версии v2"]
B --> E["T1 видит v1<br>снимок создан до T2"]
D --> F["T2 фиксирует v2"]
F --> G["Следующие транзакции видят v2"]
G --> H["Фоновый процесс VACUUM"]
H --> I["Удаление версий, недоступных ни одному активному снимку"]
Фундамент MVCC: Версии строк и идентификаторы транзакций
В основе MVCC лежат два системных атрибута, которые СУБД неявно добавляет к каждой строке:
xmin— идентификатор транзакции, которая вставила эту версию строки.xmax— идентификатор транзакции, которая удалила или перезаписала эту версию. Если строка активна,xmax = 0.
Когда транзакция начинает выполняться, ей назначается уникальный txid и создаётся снимок (snapshot). Снимок фиксирует список активных на этот момент транзакций. При каждом чтении СУБД не возвращает «последнюю» версию строки, а проверяет её видимость для текущего снимка.
Алгоритм видимости (упрощённо)
Строка видима транзакции с snapshot_txid, если:
xmin < snapshot_txmin(строка создана до начала снимка) Иxminзафиксирована.xmax = 0ИЛИxmax > snapshot_txmaxИЛИxmaxещё не зафиксирована.xminне находится в списке активных транзакций снимка.
Это правило проверяется за несколько тактов CPU с помощью целочисленных сравнений и битовых масок, без обращения к менеджеру блокировок.
Реализация под капотом: Два архитектурных лагеря
Существует два принципиально разных подхода к хранению версий, и понимание разницы критично для тюнинга производительности.
1. PostgreSQL: Версии в куче (Heap-Storage)
PostgreSQL хранит все версии строк прямо в основной таблице (в файлах данных). При UPDATE:
- Старая версия помечается как удалённая (
xmaxустанавливается в новыйtxid). - В конец таблицы (или в свободное место на той же странице) записывается новая версия.
- Индекс указывает на все версии.
Visibility Mapпомогает ускорить сканирование.
Плюсы: Простота, быстрое чтение (нет дополнительных переходов по памяти), поддержка HOT-обновлений.
Минусы: Таблица быстро разрастается (bloat), VACUUM требует постоянного фонового обслуживания.
2. MySQL/InnoDB: Отдельные Undo-логи
InnoDB хранит только последнюю версию в кластерном индексе. Старые версии выносятся в отдельные сегменты отката (UNDO tablespace). В заголовке строки хранится указатель (DB_ROLL_PTR) на предыдущую версию в undo-логе.
Плюсы: Основная таблица остаётся компактной, запись быстрее, меньше фрагментации данных.
Минусы: Чтение старой версии требует дополнительных переходов по памяти (pointer chasing), что убивает предвыборку CPU.
Mechanical Sympathy: Влияние на CPU, кэш и диски
MVCC меняет паттерн доступа к памяти, что напрямую влияет на утилизацию железа.
Кэш-линии CPU и предвыборка
В InnoDB цепочка версий разбросана по разным страницам UNDO tablespace. При чтении «истории» строки процессор совершает случайные переходы по памяти (random memory access). Это приводит к:
- Промахам кэша
L3(cache miss). - Загрузке конвейера ожидания памяти (
memory stall), где ядро CPU простаивает сотни тактов. - Неэффективности аппаратного предсказателя переходов и
prefetcher, который оптимизирован для линейного доступа.
В PostgreSQL версии часто находятся на одной странице данных. Это улучшает локальность данных, но при высоком bloat страница становится разреженной, и полезная нагрузка на кэш-линию падает.
Дисковые операции и VACUUM
VACUUM — это аналог Garbage Collector в мире СУБД. Он:
- Сканирует страницы, проверяя
xmin/xmaxпротив глобального минимального активногоtxid. - Помечает мёртвые версии (dead tuples) как свободное пространство.
- Обновляет
Visibility Mapи статистику. - (Опционально) Выполняет
VACUUM FULL, который перезаписывает всю таблицу в новый файл, освобождая место на диске (но требует эксклюзивной блокировки).
В отличие от Stop-the-World GC в Go, VACUUM работает инкрементально и асинхронно. Но если его запустить слишком поздно, autovacuum не будет успевать, и таблица начнёт занимать в 5-10 раз больше места, чем логически необходимо.
Обратная сторона медали: Разрастание таблиц и длинные транзакции
Самая опасная ловушка MVCC — длительные транзакции. Если одна транзакция удерживает снимок (например, открытый курсор, отладочный BEGIN в консоли или забытый tx в Go), СУБД не может удалить ни одну версию, созданную после начала этого снимка.
-- Транзакция A началась 3 часа назад и ничего не делает
BEGIN;
SELECT * FROM users; -- Снимок зафиксирован
-- За 3 часа в системе произошло 10 миллионов UPDATE
-- PostgreSQL хранит 10 миллионов "мёртвых" версий, потому что Транзакция A всё ещё активна
HOT Updates: Оптимизация в PostgreSQL
Если UPDATE не изменяет столбцы, участвующие в индексах, и в странице есть место, PostgreSQL выполняет Heap-Only Tuple (HOT) обновление:
- Новая версия записывается на ту же страницу.
- Индекс не обновляется.
- Создаётся цепочка указателей между версиями на странице.
Это снижает дисковый IO и нагрузку на индексы на 40-60% при частых апдейтах флагов или статусов.
Практика в Go: Как не выстрелить себе в ногу
1. Мониторинг длинных транзакций
Всегда отслеживайте max(tx_age) и pg_stat_activity. В коде можно добавить фоновую проверку пула:
// Пример проверки пула на предмет заблокированных транзакций
func CheckLongTransactions(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'idle in transaction' AND xact_start < now() - interval '5 min'
`)
if err != nil {
return fmt.Errorf("query long tx: %w", err)
}
defer rows.Close()
for rows.Next() {
var pid int
var dur string
var query string
if err := rows.Scan(&pid, &dur, &query); err != nil {
continue
}
// Логирование или автоматическое завершение проблемных сессий
fmt.Printf("Long tx: pid=%d duration=%s query=%s\n", pid, dur, query)
}
return nil
}
2. Избегайте SELECT * при сканировании таблиц
При полном сканировании MVCC фильтрует невидимые строки на лету. Если таблица раздута в 5 раз, движку придётся прочитать в 5 раз больше страниц с диска, чтобы вернуть тот же логический объём данных. Это напрямую бьёт по IOps и латентности ответа в Go-сервисе.
3. Паттерн для пакетных вставок/обновлений
Используйте COPY или unnest вместо тысяч отдельных INSERT. Это уменьшает количество версий, создаваемых в куче, и снижает нагрузку на WAL и autovacuum.
Итог
- MVCC заменяет блокировки чтением версий, обеспечивая истинный параллелизм читателей и писателей.
- Видимость определяется через
xmin/xmaxи снимок транзакции. Проверка происходит на уровне CPU инструкций, без блокировок. - Архитектура: PostgreSQL хранит версии в куче (простота чтения, риск
bloat), InnoDB — в undo-логах (компактность, рискpointer chasing). - Mechanical Sympathy: MVCC меняет паттерн доступа к памяти. Длинные транзакции убивают
VACUUM, вызывают разрастание таблиц и промахи кэшаL3. - В Go: Минимизируйте время жизни
*sql.Tx, мониторьтеidle in transaction, избегайтеSELECT *на раздутых таблицах, используйтеHOT-дружественные схемы.
Понимание того, как СУБД хранит версии данных, подводит нас к следующему критическому механизму: как эти версии надёжно записываются на диск и гарантируют сохранность при сбоях. В следующей статье мы разберём журнал упреждающей записи: 8. WAL. Write Ahead Log.