14.1 Что вообще делает VACUUM
VACUUM - это сборщик мусора PostgreSQL. За один проход по таблице он делает несколько вещей:
- находит мёртвые версии строк (те, чей
xmaxзакоммичен и старше горизонта) и помечает их указатели как переиспользуемые; - вычищает из всех индексов записи, ведущие на эти мёртвые версии;
- обновляет Free Space Map - карту свободного места, чтобы будущие вставки знали, куда класть данные;
- обновляет Visibility Map - карту видимости, отмечая страницы, где все версии видны всем (это нужно для index-only сканов и для ускорения будущих проходов);
- продвигает горизонт заморозки
relfrozenxid(об этом - глава 15).
Чего обычный VACUUM не делает - не возвращает место операционной
системе. Он освобождает место внутри файлов таблицы под будущие
вставки, но сам файл не сжимается. Уменьшить файл умеет только
VACUUM FULL, переписывающий таблицу целиком (с тяжёлой блокировкой).
Подробнее про границы и счётчики - в статье vacuum.
14.2 Горизонт: за что нельзя заглядывать
Представьте ось транзакций - слева старые, справа новые. В любой момент в базе есть набор активных транзакций. Самая старая из них определяет горизонт: её снимок может потребовать любую версию строки, существовавшую на момент её старта.
Значит, версию строки можно удалить как мусор только если она стала мёртвой раньше самой старой активной транзакции. Если хоть одна живая транзакция теоретически могла бы увидеть эту версию - удалять нельзя.
Горизонт - это не одно число на всю базу в простом смысле. У каждого
бэкенда есть свой backend_xmin - нижняя граница того, что ему ещё
может понадобиться. Глобальный горизонт уборки - это минимум по всем
ним. Двигается он только вперёд и только когда старые транзакции
завершаются. Полный разбор - в transaction-horizon.
14.3 Как одна транзакция держит мусор всей базы
Теперь ключевой сценарий. Откроем в одной сессии транзакцию и не будем её закрывать:
-- сессия A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT 1; -- снимок взят, горизонт зафиксирован
-- ... и сессия молчит, транзакцию не коммитит ...
В сессии B активно работаем: обновляем строки, плодим мёртвые версии, запускаем VACUUM:
-- сессия B
UPDATE bloat_demo SET payload = repeat('z', 200);VACUUM bloat_demo;
SELECT n_dead_tup FROM pg_stat_all_tables WHERE relname = 'bloat_demo';
И видим: n_dead_tup не падает. VACUUM отработал, но удалить ничего
не смог - потому что транзакция в сессии A всё ещё активна, и её
снимок мог бы потребовать старые версии. Горизонт застрял на ней.
Закроем транзакцию в A (COMMIT или ROLLBACK) и повторим VACUUM в
B - мёртвые версии исчезнут. Одна забытая транзакция держала мусор
всей базы.
14.4 Главный антипаттерн: idle in transaction
В реальности горизонт чаще всего держит не аналитический запрос, а
приложение, которое открыло транзакцию и забыло её закрыть -
состояние idle in transaction. Типичная причина: код взял
соединение из пула, выполнил BEGIN, сходил во внешний сервис,
получил таймаут, и транзакция повисла.
Найти таких виновников можно так:
SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;
Чем дольше xact_age, тем дальше отодвинут горизонт и тем больше
мусора заперто. Поэтому в проде ставят idle_in_transaction_session_timeout
- PostgreSQL сам прибьёт транзакцию, висящую без дела дольше лимита.
Подводный камень: длинные транзакции на реплике тоже держат горизонт мастера. Если включён
hot_standby_feedback, реплика сообщает мастеру, какие версии ей ещё нужны для запросов. Долгий запрос на реплике через эту обратную связь отодвигает горизонт уборки на мастере - и VACUUM на мастере не может убрать мусор, хотя локально никаких длинных транзакций нет. Удобно искать причину вpg_stat_activityи забыть про реплику.
14.5 Removable cutoff: граница удаления
Внутри VACUUM горизонт превращается в конкретное число - removable
cutoff (исторически в коде он зовётся OldestXmin). Это самый старый
xid, который ещё может понадобиться кому-либо. Правило простое:
- версия с
xmax, закоммиченным и строго старше cutoff, - мёртвая и удаляемая; - версия с
xmaxновее или равным cutoff - ещё может быть нужна, VACUUM её не трогает.
Это число VACUUM вычисляет в начале прохода и дальше им и руководствуется. Если cutoff застрял из-за старой транзакции, проход честно отработает, но удалит мало или ничего.
14.6 Читаем VACUUM VERBOSE построчно
Чтобы не гадать, VACUUM умеет рассказывать о себе. Запустим с
VERBOSE и разберём вывод:
VACUUM (VERBOSE) bloat_demo;
INFO: vacuuming "lab.public.bloat_demo"
INFO: finished vacuuming "lab.public.bloat_demo": index scans: 1
pages: 0 removed, 89 remain, 89 scanned (100.00% of total)
tuples: 2000 removed, 2000 remain, 0 are dead but not yet removable
removable cutoff: 754, which was 0 XIDs old when operation ended
...
По строкам:
2000 removed- столько мёртвых версий удалено;2000 remain- столько живых осталось;0 are dead but not yet removable- вот это - индикатор горизонта. Это мёртвые версии, которые VACUUM нашёл, но не смог удалить, потому что они новее removable cutoff. Если здесь большое число - в базе висит старая транзакция;removable cutoff: 754 ... 0 XIDs old- сам cutoff и насколько он отстал от текущего момента. Большой возраст cutoff - тоже признак застрявшего горизонта.
Строка «dead but not yet removable» - это первое, что смотрят при жалобах на раздувание. Ненулевое значение почти всегда означает: ищи длинную транзакцию.
14.7 Карта видимости: зачем VACUUM её ведёт
Один из побочных, но важных результатов VACUUM - обновление карты видимости (Visibility Map). Это компактная карта, где на каждую страницу таблицы приходится пара бит: «все версии в этой странице видны всем» и «все версии в этой странице заморожены».
Первый бит даёт большую оптимизацию - index-only scan. Если индекс нашёл нужные строки и карта говорит, что их страницы видны всем, PostgreSQL может вернуть данные прямо из индекса, не заглядывая в таблицу. Без VACUUM этот бит не выставляется, и index-only scan вырождается в обычный с обращением к таблице.
Поэтому VACUUM полезен не только для уборки мусора: он буквально ускоряет чтение, поддерживая карту видимости в актуальном состоянии. К index-only сканам и роли карты видимости мы вернёмся в части про индексы.
14.8 Когда VACUUM запускать руками
В обычной жизни VACUUM запускает автоматика - autovacuum (глава 16). Вручную его дёргают в нескольких ситуациях:
- после массового
DELETE/UPDATE, когда хочется убрать мусор немедленно, не дожидаясь порога autovacuum; - перед снятием статистики (
VACUUM ANALYZE), чтобы планировщик работал на свежих данных; - при диагностике -
VACUUM (VERBOSE), чтобы прочитать строку «dead but not yet removable» и понять, держит ли кто-то горизонт.
Чего точно не стоит делать в проде по привычке - VACUUM FULL на
живой таблице. Он переписывает её целиком под эксклюзивной
блокировкой: никто не сможет ни читать, ни писать, пока он не
закончит. Об этом и об измерении раздувания - в главе 16.
Уроки в sandbox
lab-14.1. Горизонт держит мусор: две сессии
Воспроизведём главный сценарий вживую. В одной сессии откроем
долгую транзакцию, в другой будем плодить и убирать мусор. Увидим,
что VACUUM бессилен, пока первая сессия не закроется. Сначала
предскажи, что покажет n_dead_tup, потом проверь.
В сессии A:
BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1;- снимок зафиксирован, не коммить.В сессии B:
UPDATE bloat_demo SET payload = repeat('z', 200);- наплодили мёртвые версии.В сессии B:
VACUUM bloat_demo;затем посмотриn_dead_tupвpg_stat_all_tables. Предскажи: упадёт ли он?В сессии B:
VACUUM (VERBOSE) bloat_demo;- найди строку «dead but not yet removable», она должна быть ненулевой.Найди виновника: в сессии B запусти запрос к
pg_stat_activityпоstate='idle in transaction'.В сессии A:
COMMIT;. В сессии B повториVACUUM bloat_demo;- теперьn_dead_tupдолжен упасть.
sandbox с автопроверкой - открыть в песочнице
lab-14.2. VACUUM VERBOSE построчно
Научимся читать отчёт VACUUM. Прогоним его на раздутой таблице и объясним каждую значимую строку: сколько удалено, сколько осталось, сколько «dead but not yet removable» и что такое removable cutoff.
Прогони
VACUUM (VERBOSE) bloat_demo;и сохрани вывод.Найди в выводе число удалённых версий (
tuples: N removed) и предскажи, совпадёт ли оно с прежнимn_dead_tup.Найди строку про removable cutoff и определи его возраст в XID.
Открой долгую транзакцию в другой сессии, повтори VACUUM и сравни строку «dead but not yet removable» - она должна вырасти.
sandbox с автопроверкой - открыть в песочнице
Резюме
- VACUUM удаляет мёртвые версии из таблицы и индексов, обновляет Free Space Map и Visibility Map, продвигает горизонт заморозки.
- Обычный VACUUM освобождает место внутри файлов под будущие вставки, но не возвращает его ОС; уменьшает файл только `VACUUM FULL`.
- Горизонт уборки - минимум `backend_xmin` по всем активным транзакциям; версию можно удалить, только если она стала мёртвой раньше горизонта.
- Одна долгая или забытая (`idle in transaction`) транзакция отодвигает горизонт и запрещает уборку мусора во всей базе.
- Внутри VACUUM горизонт - это removable cutoff (`OldestXmin`); версия удаляема, если её `xmax` закоммичен и строго старше cutoff.
- Строка `N dead but not yet removable` в `VACUUM VERBOSE` - главный индикатор: ненулевая означает застрявший горизонт, ищи длинную транзакцию.
- `hot_standby_feedback` позволяет долгому запросу на реплике держать горизонт мастера, хотя локальных длинных транзакций нет.
Контрольные вопросы
Почему VACUUM может отчитаться об успехе, но не убрать ни одной мёртвой версии?
Показать ответ
Потому что удаляемость версии определяется не фактом её смерти, а горизонтом. Если в базе есть активная транзакция, чей снимок старше момента смерти этих версий, VACUUM обязан их сохранить - они ещё могут понадобиться тому снимку. Он честно проходит таблицу, находит мёртвые версии, но классифицирует их как «dead but not yet removable» и оставляет на месте. Уборка возобновится, когда старая транзакция завершится и горизонт сдвинется вперёд.
Что такое removable cutoff и как он связан с горизонтом?
Показать ответ
Removable cutoff (в коде
OldestXmin) - это конкретное числоxid, которое VACUUM вычисляет в начале прохода: самый старый идентификатор транзакции, который ещё может кому-то понадобиться. Это и есть горизонт, выраженный числом. Версия сxmax, закоммиченным и строго старше cutoff, удаляема; новее или равным - нет. Если в базе висит старая транзакция, cutoff застревает на еёxid, и удалять становится почти нечего.Как `idle in transaction` приводит к раздуванию таблиц?
Показать ответ
Соединение открыло транзакцию (
BEGIN) и не закрыло её - например, код взял коннект из пула и завис на внешнем вызове. Пока транзакция активна, её снимок держит горизонт на месте. Все UPDATE и DELETE в других сессиях продолжают плодить мёртвые версии, но VACUUM не может их убрать - они новее горизонта. Мусор копится, таблицы и индексы раздуваются. Защита -idle_in_transaction_session_timeout, который автоматически прибивает такие транзакции.Почему долгий SELECT на реплике может раздуть таблицу на мастере?
Показать ответ
Если включён
hot_standby_feedback, реплика сообщает мастеру, какие самые старые версии ей нужны, чтобы её запросы не наткнулись на уже убранные строки. Долгий запрос на реплике через эту обратную связь отодвигает горизонт уборки на мастере. В результате VACUUM на мастере видит мёртвые версии как «not yet removable», хотя на самом мастере длинных транзакций нет. Диагностика осложняется тем, что виновник - в другом узле.Чем VACUUM отличается от VACUUM FULL и когда оправдан второй?
Показать ответ
Обычный VACUUM работает параллельно с нагрузкой, берёт лёгкую блокировку и освобождает место внутри файлов таблицы под будущие вставки, не уменьшая файл. VACUUM FULL переписывает таблицу в новый файл, физически выбрасывая мусор и возвращая место ОС, но берёт эксклюзивную блокировку - таблица недоступна на чтение и запись всё время операции. Он оправдан после разового массового удаления, когда таблица сильно раздулась и место нужно вернуть, и есть окно простоя. В рабочем цикле полагаются на обычный VACUUM и autovacuum.