В прошлом уроке мы установили: обычный VACUUM не возвращает место операционной системе. Файл таблицы остаётся того же размера, dead tuples переезжают в LP_UNUSED, и место переиспользуется будущими INSERT’ами через FSM. Это устраивает в 95% случаев — bloat стабилизируется на каком-то уровне, размер не растёт, write-heavy таблица работает.
Но иногда нужно именно сжать таблицу: например, после массового DELETE (удалили 80% данных), после ошибочной длинной транзакции, которая «отморозила» VACUUM на неделю, или просто перед миграцией, когда хочется уехать на меньшем диске. В этих случаях у вас два варианта: VACUUM FULL или внешние инструменты pg_repack / pg_squeeze.
Как работает VACUUM FULL
VACUUM FULL table — это не «более тщательный VACUUM», как может показаться. Это совершенно другая операция:
- Берёт
AccessExclusiveLockна таблицу — блокирует всё, включая SELECT. - Создаёт новый пустой файл (новый
relfilenode). - Сканирует старую таблицу и копирует только живые кортежи в новый файл, упаковывая их плотно — без дыр и dead tuples.
- Пересоздаёт все индексы с нуля (потому что ctid каждой строки изменился — новые номера страниц и offset’ов).
- Удаляет старый файл.
- Снимает блокировку.
Результат: таблица занимает столько места, сколько занимают живые кортежи плюс минимальный page overhead. Никакого bloat’а, индексы тоже свежие. Но цена — высока:
- AccessExclusiveLock на всё время операции. На таблице 100 GiB это часы простоя. SELECT, INSERT, UPDATE — всё ждёт.
- Двойной дисковый объём в момент операции. Старый файл существует, пока операция не завершилась; новый файл уже занял (примерно) свой финальный размер. Если у вас bloat 50% и таблица 100 GiB, нужно ~150 GiB свободного диска: 100 старых + 50 новых.
- Перестройка всех индексов. Это часто занимает больше времени, чем сам копирующий проход — особенно если индексов много и они большие.
Слева: lazy VACUUM на том же месте, файл сохраняется, free space внутри страниц. Справа: VACUUM FULL переписывает таблицу в новый relfilenode, удаляет старый.
Когда VACUUM FULL оправдан
Несколько разумных сценариев:
- Аномальный bloat после однократного события. Был запущен пакетный
DELETEна 80% данных. Таблица «вспухла» до 5x от живого размера и больше так не будет. Стоит один раз сжать. - Подготовка к миграции / pg_dump. Перед
pg_dumpили физическим переездом часто хочется «причесать» БД. На preprod или maintenance-окне VACUUM FULL — нормальный шаг. - Маленькие таблицы. На таблице ~1 GiB VACUUM FULL занимает секунды. Если её можно блокировать на пару минут — это самый простой способ.
- Системные / read-only таблицы. Если таблица обновляется раз в сутки cron’ом, ночное окно — отличный момент.
И сценарии, когда VACUUM FULL категорически не подходит:
- OLTP-таблица в продакшене 24/7.
AccessExclusiveLockостановит весь сервис на часы. - Таблица с активной репликацией. VACUUM FULL генерирует много WAL (пишет всю таблицу заново) и может перегрузить replication lag.
- Таблица с активной FK-связью. Заблокированная таблица может породить deadlock на смежных таблицах, которые ждут lock.
Альтернатива: pg_repack и pg_squeeze
pg_repack (extension от NTT, де-факто стандарт) и более новый pg_squeeze решают ту же задачу — переписать таблицу плотно, вернуть место ОС — но без AccessExclusiveLock. Идея:
- Создаётся пустая копия таблицы со всей структурой.
- Развешиваются триггеры на исходную таблицу: каждый INSERT/UPDATE/DELETE в течение операции записывается в специальную log-таблицу.
- Происходит первичный bulk copy:
INSERT INTO new_table SELECT * FROM old_table. Это самая длинная фаза, и она идёт под слабой блокировкой (SELECT/INSERT работают). - Применяются записи из log-таблицы к новой копии (catch-up).
- В самом конце — короткая (миллисекунды-секунды) фаза
AccessExclusiveLock: переключение имён файлов иDROPстарой таблицы. - Пересоздаются индексы. У
pg_repackесть онлайн-режим — индекс строится в новой таблице без блокировки старой.
Цена и оговорки:
- Нужны первичный ключ или уникальный индекс на таблице — без него pg_repack отказывается работать (он использует PK для catch-up).
- Тот же двойной дисковый объём, что у VACUUM FULL.
- Лишний WAL-трафик (две полные копии данных).
- Внешний инструмент: его нужно установить (как extension в Postgres + CLI на сервере) и интегрировать с мониторингом.
В продакшене 24/7 — pg_repack по умолчанию. VACUUM FULL — для maintenance-окон и не-критичных таблиц.
Демо: смотрим, как VACUUM FULL уплотняет таблицу
В pglite VACUUM FULL работает. Сделаем массовый DELETE и сравним размер таблицы до и после VACUUM FULL.
Удалим 80% orders, посмотрим размер до и после обычного VACUUM. Размер не должен сильно измениться:
После DELETE 80% строк и регулярного VACUUM таблица занимает почти столько же — потому что VACUUM не возвращает место. Теперь применим VACUUM FULL:
VACUUM FULL после массового DELETE. Размер должен заметно упасть:
Размер после VACUUM FULL должен быть ~1/5 от исходного: мы оставили 20% строк, VACUUM FULL переписал таблицу плотно, новый файл содержит только эти 20%.
Скрытая стоимость: индексы
VACUUM FULL пересоздаёт все индексы — это часто доминирующая часть времени операции. На таблице с 5 индексами по 10 GiB каждый VACUUM FULL может занять часы, из которых сама копия heap — десяток минут, а остальное — построение индексов с нуля.
Альтернатива, если нужно сэкономить время и можно потерпеть отдельный простой на индексы: REINDEX TABLE CONCURRENTLY — пересоздаёт индексы по очереди, без блокировки. Но это уже отдельная операция.
И второе: после VACUUM FULL все ctid’ы поменялись. Если ваш код где-то полагается на ctid (это плохая практика, но бывает в админских скриптах) — он сломается. Также все «холодные» индексные записи в WAL/replication slot’ах теперь указывают в никуда — реплика должна догнать VACUUM FULL.
Чек-лист
- VACUUM FULL — переписывает таблицу в новый relfilenode, плотно упаковывает живые кортежи, пересоздаёт все индексы.
- Берёт
AccessExclusiveLockна всё время операции — блокирует даже SELECT. Не подходит для OLTP 24/7. - Требует двойной дисковый объём временно — старый файл существует, пока операция не завершилась.
- pg_repack / pg_squeeze — внешние extension, делают то же самое без эксклюзивной блокировки. Триггеры + log-таблица + catch-up + короткое переименование. Требуют PK/unique index.
- Когда оправдан VACUUM FULL: после однократного аномального события (массовый DELETE), маленькие таблицы, maintenance-окно.
- Когда нельзя: OLTP-продакшен 24/7, таблицы с активной репликацией и FK на горячий путь.
- Все ctid после VACUUM FULL меняются — индексы пересоздаются, slot’ы реплики догоняют новые позиции.