Партиционирование, VACUUM и борьба с bloat в PostgreSQL

Обслуживание хранилища PostgreSQL: declarative partitioning (range/list/hash), MVCC и природа bloat, тюнинг autovacuum, VACUUM против VACUUM FULL и pg_repack, transaction ID wraparound и freeze, мониторинг раздувания таблиц

PostgreSQL не перезаписывает строку при UPDATE — он создаёт новую версию и помечает старую как мёртвую. Это плата за MVCC: читатели не блокируют писателей, но мёртвые версии накапливаются, и без уборки таблица распухает, а запросы замедляются. Партиционирование и VACUUM — два инструмента, которые держат хранилище под контролем: одно режет большие таблицы на управляемые куски, другое возвращает место и не даёт базе встать из-за wraparound.

Это третья статья серии «PostgreSQL в проде». Тема плотно связана с моделью версионирования из статей про изоляцию транзакций — здесь MVCC рассматривается со стороны эксплуатации: не «какие аномалии видит транзакция», а «что накапливается на диске и как это убирать». Все числа ниже — из живого стенда postgres-ops/partitioning-vacuum в публичном репозитории digital-cookbook: PostgreSQL 18.4 с расширениями pgstattuple и pg_repack 1.5.3.

Ретрофутуристская схема обслуживания хранилища: слева большая таблица нарезается лезвиями на аккуратные партиции-ящики по месяцам; в центре раздувшийся резервуар с мёртвыми версиями строк, к нему подведён насос-VACUUM; справа счётчик transaction id с красной зоной wraparound и рычагом freeze

В статье

Declarative partitioning

Партиционирование режет одну большую таблицу на несколько физических кусков по значению ключа, оставаясь логически одной таблицей для приложения. Три стратегии: range (по диапазону — обычно дата или id), list (по списку значений — например регион) и hash (равномерное распределение по остатку хеша). Главный выигрыш — в плане запроса: partition pruning отбрасывает партиции, которые физически не могут содержать искомое.

На стенде — таблица measurements из 600 000 строк, порезанная по месяцам на 6 range-партиций. Запрос за один месяц:

EXPLAIN (ANALYZE) SELECT count(*) FROM measurements
WHERE ts >= '2026-03-01' AND ts < '2026-04-01';
 Aggregate
   ->  Seq Scan on measurements_2026_03 measurements   (rows=103546)

В плане — одна партиция из шести. Остальные пять планировщик отбросил ещё на этапе планирования: он доказал, что искомого диапазона времени в них быть не может. А запрос по не-ключевому предикату (по значению, а не по ts) pruning не даёт — читаются все партиции через Parallel Append. Отсюда правило: партиционировать имеет смысл по тому столбцу, по которому реально фильтруют.

Второй большой выигрыш — обслуживание. Удалить месяц истории из обычной таблицы — это DELETE миллионов строк, который порождает горы мёртвых версий и bloat (см. ниже). В партиционированной таблице это метаданная-операция:

ALTER TABLE measurements DETACH PARTITION measurements_2026_01;  -- мгновенно, без DELETE

DETACH мгновенно убирает партицию из таблицы, не трогая строки построчно и не создавая bloat; отсоединённую партицию можно заархивировать и DROP. Симметрично ATTACH добавляет новый месяц. LIST-партиционирование по региону так же даёт pruning (WHERE region = 'EU' читает только партицию EU), а HASH раскладывает строки равномерно — на стенде по 4 партициям вышло ~25 000 строк в каждой из 100 000. Ограничения тоже стоит держать в голове: уникальность обеспечивается в пределах партиции, поэтому столбец партиционирования должен входить в первичный ключ; а слишком мелкое дробление раздувает время планирования.

MVCC и природа bloat

MVCC даёт читателям неблокирующее чтение ценой того, что UPDATE не меняет строку на месте, а пишет новую версию и помечает старую мёртвой; DELETE тоже лишь помечает. Мёртвые версии копятся, пока их не уберёт VACUUM, — и это и есть bloat. На стенде таблица из 500 000 строк с отключённым autovacuum, после массового UPDATE всех строк:

исходно:            heap = 67 MB,  dead = 0%
после UPDATE всех:  heap = 135 MB, dead = 47%   (pgstattuple)

Размер удвоился (67 → 135 МБ), 47% таблицы — мёртвые версии. Ключевой и неочевидный момент — что делает обычный VACUUM:

после VACUUM:       heap = 135 MB, dead = 0%, free = 50%

Мёртвых версий больше нет, но размер файла остался 135 МБ. VACUUM вернул место для переиспользования внутри файла (free space), но не отдал его операционной системе. Доказательство — вставка ещё 200 000 строк после этого не увеличила файл вовсе: новые строки легли в освобождённое место. Это фундаментальное свойство: обычный VACUUM держит bloat под контролем через переиспользование, не блокируя работу, но физически файл не ужимает. Отдельно существует index bloat — индексы распухают по той же причине и уплотняются отдельно.

Тюнинг autovacuum

Вручную звать VACUUM никто не будет — этим занимается autovacuum, запускающий уборку по таблице, когда число мёртвых версий превысит порог autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × n_live. Проблема в дефолтном scale_factor = 0.2: он означает «ждать, пока протухнет 20% таблицы». Для таблицы в 500 000 строк это порог в 100 050 мёртвых версий. На стенде UPDATE 60 000 строк (меньше порога) autovacuum игнорирует — спустя 15 секунд он так и не пришёл:

дефолт (порог 100050):  n_dead_tup = 60000, autovacuum_count = 0, last_autovacuum = (не было)

60 000 мёртвых версий висят, раздувая таблицу и портя статистику планировщика. Лечится per-table настройкой — снижаем порог для конкретной горячей таблицы:

ALTER TABLE av_demo SET (autovacuum_vacuum_scale_factor = 0.01,
                         autovacuum_vacuum_threshold    = 1000);
per-table (порог 6000):  n_dead_tup = 0, autovacuum_count = 1, last_autovacuum = 2026-...

Новый порог — 6000, те же 60 000 мёртвых версий сильно выше него, и autovacuum убрал их в пределах naptime. Вывод: горячие, часто обновляемые таблицы нуждаются в своих порогах — глобальный 0.2 для них слишком поздний. Для очень крупных таблиц полезен и autovacuum_vacuum_cost_limit/cost_delay — троттлинг, чтобы уборка не забивала I/O. Диагностика идёт через pg_stat_user_tables (n_dead_tup, last_autovacuum); отдельно стоит следить за долгими транзакциями — они держат «горизонт» видимости и не дают VACUUM убрать даже уже мёртвые версии.

VACUUM vs VACUUM FULL vs pg_repack

Когда файл уже раздут и место надо вернуть ОС (не просто переиспользовать), есть три инструмента с разной ценой.

  • VACUUM — убирает мёртвых, место остаётся в файле, не блокирует. Разобран выше.
  • VACUUM FULL — переписывает таблицу с нуля, отдаёт место ОС, но держит ACCESS EXCLUSIVE lock: на всё время таблица недоступна даже для чтения.
  • pg_repack — тоже переписывает и отдаёт место, но онлайн: короткие блокировки лишь в начале и конце, основную работу читатели не замечают.

По возвращаемому месту VACUUM FULL и pg_repack равны — оба ужали раздутую таблицу с 269 МБ до 135 МБ. Разница — в доступности, и она принципиальна. На стенде во время операции слали точечные SELECT и считали, сколько прошло:

VACUUM FULL:  проб прошло 1,  макс. задержка 4.5 c   ← читатели заблокированы всю операцию
pg_repack:    проб прошло 39-43                       ← читатели работают почти всё время

VACUUM FULL заблокировал чтение на всю операцию — прошла всего одна проба, повисшая до конца. pg_repack пропустил почти все пробы: читатели работали, короткие задержки давали лишь служебные фазы (создание инфраструктуры в начале, финальный swap в конце), а не вся операция. Отсюда правило прода: VACUUM FULL на живой таблице — это плановый простой, pg_repack — почти без простоя, ценой временного удвоения места на диске (пишется копия) и требования первичного ключа. Заметный сюжет ближайшего будущего — PostgreSQL 19 (в бете на момент написания) вводит нативную команду REPACK (CONCURRENTLY) ...: то, что сейчас делает внешнее расширение, встраивается в сам сервер.

Три способа против bloat: размер отдают ОС только FULL и pg_repack

раздутоданные50% free269 МБ

VACUUMданныеfree внутри269 МБбез блокировки

VACUUM FULLданные135 МБACCESS EXCLUSIVE

pg_repackданные135 МБонлайн

Wraparound и freeze

Самый опасный класс проблем обслуживания — transaction ID wraparound. Идентификатор транзакции 32-битный и идёт по кругу; если старую версию строки не «заморозить» (freeze) до того, как счётчик обойдёт ~2 миллиарда, она вдруг окажется «из будущего» и станет невидимой — данные как будто пропадут. Чтобы этого не случилось, VACUUM замораживает старые версии, проставляя им признак «видна всем всегда». Возраст меряется через age(relfrozenxid) — «сколько транзакций назад заморожена таблица». На стенде после 100 000 транзакций и последующего VACUUM FREEZE:

исходно:                 xid_age = 2
после 100000 транзакций: xid_age = 100005
после VACUUM FREEZE:     xid_age = 0        ← все версии заморожены

Порогами управляют autovacuum_freeze_max_age (по умолчанию 200 000 000 — при этом возрасте autovacuum придёт принудительно, даже если bloat нет и таблица не менялась), vacuum_freeze_min_age (50 000 000) и vacuum_freeze_table_age (150 000 000). Мониторинг ведут по всем базам через age(datfrozenxid): приближение к 200 миллионам — сигнал, к 2 миллиардам — аварийная зона, где PostgreSQL начнёт отказывать в новых транзакциях ради спасения данных. Ранний алерт (скажем, при 50% от autovacuum_freeze_max_age) даёт запас, чтобы разобраться, почему freeze не поспевает, — обычно виновата долгая транзакция или задавленный autovacuum.

Точный мониторинг bloat, чтобы не гадать, даёт pgstattuple (как в примерах выше — точные dead_tuple_percent и free_percent); он честный, но сканирует таблицу целиком. Для дешёвого постоянного мониторинга используют оценочные запросы по системному каталогу (pg_stat_user_tables, pgstattuple_approx) и встраивают их в наблюдаемость с порогами тревоги.

Следующая статья серии остаётся в теме планировщика, но с другого угла: «Оптимизация запросов PostgreSQL: EXPLAIN на практике»готовится, с 6 октября — как читать план запроса, ведь именно autovacuum поставляет планировщику ту статистику, без которой хорошего плана не будет. А как устроена сама HA-обвязка сервера — в первой статье серии, «HA PostgreSQL: Patroni, репликация и PITR-бэкапы».

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии