VACUUM — очистка мусора и защита от катастрофы

Предпосылки: MVCC, буферный кеш, страницы и кортежи.

Очереди задач | B-tree

Интернет-магазин обрабатывает 1000 заказов в час, каждый заказ меняет статус 3-4 раза за свой жизненный цикл. MVCC при каждом UPDATE создаёт новую версию строки, а старая становится мусором. За неделю — миллионы мёртвых версий. Таблица orders, которая должна занимать 2 ГБ, разрастается до 15 ГБ. Sequential scan читает все 15 ГБ, включая страницы, набитые мусором. Запросы замедляются в разы. А если мусор копится слишком долго, возникает угроза ещё серьёзнее: база может вообще перестать работать из-за переполнения счётчика транзакций.

Откуда берутся dead tuples

UPDATE не меняет tuple — создаёт новую версию, старая получает xmax.

UPDATE users SET name = 'Bob' WHERE id = 1;
 
До:  [xmin=100, xmax=0,   name='Alice']  ← живая версия
После:
     [xmin=100, xmax=150, name='Alice']  ← dead tuple (старая версия)
     [xmin=150, xmax=0,   name='Bob']    ← живая версия

Dead tuple — версия строки, которую ни одна активная транзакция не может видеть. Мусор, занимающий место на странице.

Когда dead tuple можно удалить

VACUUM не может удалить dead tuple, пока хоть одна транзакция может его видеть. Условия для удаления:

  • Транзакция, записавшая xmax, закоммичена
  • Все активные транзакции начались ПОСЛЕ этого коммита
  • Значит никакой snapshot не включает этот tuple

Что VACUUM делает — пошагово

VACUUM проходит по таблице и освобождает место от dead tuples.

Алгоритм прохода:

1. Получить список страниц таблицы
2. Для каждой страницы:
   a. Проверить: отмечена ли страница как all-visible (все версии видимы всем)
   b. Прочитать страницу в буфер
   c. Для каждого tuple на странице:
      - Проверить видимость (xmax закоммичен? все активные транзакции новее?)
      - Если tuple стал мусором — добавить в список на удаление
   d. Пометить line pointers удаляемых tuples как «unused»
   e. Сдвинуть живые данные, освободить место (дефрагментация)
   f. Обновить карту свободного места: сколько байт свободно
   g. Если все tuples видимы всем — отметить страницу как all-visible
3. Очистить индексы от ссылок на удалённые tuples

Что происходит на странице:

До VACUUM:
┌─────────────────────────────────────┐
│ LP1 → tuple 1 (dead)                │
│ LP2 → tuple 2 (live)                │
│ LP3 → tuple 3 (dead)                │
├─────────────────────────────────────┤
│ [tuple 3 data] [tuple 2 data] [tuple 1 data] │
└─────────────────────────────────────┘
 
После VACUUM:
┌─────────────────────────────────────┐
│ LP1 → unused                        │
│ LP2 → tuple 2 (live)                │
│ LP3 → unused                        │
├─────────────────────────────────────┤
│ [tuple 2 data] [свободное место...] │
└─────────────────────────────────────┘

Free Space Map и Visibility Map

VACUUM взаимодействует с двумя вспомогательными структурами.

Free Space Map (FSM) — отдельный файл (fork) для каждой таблицы. Хранит приблизительное количество свободного места на каждой странице. INSERT использует FSM, чтобы быстро найти страницу с достаточным местом, вместо перебора всех страниц.

VACUUM обновляет FSM: После очистки страницы сообщает: «на странице N теперь X байт свободно».

Visibility Map (VM) — отдельный файл (fork). Для каждой страницы хранит один бит: «все tuples на этой странице видимы всем транзакциям» (all-visible).

VACUUM использует VM: Если all-visible = 1, страницу можно пропустить — там точно нет dead tuples. Это огромная экономия: таблица в миллион страниц, 99% страниц all-visible → VACUUM читает только 10,000 страниц.

Жизненный цикл бита all-visible:

  1. Страница создана, данные меняются → all-visible = 0
  2. VACUUM проверил: все tuples видимы всем → all-visible = 1
  3. UPDATE или DELETE на странице → all-visible = 0 (сбрасывается)
  4. Следующий VACUUM → снова проверяет и ставит 1

Критично: файл таблицы НЕ уменьшается

VACUUM освобождает место внутри страниц, но не возвращает страницы операционной системе.

Таблица из 1000 страниц (8 MB), VACUUM очистил 500 страниц полностью:
 
До VACUUM:   файл = 8 MB
После VACUUM: файл = 8 MB (тот же размер!)
 
Но внутри: 500 страниц пустые, доступны для новых INSERT.

Почему так: Удаление страниц из середины файла потребовало бы изменения номеров всех последующих страниц. А ctid в индексах содержит номер страницы — пришлось бы обновить все индексы. Это слишком дорого.

Следствие для пользователя: Если таблица выросла до 100 GB, потом удалили 90% данных — файл останется 100 GB. Место внутри свободно для INSERT, но на диске занято.

Bloat — когда VACUUM недостаточен

Bloat (раздувание) — ситуация, когда таблица занимает на диске намного больше места, чем нужно для живых данных.

Как возникает:

Массовый UPDATE затронул 900,000 строк из 1,000,000:
- 900,000 старых версий стали dead tuples
- 900,000 новых версий созданы (возможно, на новых страницах!)
- Таблица выросла почти вдвое
 
VACUUM очистил dead tuples:
- Место освобождено внутри старых страниц
- Но файл не уменьшился
- Если INSERT'ов мало — место простаивает

Что видит пользователь при bloat:

  • Sequential scan читает все страницы, включая пустые. Запрос, который должен занять 1 секунду, занимает 10.
  • Буферный кеш заполняется пустыми страницами — полезные данные вытесняются.
  • Cache hit ratio падает, latency растёт.
  • Бэкапы становятся больше и дольше.

Измерение bloat:

-- pgstattuple — расширение для анализа физической структуры таблиц
CREATE EXTENSION pgstattuple;
 
SELECT
    pg_size_pretty(pg_relation_size('users')) as total_size,
    pg_size_pretty(dead_tuple_len) as dead_space,
    round(100 * dead_tuple_percent::numeric, 2) as dead_percent
FROM pgstattuple('users');

VACUUM FULL — радикальное решение bloat

VACUUM FULL — полная перезапись таблицы. Создаёт новый файл только с живыми данными.

VACUUM FULL users;
 
1. Берёт ACCESS EXCLUSIVE lock (полная блокировка таблицы!)
2. Создаёт новый файл
3. Копирует только живые tuples
4. Перестраивает все индексы
5. Удаляет старый файл
6. Освобождает lock

Результат: Файл уменьшается до размера живых данных. Bloat исчезает.

Цена: ACCESS EXCLUSIVE lock. Пока VACUUM FULL работает — никто не может ни читать, ни писать. Для таблицы в 100 GB это могут быть часы. Пользователи видят: приложение недоступно.

Альтернатива: Расширение pg_repack — делает то же самое, но без полной блокировки. Использует триггеры для отслеживания изменений во время копирования.

Transaction ID Wraparound — почему база может остановиться

VACUUM решает не только проблему bloat. Есть ещё более критичная задача: защита от wraparound.

Проблема: XID — 32-битный счётчик (~4 миллиарда значений). Но реальное «безопасное» пространство — около 2 миллиардов (половина окна для сравнения «раньше/позже»). При высокой нагрузке (10,000 транзакций/сек) это пространство исчерпывается примерно за 2 дня.

Когда счётчик доходит до максимума — он оборачивается (wraps around) и начинает сначала: …4,294,967,294 → 4,294,967,295 → 0 → 1 → 2…

Почему это катастрофа:

PostgreSQL сравнивает XID, чтобы определить «раньше/позже». Для этого делит пространство XID пополам: половина — прошлое, половина — будущее.

Представь счётчик на 10 (для простоты):
Возможные XID: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, потом снова 0...
 
Правило: 5 чисел назад — прошлое, 5 чисел вперёд — будущее.
 
Текущий XID = 7:
Прошлое (считаем назад): 6, 5, 4, 3, 2
Будущее (считаем вперёд): 8, 9, 0, 1
 
Tuple с xmin = 3: попадает в прошлое, tuple видим ✓

Проблема возникает, когда tuple слишком старый:

Tuple создан при XID = 3.
Текущий XID = 7: xmin=3 в прошлом, tuple видим ✓
 
Проходит много транзакций, XID оборачивается: 8, 9, 0, 1...
 
Текущий XID = 1:
Прошлое (считаем назад): 0, 9, 8, 7, 6
Будущее (считаем вперёд): 2, 3, 4, 5
 
xmin = 3 теперь попадает в БУДУЩЕЕ!
PostgreSQL думает: транзакция 3 ещё не началась, tuple невидим.
 
Данные «исчезли».

С реальными числами: tuple старше ~2 миллиардов транзакций рискует «исчезнуть». Таблица с миллионами строк внезапно становится «пустой».

Freezing — решение проблемы wraparound

Freezing (заморозка) — пометка старых tuples как «вечно видимых». Замороженный tuple выводится из игры сравнения XID.

После freezing:
┌─────────────────────────────────────┐
│ xmin = 100,000,000                  │  ← число остаётся (для информации)
│ flags = HEAP_XMIN_FROZEN            │  ← специальный флаг в t_infomask
│ data = 'Alice'                      │
└─────────────────────────────────────┘
 
При проверке видимости:
→ Вижу флаг HEAP_XMIN_FROZEN
→ Не сравниваю xmin с текущим XID
→ Tuple видим всем, всегда

Когда VACUUM замораживает:

Параметр vacuum_freeze_min_age в postgresql.conf (по умолчанию 50,000,000). Если tuple старше этого количества транзакций — заморозить.

Текущий XID = 200,000,000
Tuple с xmin = 100,000,000
Возраст = 200,000,000 - 100,000,000 = 100,000,000 транзакций
100,000,000 > 50,000,000 → заморозить

Почему не замораживать сразу? Freezing требует записи на страницу (меняем флаг в tuple header). Это делает страницу dirty и генерирует записи в WAL. Если замораживать слишком агрессивно — лишний I/O.

Aggressive VACUUM — принудительная заморозка

Обычный VACUUM пропускает all-visible страницы (через VM). Но на all-visible страницах тоже могут быть незамороженные tuples — они видимы, но ещё не заморожены.

Aggressive VACUUM — сканирует ВСЕ страницы, включая all-visible, чтобы заморозить старые tuples.

Триггер aggressive VACUUM:

Каждая таблица хранит relfrozenxid в pg_class — самый старый незамороженный XID в этой таблице. Параметр autovacuum_freeze_max_age (по умолчанию 200,000,000).

Текущий XID = 350,000,000
relfrozenxid таблицы = 100,000,000
Возраст = 350,000,000 - 100,000,000 = 250,000,000
 
250,000,000 > autovacuum_freeze_max_age (200,000,000)
→ Запустить aggressive VACUUM!

После aggressive VACUUM relfrozenxid продвигается вперёд — все tuples старше этого XID заморожены.

Autovacuum — автоматическая очистка

VACUUM нужно запускать регулярно. Ручной запуск для каждой таблицы — нереалистично. Autovacuum — фоновый процесс, который автоматически запускает VACUUM на таблицы, которым это нужно.

Триггер autovacuum (для очистки dead tuples):

dead_tuples > threshold
 
threshold = autovacuum_vacuum_threshold
          + autovacuum_vacuum_scale_factor × число_строк
 
По умолчанию: 50 + 0.2 × число_строк

Пример: таблица с 10,000 строк → threshold = 50 + 2,000 = 2,050 dead tuples. Как только накопится 2,051 dead tuple — autovacuum запустит VACUUM.

Параметры autovacuum_vacuum_threshold и autovacuum_vacuum_scale_factor задаются в postgresql.conf или переопределяются для конкретной таблицы через ALTER TABLE ... SET (autovacuum_vacuum_threshold = ...).

Диагностика:

-- pg_stat_user_tables — статистика по таблицам, включая VACUUM
SELECT
    relname,
    n_dead_tup,        -- текущее количество dead tuples
    last_autovacuum,   -- когда последний раз запускался autovacuum
    last_vacuum        -- когда последний раз запускался ручной VACUUM
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

Красные флаги:

  • n_dead_tup большой (миллионы) — VACUUM не успевает или заблокирован
  • last_autovacuum давно (дни, недели) или NULL — autovacuum не работает

Почему долгие транзакции — враг VACUUM

Долгая транзакция держит snapshot. VACUUM не может удалить dead tuples, которые эта транзакция ещё может видеть через свой snapshot.

Транзакция T1 открыта 2 часа, держит snapshot с xmax = 100,000,000.
 
VACUUM видит 1,000,000 dead tuples с xmax < 100,000,000.
Но T1 может их видеть! (xmax > snapshot.xmax для T1)
VACUUM не может их удалить.
 
Bloat растёт всё время, пока T1 не завершится.

То же самое с freezing: VACUUM не может заморозить tuple, если какая-то транзакция ещё может его видеть. relfrozenxid не продвигается, риск wraparound растёт.

Решение: Не держать долгие транзакции. Или автоматически убивать idle транзакции:

-- Параметр в postgresql.conf или SET для сессии
SET idle_in_transaction_session_timeout = '5min';

Жизненный цикл VACUUM — итог

1. [MVCC](../concurrency/mvcc.md) создаёт dead tuples при UPDATE/DELETE
2. Dead tuples накапливаются на страницах
3. Autovacuum отслеживает количество dead tuples
4. Когда превышен threshold → запускается VACUUM
5. VACUUM проходит по страницам (пропуская all-visible через VM)
6. Освобождает место, обновляет FSM (для INSERT), обновляет VM
7. Замораживает старые tuples (защита от wraparound)
8. Место доступно для повторного использования
9. Файл таблицы не уменьшается — для этого нужен VACUUM FULL

VACUUM поддерживает таблицы в рабочем состоянии, но не ускоряет поиск конкретных строк. За быстрый доступ к данным отвечают индексы: B-tree покрывает точные совпадения и диапазоны.

Sources


Очереди задач | B-tree