В модуле про MVCC мы выяснили: UPDATE не меняет строку на месте, а создаёт новую версию кортежа, а старую помечает мёртвой (xmax = <transaction>). DELETE тоже не удаляет — только проставляет xmax. Через десять тысяч UPDATE одной строки таблица содержит десять тысяч физических кортежей, из которых живой один. Если ничего не делать — таблица будет расти бесконечно. Этим «ничего не делать» как раз и занимается VACUUM: он находит мёртвые кортежи и переводит занятое ими место в категорию «свободное, можно переиспользовать».
Главное, что нужно понять про VACUUM с самого начала: он почти никогда не возвращает место операционной системе. Файл таблицы на диске после VACUUM останется ровно того же размера — но внутри страниц теперь будут «дыры», куда последующие INSERT’ы и UPDATE’ы смогут писать новые версии строк, не расширяя файл.
Что именно делает обычный (lazy) VACUUM
Команда VACUUM table (без FULL) запускает так называемый lazy vacuum. Это фоновый сканер, который проходит по таблице страница за страницей и для каждой делает четыре вещи:
- Находит все , которые точно не нужны ни одному активному снапшоту.dead tuples
- Помечает их line pointer’ы как
LP_DEAD(а после второго прохода —LP_UNUSED), и кладёт их offset/length в Free Space Map страницы. - Обновляет : если все кортежи на странице видны всем активным транзакциям, выставляет битVisibility Map
all-visible. Это критично для index-only scan и для следующего VACUUM (он сможет такие страницы пропустить). - Чистит индексы: проходит по всем индексам таблицы и удаляет записи, ссылающиеся на освобождённые line pointer’ы.
Эта последовательность называется two-pass strategy. Первый проход по heap собирает массив tid (идентификаторов умерших кортежей) в memory (контролируется maintenance_work_mem). Затем — проход по каждому индексу: удалить из B-tree всё, что ссылается на эти tid. И только после этого второй проход по heap, который окончательно превращает LP_DEAD в LP_UNUSED.
До: 4 живых + 3 мёртвых кортежа, страница 'занята' на 80%. После: те же 4 живых, но 3 line pointer'а в LP_UNUSED, место в FSM. На диске страница такого же размера.
Освобождённое место — это не «дыра в файле»: line pointer’ы остаются, просто помечены LP_UNUSED, а tuple-данные в дальней области страницы перетираются при следующей записи. FSM (Free Space Map) запоминает: «на странице 17 есть 2.4 KiB свободного места». Когда придёт следующий INSERT, PostgreSQL спросит FSM «дай страницу с местом ≥ нужный размер», и попадёт прямо в эту дыру. Без полного скана.
Чего VACUUM НЕ делает
Ключевые «нельзя ожидать»:
- Не возвращает место операционной системе. Файл
<relfilenode>остаётся того же размера. Это видно поpg_relation_size: до VACUUM и после — одно и то же число. Только если в конце таблицы оказалась целая страница, состоящая полностью из мёртвых кортежей (truncate-trailing-empty-pages), VACUUM её отрежет. Это редкий случай, требующий специфичного паттерна удаления. - Не сжимает страницы. Если на странице 100 живых кортежей и 8 KiB пусто внутри, VACUUM не будет «склеивать» эту страницу с соседней. Каждая страница остаётся самостоятельной, и dead-space внутри неё доступен только для записи в эту же страницу.
- Не блокирует запись и чтение. Lazy VACUUM берёт
ShareUpdateExclusiveLock— он совместим сSELECT,INSERT,UPDATE,DELETE. Конфликтует только с DDL и с другим VACUUM/ANALYZE на той же таблице. Поэтому VACUUM можно (и нужно) гонять в продакшене. - Не обновляет статистику (если не сказать
VACUUM ANALYZE). Это разные операции. ANALYZE собирает гистограммы и MCV; VACUUM трогает heap и indexes.
Чтобы вернуть место ОС или физически уплотнить страницы — нужен VACUUM FULL или pg_repack/pg_squeeze. Об этом — следующий урок.
Visibility Map и зачем она нужна
Каждая heap-таблица имеет свой файл _vm. В нём — 2 бита на каждую heap-страницу: all-visible и all-frozen. Зачем эти биты:
- all-visible: если на странице все кортежи видны всем активным транзакциям (т.е. нет ни одной строки, чей
xminещё может быть незавершён, и нет ни одногоxmax-маркера), бит = 1. Следующие сканеры этой страницы могут полагаться, что им не нужно проверять MVCC видимость каждого кортежа. - all-frozen: все кортежи на странице frozen (xmin =
FrozenTransactionId). Этот бит используется для wraparound-protection — но об этом в уроке про wraparound.
Главное практическое применение all-visible — index-only scan. Когда индекс может полностью ответить на запрос («найди count рейтингов 5», «найди email по id»), Postgres должен убедиться, что данные в индексе соответствуют видимым данным в heap. Без VM ему пришлось бы открыть каждую heap-страницу. С VM: если бит all-visible стоит, можно вернуть данные прямо из индекса, не трогая heap. Это в 10-100 раз быстрее.
VACUUM выставляет all-visible, когда страница «успокоилась». Любой UPDATE или DELETE сбрасывает этот бит. Поэтому write-heavy таблицы редко получают пользу от index-only scan: их VM почти всегда «грязная».
Демо: смотрим dead tuples и эффект VACUUM
Возьмём medium-датасет, сделаем массовый UPDATE и посмотрим, сколько dead tuples появится. Внимание: pglite инициализирует датасет ~5 секунд.
UPDATE 5000 строк customers (поднимаем total signup_date на месяц). Затем смотрим dead tuples через pg_stat_user_tables (в pglite счётчики работают приблизительно, но идею показывают):
После UPDATE на 5000 строк ожидаем n_dead_tup ≈ 5000 — каждая обновлённая строка оставила старую версию. Размер таблицы при этом вырос (новые версии занимают новое место).
Теперь запустим VACUUM и посмотрим, что изменилось:
Запускаем VACUUM и смотрим состояние таблицы. Размер на диске не должен уменьшиться — но n_dead_tup должен упасть до нуля.
В реальном PostgreSQL n_dead_tup после VACUUM = 0, размер size_after_vacuum = такой же, как был до VACUUM (или чуть больше, чем до UPDATE). Освобождённое место — это slot’ы в страницах, доступные через FSM. Следующий пакет INSERT’ов будет переиспользовать эти слоты вместо роста файла.
Когда VACUUM не справляется
VACUUM может «не справиться» по двум причинам:
- Долгий снапшот блокирует освобождение. Если в системе есть открытая транзакция, которая стартовала до UPDATE — её snapshot всё ещё видит старые версии. VACUUM не может пометить такие dead tuples как unused, пока эта транзакция не завершится. Открытый репликационный слот (
pg_replication_slots.xmin) — частая причина. Долгий аналитический запрос на read-replica — другая. - VACUUM не успевает за write-rate. Если таблица получает 10K UPDATE/sec, а autovacuum запускается раз в минуту и проходит 30 секунд — между запусками накапливается полмиллиона dead tuples. В этом случае таблица растёт быстрее, чем VACUUM очищает.
Решения — в уроках про autovacuum (тюнинг частоты и cost limit) и про мониторинг (как заметить долгие транзакции и хвосты).
Чек-лист
- VACUUM = переводит dead tuples в LP_UNUSED, обновляет VM, обновляет FSM, чистит индексы. Не возвращает место ОС.
- Two-pass strategy: scan heap → собрать tid → пройти все индексы → scan heap снова → mark LP_UNUSED.
- Visibility Map (2 бита на страницу): all-visible включает index-only scan; all-frozen используется для wraparound-protection.
- VACUUM берёт
ShareUpdateExclusiveLock— не блокирует SELECT/INSERT/UPDATE/DELETE. Можно гонять в продакшене. - VACUUM не уменьшит файл на диске, пока в конце таблицы не окажутся полностью пустые страницы (редкий случай).
- Не справляется при долгих транзакциях (snapshot держит видимость старых версий) или при отставании от write-rate.