В прошлом уроке мы разобрали MVCC и поняли, что UPDATE и DELETE в postgresql не стирают строку на месте, а оставляют мёртвые версии. Кто-то должен их собирать, иначе таблица пухнет, индексы тормозят, а счётчик транзакций однажды переполнится и сервер встанет. За уборку отвечает VACUUM. Урок про то, как работает очистка, как её автоматизирует autovacuum, что такое заморозка строк (freeze) и почему transaction ID wraparound - самая страшная авария, которую можно довести до полной остановки базы.

Как это работает
Каждая версия строки несёт служебные поля xmin (кто создал) и xmax (кто удалил). Версия мёртвая, когда её xmax зафиксирован и она больше не видна ни одному активному снимку. Пока мёртвые версии лежат в страницах, они занимают место и заставляют сканы перебирать лишнее. Раздувание таблицы такими версиями называют bloat.
VACUUM проходит по страницам, помечает место от мёртвых версий свободным и кладёт его в карту свободного пространства (FSM), чтобы будущие вставки переиспользовали дыры. Важно: обычный VACUUM не возвращает место операционной системе и не сжимает файл, он лишь освобождает место внутри для повторного использования. Поэтому после массового DELETE размер файла не падает, но новые строки лягут в очищенные страницы.
Чтобы не сканировать всю таблицу каждый раз, postgresql держит visibility map - битовую карту по две позиции на страницу: all-visible (на странице нет версий, невидимых кому-либо) и all-frozen. VACUUM пропускает страницы all-visible и за счёт этого работает инкрементально. Та же карта включает index-only scan: если страница all-visible, данные берутся прямо из индекса, без обращения к таблице.
Теперь про заморозку. Номер транзакции (XID) - это 32-битное число, оно ходит по кругу примерно в 4 миллиарда значений. Видимость определяется сравнением "старше/моложе", и чтобы старые строки не оказались вдруг из будущего после оборота счётчика, их xmin заменяют отметкой "заморожено навсегда" - такая версия видна всем без сравнения XID. Это и есть freeze. Если запустить заморозку слишком поздно, наступает transaction ID wraparound: чтобы не показать данные из будущего, postgresql аварийно запрещает новые транзакции, и база уходит в read-only до ручного VACUUM. Доводить до этого нельзя.
Autovacuum - фоновый демон, который сам запускает VACUUM и ANALYZE по таблицам, где накопилось достаточно изменений. Порог считается как autovacuum_vacuum_threshold плюс autovacuum_vacuum_scale_factor, умноженный на число строк: по умолчанию 50 + 0.2 * reltuples, то есть примерно при 20 процентах изменённых строк. Отдельная ветка - антивраппераунд-автовакуум: он стартует принудительно, когда возраст самой старой незамороженной транзакции доходит до autovacuum_freeze_max_age (по умолчанию 200 млн), и его нельзя пропустить.
SQL и примеры
Посмотрим, сколько мёртвых версий накопила таблица и когда её чистили в последний раз. Запрос к статистике по схеме bookings демобазы Авиаперевозки:
Код: Выделить всё
SELECT relname, n_live_tup, n_dead_tup,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'bookings'
ORDER BY n_dead_tup DESC;
Код: Выделить всё
SELECT pg_size_pretty(pg_relation_size('bookings.ticket_flights'));
UPDATE bookings.ticket_flights SET amount = amount + 0;
SELECT pg_size_pretty(pg_relation_size('bookings.ticket_flights'));
VACUUM (VERBOSE, ANALYZE) bookings.ticket_flights;
Код: Выделить всё
VACUUM FULL bookings.ticket_flights;
Проверим возраст транзакций - насколько близко до wraparound:
Код: Выделить всё
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY age(relfrozenxid) DESC
LIMIT 10;
Код: Выделить всё
ALTER TABLE bookings.ticket_flights
SET (autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_cost_delay = 2);
В PostgreSQL 16 у VACUUM появился параметр BUFFER_USAGE_LIMIT (и глобальный vacuum_buffer_usage_limit), ограничивающий вытеснение кэша уборкой, а очистка индексов научилась идти параллельно. В PostgreSQL 17 список мёртвых TID переписали на структуру TidStore (адаптивное радиксное дерево): память на вакуум упала кратно, исчез старый лимит в 1 ГБ, индексы чаще проходятся за один заход, а прогресс в pg_stat_progress_vacuum теперь меряется в байтах (dead_tuple_bytes).
Частые грабли
- Отключают autovacuum "чтобы не мешал нагрузке" - и через месяц получают чудовищный bloat и заморозку всего разом. Не выключайте, а настраивайте.
- Думают, что обычный VACUUM уменьшит файл на диске. Нет: место отдаёт ОС только VACUUM FULL или pg_repack, обычный лишь освобождает его внутри.
- Запускают VACUUM FULL на проде в часы нагрузки и ловят полную блокировку таблицы на чтение и запись.
- Долгая открытая транзакция или брошенный replication slot держат "горизонт": autovacuum видит старые версии как ещё нужные и не убирает их, bloat растёт несмотря на работающий демон.
- Игнорируют рост age(relfrozenxid), пока база не уйдёт в read-only по wraparound. К этому моменту лечить тяжело и долго.
- Ставят scale_factor 0.2 для таблицы в сотни миллионов строк: 20 процентов - это десятки миллионов мёртвых версий до пробуждения автовакуума. Для крупных таблиц снижайте порог.
- Путают ANALYZE и VACUUM: ANALYZE обновляет статистику для планировщика, но мёртвые версии не убирает.
- В psql подключитесь к демобазе и снимите размер: SELECT pg_size_pretty(pg_total_relation_size('bookings.boarding_passes'));
- Посмотрите n_dead_tup и last_autovacuum для этой таблицы в pg_stat_user_tables.
- Сделайте массовый UPDATE без изменения данных: UPDATE bookings.boarding_passes SET seat_no = seat_no; и снова замерьте размер и n_dead_tup.
- Выполните VACUUM (VERBOSE) bookings.boarding_passes; и прочитайте в выводе, сколько версий removable и сколько страниц пропущено по visibility map.
- Сравните размер до и после: убедитесь, что обычный VACUUM файл не уменьшил.
- Выполните VACUUM FULL bookings.boarding_passes; и сравните размер ещё раз - теперь он должен упасть.
- Посмотрите age(relfrozenxid) и сравните со SHOW autovacuum_freeze_max_age; прикиньте запас до антивраппераунд-вакуума.
- Чем мёртвая версия строки отличается от живой и по каким полям это определяется?
- Почему обычный VACUUM не уменьшает размер файла на диске, а VACUUM FULL уменьшает, и какой ценой?
- Как считается порог срабатывания autovacuum и что задают threshold и scale_factor?
- Что такое freeze, зачем замораживают строки и чем грозит transaction ID wraparound?
- Какую роль играет visibility map в ускорении VACUUM и в работе index-only scan?
- Почему долгая открытая транзакция или брошенный слот репликации мешают autovacuum убирать мёртвые версии?