Прямой ответ
Postgres не изменяет строки на месте: UPDATE/DELETE помечают старую версию строки мёртвой, новая версия занимает новое место (MVCC — изоляция и версии); без уборки таблица и индексы раздуваются (bloat): место занято мёртвыми строками, последовательные сканы читают их впустую, планировщик оценивает cardinality по раздутой таблице — планы деградируют (чтение планов). Убирает мёртвые строки VACUUM: помечает пространство повторно используемым (без отдачи ОС), обновляет статистику видимости; VACUUM FULL переписывает таблицу целиком с эксклюзивной блокировкой (минуты недоступности записи — только в крайнем случае, в проде — pg_repack). В норме работает autovacuum (демон, сам обходит таблицы по порогам), и боевая настройка сводится к трём вещам: (1) агрессивнее пороги для горячих таблиц (ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_insert_scale_factor = 0.05) — дефолтные 20% на таблице в 100M строк — «порог уборки» в 20M мёртвых строк — гигантский bloat до визита); (2) не мешать ему — больше воркеров (autovacuum_max_workers=4–6), меньше лимит стоимости (autovacuum_vacuum_cost_limit выше — vacuum быстрее догоняет нагрузку; на SSD «боязнь нагрузки от vacuum» из эпохи дисков — анахронизм), и (3) убить длинные транзакции — незакрытая транзакция/репликационный слот с отставанием удерживают xmin-горизонт: vacuum не может чистить строки, нужные «старому» снимку — одна забытая сессия в idle in transaction раздувает базу до терабайтов (мониторинг pg_stat_activity по xact_start и pg_replication_slots по xmin — обязательные алерты). Диагностика: pg_stat_user_tables (n_dead_tup, last_autovacuum) + расширения (pgstattuple/pg_stat_statements-косвенно) — «мёртвых строк много, last_autovacuum давно» — вакуум не догоняет или заблокирован.
Механика: что делает vacuum и почему bloat растёт
Жизненный цикл строки: UPDATE создаёт новую версию (новая страница или та же при HOT-обновлении — Heap-Only Tuple: без изменения индексных колонок и наличии места на странице — новая версия в той же странице, индексы не трогаются — HOT-доля видна в pg_stat_user_tables.n_tup_hot_upd; чем выше — тем дешевле обновления: не обновляйте индексированные колонки без нужды, держите fillfactor < 100 для горячих таблиц — место под HOT на странице), старая версия умирает, когда ни одна транзакция не может её видеть (xmin-горизонт). VACUUM: проход по таблице — сбор мёртвых версий → очистка индексов (удаление указателей на мёртвые — самая дорогая часть; HOT уменьшает и её) → пометка пространства свободным (FSM — будущие INSERT переиспользуют; ОС память не возвращается — файл таблицы не уменьшается; возврат — только VACUUM FULL/pg_repack/TRUNCATE) → freeze старых XID (защита от wraparound — отдельная причина, по которой vacuum обязан ходить даже по неизменяемым таблицам). Bloat-динамика: нагрузка обновлений > скорость vacuum → рост мёртвых → сканы медленнее → vacuum ещё не успевает — спираль; выход — агрессивные пороги + достаточная скорость vacuum + устранение xmin-якорей. Отдельный случай — bloat индексов: REINDEX CONCURRENTLY (без блокировок — об индексах) — частичные «мусорные» индексы удалять вовсе (pg_stat_user_indexes.idx_scan = 0 за месяцы — кандидат).
Autovacuum: настройка под нагрузку
Глобальные параметры, которые действительно меняют: autovacuum_max_workers (3 → 4–6 на нагруженных инстансах — параллельные уборки разных таблиц), autovacuum_naptime (минута дефолт — ок), autovacuum_vacuum_cost_limit (200 дефолт — наследие осторожности к дискам; на SSD — 1000–2000: vacuum быстрее догоняет — цена — больше IO во время уборки, обычно незаметно), autovacuum_vacuum_cost_delay (2 мс дефолт — с новым лимитом можно 0–1); log_autovacuum_min_duration=1000 — логировать долгие уборки (видимость — половина дела). Per-table (главное): горячие таблицы — autovacuum_vacuum_scale_factor=0.01–0.05 + autovacuum_vacuum_threshold малый (порог = threshold + scale_factor×размер — на больших таблицах scale_factor решает), autovacuum_vacuum_insert_scale_factor (чистка только-вставочных таблиц — append-only логи тоже раздуваются от мертвецов aborted-транзакций и freeze-обязанностей), fillfactor 85–90 для update-тяжёлых (HOT-место — меньше bloat индексов), autovacuum_vacuum_cost_limit per-table выше (горячая таблица убирается без сонливости). Анти-настройка: отключать autovacuum на таблице («мешает пикам») — путь к ручному VACUUM по расписанию и аварийному wraparound-вакууму в худший момент; правильно — ускорить/расписать по окнам (cost-параметры), не выключать. И anti-wraparound мониторинг: age(datfrozenxid) из pg_datbase-представлений — алерт при приближении к autovacuum_freeze_max_age (wraparound-вакуум останавливает всё — катастрофа, до которой доводить нельзя).
xmin-якоря: длинные транзакции и слоты
Vacuum бессилен, пока жива самая старая видящая строки транзакция — якоря xmin: (1) idle in transaction — сессия открыла транзакцию и «ушла на обед» (типично: ORM начал транзакцию, код завис на внешнем HTTP внутри неё — сессии SQLAlchemy: autocommit-дисциплина и короткие транзакции); защита — idle_in_transaction_session_timeout=60s (серверно убивать), алерт на pg_stat_activity где state='idle in transaction' AND xact_start < now()-interval '5 min'; (2) отстающие реплики/слоты — репликационный слот удерживает xmin на первичной, пока подписчик не догнал (реплика умерла — слот держит — первичная раздувается; мониторинг pg_replication_slots.xmin/restart_lsn-отставание, max_slot_wal_keep_size — предел, после которого слот инвалидируется — выбор «реплика цела» vs «первичная цела» — осознанный); (3) долгие запросы-сканы (аналитика на-primary часами — выносить на реплику — реплики в следующем разборе); (4) забытые prepared-транзакции (2PC — pg_prepared_xacts не пуст — редкость, но фатальность). Диагностический запрос-минимум: SELECT pid, xact_start, state, query FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start LIMIT 5 — в мониторинг (Prometheus-экспортер отдаёт это метриками).
Практический сценарий: таблица заказов распухла втрое
Симптомы: последовательный скан по orders (отчёт) — 40 с вместо 8; размер таблицы 90 ГБ при «оценочно 30 ГБ данных»; p95 выборки деградировал. Диагностика: pg_stat_user_tables: n_dead_tup = 62M при n_live_tup = 38M (62% мёртвых), last_autovacuum — 9 часов назад; xmin-якорь найден — репликационный слот аналитической реплики отставал на 40 ГБ (реплику перезапускали вчера — слот держал); горячая таблица с дефолтным scale_factor=0.2 — порог уборки ~20M мёртвых — vacuum приходил поздно и уходил надолго. Лечение: (1) слот — отставание закрыто, алерт на lag слота добавлен (pg_replication_slots в postgres_exporter — метрики); (2) per-table: ALTER TABLE orders SET (autovacuum_vacuum_scale_factor=0.02, autovacuum_vacuum_cost_limit=2000, fillfactor=90); autovacuum_max_workers=5 глобально; (3) разовая уборка — VACUUM (ANALYZE) orders вручную в низконагруженное окно (место переиспользовано — таблица перестала расти; размер файла не уменьшился — ожидаемо), REINDEX CONCURRENTLY двух самых раздутых индексов (−11 ГБ суммарно); (4) idle_in_transaction_session_timeout=2min + алерт «транзакция > 5 мин»; (5) возврат места ОС не понадобился — место ушло под рост таблицы (переупаковка pg_repack — в план на большое окно, если понадобится). Через две недели: n_dead_tup держится <2M (autovacuum догоняет каждые ~20 минут — видно в логе), скан отчёта — 12 с (ostatok — уже оптимизация запроса/партиционирование — партиции).
FAQ
VACUUM против VACUUM FULL против pg_repack? VACUUM — без блокировок записи (AccessShare), место в переиспользование, файл не уменьшается — рутина; VACUUM FULL — полная перепись с ACCESS EXCLUSIVE (запись стоит всё время — в проде почти запрещён); pg_repack — онлайн-пересборка (минимальные блокировки, нужно двойное место и время — плановая операция большого окна); TRUNCATE — для «очистить всё» (мгновенно, отдаёт место).
Нужен ли VACUUM для append-only таблиц (логи, события)? Да, но иначе: мёртвых мало (нет UPDATE/DELETE) — однако freeze-обязанность остаётся (анти-wraparound), и aborted-транзакции оставляют мусор; главное для append-only — не bloat, а партиционирование + DROP старых партиций (мгновенная «уборка» вместо сканов вакуума по терабайтам — партиционирование).
Почему autovacuum «вдруг» начал мешать в часы пик?
Обычно он и раньше работал — просто догнал отставание (после чистки якоря/настройки) или совпал с пиком IO; инструменты: per-table cost_limit ниже в пиковые окна (cron-ом менять — спорно, но бывает), autovacuum_vacuum_cost_delay выше на время, и честнее — развести пики: тяжёлые уборки планировать окнами через manual VACUUM с высоким приоритетом ночью при сниженных днём лимитах; отключение — не вариант (wraparound и лавина).
Как понять, что bloat именно проблема, а не «данные выросли»?
Сверка: pg_stat_user_tables (n_live_tup × средняя ширина против pg_total_relation_size) — расхождение в разы — bloat; pgstattuple (точность до страницы, тяжелее — по конкретным подозрениям); косвенно — деградация планов при том же объёме «полезных» данных + высокий n_dead_tup/last_autovacuum старый; «таблица растёт, вставки те же» — классический признак (место не переиспользуется — vacuum не ходит).
Источники: документация PostgreSQL (routine vacuuming, autovacuum config, monitoring stats) — ссылки в шапке.