PostgreSQL один из лидеров в БД, одно из его преимуществ — это предсказуемое поведение под нагрузкой и отсутствие блокировок при чтении. Это достигается, в том числе, с помощью специального механизма — MVCC (Multiversion Concurrency Control), или многоверсионный контроль конкурентного доступа.
Зачем нужен MVCC
В любой базе данных есть проблема: что делать, если один клиент читает строку, а другой её в этот момент изменяет? Простой подход — блокировки. Один читает — другой ждёт. Один пишет — все ждут. Это просто, но плохо масштабируется.
PostgreSQL идёт другим путём: вместо блокирования он хранит несколько версий одной и той же строки. Читатель получает "свою" консистентную картину данных, даже если в этот момент кто-то что-то активно обновляет. Самое важное, что нужно понять: в PostgreSQL UPDATE не изменяет строку на месте.
При UPDATE происходит следующее: создаётся новая версия с обновлёнными данными, а старая версия строки
остаётся в таблице, но помечается как "устаревшая".
При DELETE строка также остаётся помеченная как "устаревшая".
Это и есть основа MVCC — база хранит цепочку версий одной строки. Каждая версия строки физически хранится отдельно,
но имеет указатель ctid — это ссылка на её положение в таблице. При обновлении новая версия получает
новый ctid. Старые и новые версии связаны логически и могут быть найдены через внутренние указатели.
В некоторых случаях PostgreSQL может оптимизировать обновление с помощью HOT (Heap Only Tuple). Если
изменяемые поля не затрагивают индексы, новая версия создаётся без обновления индексных записей. Это снижает
накладные расходы и замедляет рост таблицы.
Как PostgreSQL понимает, какую версию показывать
Каждая строка в PostgreSQL содержит служебные поля:
xmin— ID транзакции, которая создала строкуxmax— ID транзакции, которая "удалила" строку (или обновила)
Когда транзакция выполняет SELECT, PostgreSQL проверяет эти поля и определяет видна ли конкретная
версия строки в рамках текущего снимка (snapshot).
Если описать это простым языком, то строка видна, если xmin уже закоммичен и при этом xmax
либо пустой, либо относится к незакоммиченной/будущей транзакции. За счёт этого каждая транзакция видит
согласованное состояние базы на момент своего старта.
Что такое snapshot и почему он важен
Snapshot — это набор правил, по которым PostgreSQL определяет, какие транзакции считаются видимыми на момент начала запроса или транзакции (своего рода "снимок" базы данных).
В PostgreSQL snapshot фиксируется не для всей базы навсегда, а в зависимости от уровня изоляции:
Read Committed— snapshot свой на каждый запрос (один и тот жеSELECTв рамках одной транзакции может возвращать разные данные)Repeatable Read— snapshot фиксируется на всю транзакциюSerializable— так же как дляRepeatable Read, но добавляется дополнительная логика для предотвращения аномалий
Это объясняет поведение, которое часто удивляет: один и тот же SELECT в рамках одной транзакции может возвращать разные данные — если используется Read Committed.
DELETE и UPDATE — это почти одно и то же
С точки зрения MVCC, DELETE — это просто установка xmax, а UPDATE — это
установка xmax и создание новой строки с новым xmin.
Почему таблицы разрастаются (bloat)
Так как старые версии строк не удаляются мгновенно, таблица со временем "раздувается". Даже если постоянно обновлять одну и ту же строку, в таблице будут накапливаться её старые версии. Их нельзя удалить сразу, потому что, какая-то транзакция всё ещё может использовать старый snapshot и должна видеть эти строки.
VACUUM: уборщик версий
Для очистки старых версий строк используется VACUUM. Он проходит по таблице и удаляет строки, которые
уже не видны ни одной активной транзакции и больше не нужны для MVCC.
MVCC напрямую требует регулярного обслуживания. Без VACUUM (если он отключён или не справляется),
начнутся проблемы с производительностью из-за роста размера таблиц, планировщик запросов будет неправильно строить
планы, увеличится время ввода/вывода
Стоит учитывать, что обычный VACUUM не уменьшает физический размер таблицы, а лишь помечает освобождённое
место как пригодное для повторного использования. Чтобы реально сжать таблицу, требуется VACUUM FULL,
но он требует эксклюзивной блокировки.
Кроме очистки, VACUUM решает ещё одну критическую задачу — предотвращает переполнение счётчика
транзакций (transaction ID wraparound). Для этого старые версии строк PostgreSQL помечает как очень старые и всегда
видимые (freeze), чтобы их xmin больше не участвовал в проверке видимости. Без этого со временем база
перестаёт корректно определять, какие данные актуальны, и может остановить работу, чтобы избежать повреждения
данных.
Почему SELECT не блокирует UPDATE (и наоборот)
Благодаря MVCC, SELECT читает "старую" версию строки, если новая ещё не закоммичена. Т.е. чтение не
ждёт запись, запись не блокирует чтение. Но есть нюанс: блокировки всё равно существуют. Например:
UPDATEконфликтует с другимUPDATEтой же строкиDDLоперации используют более жёсткие блокировки
MVCC не отменяет блокировки, он снижает их влияние в наиболее частом использовании — чтении.
Практические последствия для разработчика
MVCC — это не просто внутренняя реализация. Он влияет на поведение системы, и это нужно учитывать. Несколько важных моментов:
- Долгие транзакции — зло. Пока транзакция живёт,
VACUUMне может удалить старые версии строк. Одна "зависшая" транзакция может привести к раздутию таблицы - Частые
UPDATEприводят к большому количеству версий. Это может ухудшать производительность, особенно без индексов и правильногоVACUUM SELECT COUNT(*)на большой таблице может быть дорогим — потому что PostgreSQL должен учитывать видимость строк
MVCC — это одна из ключевых фишек PostgreSQL. Он делает возможным высокую конкурентность без агрессивных блокировок, но требует
понимания и дисциплины. За счёт создания новых версий строк и управления их видимостью, PostgreSQL может
сохранять хорошую производительность под нагрузкой, но это может приводить к раздуванию размера таблиц
(bloat) если не используются VACUUM