PostgreSQL: что на самом деле происходит во время VACUUM

Классическая история: кто-то удаляет из таблицы двадцать миллионов старых строк, ожидая, что база данных сразу же вздохнёт свободнее и du -sh покажет цифру поменьше. Проходит час. Таблица занимает ровно столько же места на диске. Человек идёт разбираться, находит слово «VACUUM» в логах и пишет мне сообщение с одним вопросом: «А оно вообще работает?»

Работает. Просто не делает того, что вы думаете. И чтобы понять, что оно делает на самом деле, придётся сначала разобраться, почему PostgreSQL вообще устроен так, что удалённые строки не исчезают мгновенно.

MVCC в двух словах

PostgreSQL не перезаписывает строки на месте. Когда вы делаете UPDATE или DELETE, старая версия строки не исчезает физически — она помечается как недействительная для новых транзакций, а на диске остаётся до тех пор, пока ни одной активной транзакции она больше не нужна. Это и есть MVCC — multiversion concurrency control, механизм, который позволяет читателям не блокировать писателей и наоборот.

Отсюда следствие, которое многих удивляет: DELETE FROM orders WHERE created_at < '2020-01-01' не освобождает место на диске сразу. Он помечает строки как «мёртвые» (dead tuples). Пока хоть одна транзакция теоретически могла бы их увидеть — снапшот, открытый до удаления, — PostgreSQL обязан хранить эти версии. Дальше в игру вступает VACUUM.

Из практики

Однажды ко мне пришли с тикетом «диск скоро закончится, помогите». Смотрю — таблица логов на 400 ГБ, из которых живых данных от силы на 40. Автовакуум был выключен три месяца назад «на всякий случай» перед миграцией и просто забыт включённым обратно. Три месяца — это очень много мёртвых кортежей, и восстанавливать таблицу пришлось уже через pg_repack, а не через обычный VACUUM.

Что реально делает обычный VACUUM

Обычный VACUUM (без FULL) не возвращает место операционной системе. Вместо этого он:

  • помечает место, занятое мёртвыми кортежами, как свободное внутри файла таблицы, чтобы его могли использовать будущие вставки и обновления;
  • обновляет visibility map — карту страниц, где все строки видны всем транзакциям, что ускоряет index-only scan;
  • обновляет карту свободного места (free space map), по которой будущие вставки находят, куда писать;
  • замораживает старые строки, предотвращая transaction ID wraparound.

То есть таблица на диске не уменьшается, но освободившееся место переиспользуется. Если ваша нагрузка стабильна (примерно одинаковое количество вставок и удалений), размер файла таблицы со временем стабилизируется — просто не сразу и не так, как ожидает интуиция. Пустые страницы в конце файла — единственное, что VACUUM всё же может обрезать: если это самые последние страницы файла и они полностью свободны, он их отбрасывает через truncate. Но это скорее приятное исключение, а не основной механизм.

Freeze и transaction ID wraparound

У PostgreSQL 32-битный счётчик transaction ID: каждая строка хранит, какой транзакцией она создана, и когда счётчик проходит полный круг, старые ID начинают выглядеть «из будущего» относительно новых. Без защиты это сломало бы видимость данных полностью. Защита называется freezing: VACUUM помечает достаточно старые строки как «замороженные», после чего они видимы всем транзакциям всегда, независимо от сравнения ID. Пока freeze успевает происходить регулярно, вы не заметите этот механизм. Если же autovacuum не успевает — PostgreSQL сначала предупреждает в логах о приближении wraparound, а в критической точке переходит в защитный режим, ограничивающий запись, пока не отработает экстренный VACUUM.

sql
SELECT relname,
       age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY xid_age DESC
LIMIT 10;

Если xid_age для какой-то таблицы приближается к сотням миллионов — это повод разобраться заранее, а не ждать предупреждения в логах в три часа ночи.

VACUUM, VACUUM FULL и autovacuum

КомандаБлокировкаВозвращает место ОСКогда использовать
VACUUMНет (кроме коротких моментов)НетРегулярно, обычно через autovacuum
VACUUM FULLACCESS EXCLUSIVEДаРедко, в окно обслуживания
autovacuumНетНетВсегда включён по умолчанию

VACUUM FULL действительно уменьшает файл на диске — но делает это, полностью переписывая таблицу и требуя эксклюзивную блокировку на всё время операции. На проде с активной нагрузкой это редко хорошая идея в рабочее время. Для случаев сильного bloat обычно лучше подходит pg_repack, который делает похожую работу без долгой блокировки, создавая копию таблицы параллельно и подменяя её в конце короткой транзакцией.

Для измерения реального bloat, а не только приблизительной оценки из системного каталога, удобно расширение pgstattuple (SELECT * FROM pgstattuple('orders')) — оно показывает dead_tuple_percent, долю мёртвых строк от размера таблицы. Число выше 20-30% на активно используемой таблице обычно означает, что дефолтный autovacuum_vacuum_scale_factor = 0.2 (запуск VACUUM при 20% мёртвых строк) слишком мягкий именно для этой таблицы — крупные и активно изменяемые таблицы почти всегда получают собственные, более агрессивные настройки через ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.02).

Частая причина bloat

Одна забытая открытая транзакция (например, зависшая сессия в приложении) может помешать VACUUM очистить строки на совершенно другой, активно используемой таблице. Проверяйте pg_stat_activity на долгие idle in transaction сессии в первую очередь — это чаще причина, чем сам VACUUM.

sql
SELECT relname,
       n_live_tup,
       n_dead_tup,
       last_autovacuum,
       last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

Если n_dead_tup постоянно растёт, а last_autovacuum либо пуст, либо был давно — это повод разбираться: либо autovacuum не успевает из-за настроек, либо на таблице действительно есть долгие транзакции, которые держат старые версии строк живыми.

Стоит ли выключать autovacuum

Нет. Почти никогда. Autovacuum существует не для красоты — без него вы рано или поздно упрётесь в transaction ID wraparound, а это уже не «таблица не уменьшилась», а «база остановила приём записи, пока не отработает экстренный VACUUM». Если autovacuum не успевает — тюнингуйте пороги под конкретную таблицу, а не выключайте его целиком. Единственный разумный случай временного отключения — массовая загрузка данных в новую, ещё пустую таблицу, да и то лучше просто снизить приоритет, а не отключать совсем и забывать включить обратно.

Коротко

  • VACUUM не сжимает таблицу — он переиспользует место внутри файла.
  • Freeze защищает от transaction ID wraparound — самой опасной причины остановки записи в PostgreSQL.
  • Для реального уменьшения файла на диске нужен VACUUM FULL или pg_repack.
  • Долгие транзакции — главный враг эффективного VACUUM, проверяйте их первыми.

А диск, который «скоро закончится», в девяти случаях из десяти на самом деле полон мёртвых кортежей, а не полезных данных. Проверьте это раньше, чем закажете новый SSD.