PostgreSQL VACUUM и autovacuum: тонкая настройка для продакшена

Зачем PostgreSQL нужен VACUUM

PostgreSQL работает на MVCC (Multi-Version Concurrency Control) — механизме, при котором обновление строки не удаляет старую версию, а оставляет её висеть с пометкой «мёртвая» (dead tuple). Транзакции изолированы — это хорошо. Но «мёртвые» строки никуда не деваются сами по себе и тихо съедают место на диске.

VACUUM делает три вещи, без которых база рано или поздно встанет:

  • Освобождение места — помечает пространство «мёртвых» строк как пригодное для новых данных (именно помечает, а не возвращает ОС — для этого нужен VACUUM FULL)
  • Обновление карты видимости — без актуальной visibility map Index-Only Scans вынуждены лезть в heap, теряя всё своё преимущество по скорости
  • Заморозка старых транзакций — предотвращает Transaction ID Wraparound, который в худшем случае кладёт базу полностью и безвозвратно

Без регулярного VACUUM база раздуется до неприличных размеров, запросы начнут спотыкаться о «мёртвые» строки при каждом сканировании, а после ~2 миллиардов транзакций PostgreSQL просто остановится сам — принудительно, чтобы не допустить wraparound. Мы видели такое в проде. Это больно.

VACUUM vs VACUUM FULL

Здесь важно не путать два режима — они принципиально разные по поведению:

ПараметрVACUUMVACUUM FULL
БлокировкаНе блокирует записьExclusive lock — полная блокировка
Возврат места ОСНет (место переиспользуется)Да, файл перезаписывается
СкоростьБыстроМедленно (перезапись всей таблицы)
ДаунтаймНетДа, таблица недоступна

VACUUM FULL в продакшене — это крайняя мера, только когда bloat уже критический и других вариантов нет. Он держит exclusive lock на таблицу, пока работает. Если нужно вернуть место без блокировок — используйте pg_repack. Разница ощутимая.

Как работает autovacuum

Autovacuum — фоновый демон, который сам следит за таблицами и запускает VACUUM, когда «мёртвых» строк накопилось достаточно. Порог запуска считается по формуле:

dead_tuples > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples

С настройками по умолчанию (threshold = 50, scale_factor = 0.2) для таблицы на миллион строк autovacuum проснётся только после того, как появятся 200 050 «мёртвых» строк. Это 20% таблицы — огромный балласт. Для небольших таблиц ещё терпимо, но на больших такой подход убивает производительность.

Смотрим, что сейчас делает autovacuum:

SELECT schemaname, relname, 
       n_live_tup, n_dead_tup,
       round(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 1) AS dead_pct,
       last_autovacuum, last_autoanalyze,
       autovacuum_count, autoanalyze_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;

Если dead_pct стабильно держится выше 10% — autovacuum не вывозит нагрузку и пора его настраивать.

Настройка глобальных параметров autovacuum

Для высоконагруженных серверов в postgresql.conf меняем следующие параметры:

# Количество воркеров autovacuum (по умолчанию 3)
autovacuum_max_workers = 6

# Порог запуска: вакуумить чаще
autovacuum_vacuum_threshold = 50
autovacuum_vacuum_scale_factor = 0.05  # 5% вместо 20%

# Порог ANALYZE
autovacuum_analyze_threshold = 50
autovacuum_analyze_scale_factor = 0.025  # 2.5%

# Агрессивность: меньше пауз между итерациями
autovacuum_vacuum_cost_delay = 2ms  # по умолчанию 2ms в PG15+
autovacuum_vacuum_cost_limit = 800   # по умолчанию 200

# Заморозка для предотвращения wraparound
vacuum_freeze_min_age = 10000000
vacuum_freeze_table_age = 150000000
autovacuum_freeze_max_age = 200000000

Параметр autovacuum_vacuum_cost_limit — это лимит «работы» за один проход до паузы. По умолчанию 200. Поднимаем до 800 — autovacuum становится в 4 раза шустрее. На серверах с SSD смело ставьте 1200–2000, железо выдержит.

Настройка на уровне таблицы

Горячие таблицы лучше не трогать общими настройками — задайте им индивидуальные параметры прямо на таблице:

-- Таблица с частыми UPDATE (например, сессии)
ALTER TABLE user_sessions SET (
    autovacuum_vacuum_scale_factor = 0.01,   -- 1%
    autovacuum_vacuum_threshold = 100,
    autovacuum_vacuum_cost_delay = 0,         -- без пауз
    autovacuum_vacuum_cost_limit = 2000
);

-- Таблица-лог (только INSERT, dead tuples не образуются)
ALTER TABLE audit_log SET (
    autovacuum_vacuum_scale_factor = 0.0,
    autovacuum_vacuum_threshold = 1000000,    -- реже вакуумить
    autovacuum_freeze_max_age = 500000000     -- но замораживать вовремя
);

В PostgreSQL 13 наконец добавили autovacuum для INSERT-only таблиц через параметр autovacuum_vacuum_insert_threshold. Раньше такие таблицы вообще не вакуумировались автоматически, и visibility map не обновлялась — что сводило на нет все преимущества Index-Only Scans.

Мониторинг bloat

Bloat — это мёртвый груз внутри таблиц и индексов: место занято, но данных там нет. Мы мониторим его регулярно — раз в неделю минимум:

-- Оценка bloat таблиц (упрощённый запрос)
SELECT
    schemaname || '.' || tablename AS table_name,
    pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
    pg_size_pretty(
        pg_total_relation_size(schemaname || '.' || tablename) - 
        pg_relation_size(schemaname || '.' || tablename)
    ) AS index_size,
    CASE WHEN pg_relation_size(schemaname || '.' || tablename) > 0
        THEN round(
            (1 - (n_live_tup::numeric * 8192) / 
            NULLIF(pg_relation_size(schemaname || '.' || tablename), 0)) * 100, 1
        )
    END AS est_bloat_pct
FROM pg_stat_user_tables
JOIN pg_tables USING (schemaname, tablename)
WHERE n_live_tup > 1000
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC
LIMIT 20;

Хотите точные цифры — поставьте расширение pgstattuple. Оно считает честнее, чем оценочные запросы по системным таблицам:

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT * FROM pgstattuple('public.orders');
-- dead_tuple_percent покажет точный процент bloat

Bloat индексов

Индексы раздуваются не меньше таблиц. И что важно — обычный VACUUM их не сжимает, только отмечает место как свободное. Проверяем так:

SELECT
    indexrelname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    idx_scan AS scans,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE NOT indisunique
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;

Раздутые индексы пересоздаём через REINDEX CONCURRENTLY — начиная с PostgreSQL 12 это работает без блокировки таблицы:

REINDEX INDEX CONCURRENTLY idx_orders_customer_id;

Таблица остаётся доступной на чтение и запись, пока индекс пересоздаётся. Но место потребуется — временно в 2–3 раза больше, чем занимает текущий индекс. Проверьте диск заранее.

Предотвращение Transaction ID Wraparound

Transaction ID в PostgreSQL — 32-битное число. Максимум ~4,3 миллиарда значений. Когда счётчик подбирается к лимиту, PostgreSQL переходит в аварийный режим и блокирует все транзакции до устранения проблемы. Мы не драматизируем — это реальный сценарий, который случался в продакшене у разных компаний.

Следим за возрастом транзакций по всем базам:

SELECT datname, 
       age(datfrozenxid) AS xid_age,
       round(age(datfrozenxid)::numeric / 2000000000 * 100, 2) AS pct_to_wraparound,
       current_setting('autovacuum_freeze_max_age') AS freeze_max_age
FROM pg_database
ORDER BY xid_age DESC;

Ориентиры такие: xid_age около 200 миллионов — autovacuum переходит в агрессивный режим заморозки. Около 1,2 миллиарда — в логах начнут сыпаться предупреждения. Около 2 миллиардов — аварийная остановка. До последнего лучше не доводить.

Самые «старые» таблицы — первые кандидаты на принудительный VACUUM FREEZE:

SELECT schemaname, relname, 
       age(relfrozenxid) AS xid_age,
       pg_size_pretty(pg_total_relation_size(oid)) AS size
FROM pg_class
JOIN pg_stat_user_tables USING (relname)
WHERE relkind = 'r'
ORDER BY age(relfrozenxid) DESC
LIMIT 10;

Устранение проблем с autovacuum

За годы работы с PostgreSQL мы собрали несколько типичных ситуаций, когда autovacuum не справляется. Вот они с решениями:

Проблема 1: autovacuum постоянно прерывается длинными транзакциями.

-- Найти длинные транзакции, блокирующие vacuum
SELECT pid, now() - xact_start AS duration, 
       state, query
FROM pg_stat_activity
WHERE state != 'idle'
  AND xact_start < now() - interval '1 hour'
ORDER BY xact_start;

Решение простое — настройте idle_in_transaction_session_timeout = '10min'. Зависшие транзакции будут завершаться автоматически, не мешая autovacuum работать.

Проблема 2: autovacuum не успевает обойти все таблицы.

Добавляем воркеров и сокращаем задержку между запусками:

autovacuum_max_workers = 8
autovacuum_naptime = 15s  # проверять чаще (по умолчанию 1 мин)
autovacuum_vacuum_cost_delay = 0  # без пауз на SSD

Проблема 3: VACUUM ползёт часами на огромных таблицах.

Таблицы больше 100 ГБ лучше партиционировать. VACUUM обрабатывает каждую партицию отдельно — быстрее и параллельно. На практике это меняет картину кардинально.

pg_repack: онлайн-дефрагментация без блокировок

Когда bloat уже критический и обычный VACUUM не помогает — берём pg_repack. Он пересоздаёт таблицу целиком, но без exclusive lock:

# Установка
sudo apt install postgresql-16-repack

# Создание расширения
psql -d mydb -c "CREATE EXTENSION pg_repack;"

# Перепаковка таблицы
pg_repack -d mydb -t public.orders --no-superuser-check

# Перепаковка всех таблиц с bloat > 30%
pg_repack -d mydb --all-tables

Механизм такой: pg_repack создаёт копию таблицы, все изменения на время перестройки перехватывает триггер, затем происходит атомарная подмена. Сервис не останавливается. Но место на диске нужно заранее — понадобится столько же, сколько занимает таблица вместе с индексами.

Удобно автоматизировать через cron — раз в неделю перепаковываем самые раздутые таблицы:

#!/bin/bash
# /opt/scripts/repack-bloated.sh
TABLES=$(psql -d mydb -t -c "
  SELECT schemaname || '.' || relname
  FROM pg_stat_user_tables
  WHERE n_dead_tup > 100000
    AND n_dead_tup::float / NULLIF(n_live_tup, 0) > 0.3
")

for TABLE in $TABLES; do
    echo "Repacking $TABLE..."
    pg_repack -d mydb -t "$TABLE" --no-superuser-check 2>&1
done

Часто задаваемые вопросы

Если autovacuum настроен нормально — руками VACUUM запускать незачем, он сам разберётся. Но есть два случая, когда без ручного запуска не обойтись: после того как удалили большой кусок таблицы через DELETE, и перед VACUUM FULL или pg_repack. В остальных ситуациях просто следите за pg_stat_user_tables — там всё видно.

Обычный VACUUM не блокирует ни чтение, ни запись — работает параллельно со всем остальным. Нагрузку на I/O можно прикрутить через autovacuum_vacuum_cost_delay и autovacuum_vacuum_cost_limit. На SSD это вообще почти незаметно. А вот VACUUM FULL — совсем другая история: он кладёт на таблицу эксклюзивную блокировку. Только в maintenance window, никак иначе.

XID исчерпан — это уже не просто проблема, это авария. Запускаете PostgreSQL в single-user mode: postgres --single -D /var/lib/postgresql/16/main mydb, потом гоните VACUUM FREEZE по самым старым таблицам. Но лучше до такого вообще не доводить. Мы в таких проектах всегда вешаем алерт на age(datfrozenxid) — порог 500 миллионов, и при подходе к нему уже можно спокойно реагировать, а не тушить пожар.

Отключать autovacuum — плохая идея. Совсем плохая. Получите wraparound и будете вспоминать этот день. При массовой загрузке — ETL, миграции — просто поднимите пороги для конкретной таблицы: ALTER TABLE t SET (autovacuum_vacuum_threshold = 10000000). Загрузку завершили — верните настройки обратно и сразу запустите VACUUM ANALYZE вручную. Это рабочая схема, проверено.

На практике смотрим три вещи. Первое: n_dead_tup / n_live_tup на активных таблицах не должно уходить выше 5–10%. Второе: last_autovacuum на горячих таблицах — если там давность больше нескольких часов, что-то идёт не так. Третье: age(relfrozenxid) не должен расти бесконтрольно — это прямой путь к тому самому wraparound. Как только какая-то из метрик выбивается из нормы — идёте и правите параметры autovacuum.

Нужна помощь с настройкой?

Команда АйТи Фреш занимается настройкой и поддержкой PostgreSQL больше 15 лет — знаем эти грабли наизусть. Обслуживание от 15 000 ₽/мес.

📞 Связаться с нами
#PostgreSQL VACUUM#autovacuum настройка#PostgreSQL оптимизация#bloat PostgreSQL#dead tuples#wraparound#autovacuum_vacuum_cost_delay#pg_stat_user_tables
Комментарии 0

Оставить комментарий

загрузка...