Справочник DBA: базы данных изнутри читается по порядку

Статья 6.1 · раздел 6 «VACUUM, bloat и обслуживание» · 40 из 109

VACUUM изнутри: фазы, TidStore, проходы по индексам и усечение хвоста таблицы

опубликовано
читать
≈ 11 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18, расширение pageinspect; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

VACUUM убирает 3 000 000 мёртвых строк за 2,7 секунды, а при другом maintenance_work_mem те же 3 000 000 за 4,8; 800 000 строк при лимите стоимости 200 идут 24 секунды. Время определяют число проходов по индексам и паузы cost-based delay, а не размер удалённого. Внутри одного запуска VACUUM не один проход, а цикл: просмотреть таблицу и собрать адреса мёртвых версий строк (TID), по этому списку вычистить из индексов ссылки, затем освободить указатели в самой таблице, и только потом, если хвост пуст, отрезать его. Когда запускается VACUUM, чем он отличается от VACUUM FULL и как читать n_dead_tup, разобрано в основе про VACUUM и autovacuum; откуда берутся мёртвые версии, в статье про MVCC. Здесь разбирается один запуск: фазы, память под список TID и цена каждой фазы.

Опыт: фазы одного VACUUM

Таблица в 10 миллионов строк, два индекса, автовакуум выключен, чтобы он не вмешался. Данные вместе с WAL занимают около 1,8 ГБ.

docker run -d --name dba-vacuum-internals -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-vacuum-internals psql -U postgres
CREATE TABLE v (id int, k int) WITH (autovacuum_enabled = false);
INSERT INTO v SELECT g, ((g::bigint * 7919) % 10000000)::int FROM generate_series(1, 10000000) g;
CREATE INDEX v_id ON v (id);
CREATE INDEX v_k ON v (k);
ANALYZE v;
SELECT pg_relation_size('v')/8192 AS heap_pages, pg_relation_size('v_id')/8192 AS id_pages, pg_relation_size('v_k')/8192 AS k_pages;
VACUUM v;
DELETE FROM v WHERE id % 10 < 3;
 heap_pages | id_pages | k_pages 
------------+----------+---------
      44248 |    27422 |   27422
DELETE 3000000

Первый VACUUM нужен, чтобы выставить биты карты видимости; DELETE убрал 30 % строк, по три из каждых десяти, поэтому мёртвые версии лежат на каждой из 44 248 страниц. Теперь VACUUM VERBOSE, а из второго терминала SELECT * FROM pg_stat_progress_vacuum, раз в 0,4 секунды:

SET maintenance_work_mem = '64MB';
VACUUM (VERBOSE) v;

Вывод VERBOSE (строки removable cutoff, frozen и скорости чтения опущены):

INFO:  launched 1 parallel vacuum worker for index vacuuming (planned: 1)
INFO:  finished vacuuming "postgres.public.v": index scans: 1
pages: 0 removed, 44248 remain, 44248 scanned (100.00% of total), 0 eagerly scanned
tuples: 3000000 removed, 7000000 remain, 0 are dead but not yet removable
visibility map: 44248 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan needed: 44248 pages from table (100.00% of total) had 3000000 dead item identifiers removed
index "v_id": pages: 27422 in total, 0 newly deleted, 0 currently deleted, 0 reusable
index "v_k": pages: 27422 in total, 0 newly deleted, 0 currently deleted, 0 reusable
buffer usage: 75795 hits, 111902 reads, 127439 dirtied
WAL usage: 187406 records, 98910 full page images, 564679581 bytes, 10235 buffers full
system usage: CPU: user: 0.87 s, system: 0.61 s, elapsed: 2.74 s

Разбор по строкам:

  • index scans: 1: все индексы прочитаны целиком один раз. Это число проходов, а не число индексов: индексов два (v_id, v_k), и для их чистки запущен один параллельный воркер.
  • 44248 scanned (100.00%): карта видимости не позволила пропустить ни одной страницы, потому что DELETE стёр её бит везде. Страницы с битом VACUUM пропускает: в опыте с усечением ниже он просмотрел 8 850 страниц из 44 248 (про карту видимости: Index Only Scan).
  • 3000000 removed и 3000000 dead item identifiers: столько TID попало в список мёртвых, по одному на каждую удалённую строку.
  • 44248 pages set all-visible: в конце все страницы снова помечены как видимые всем.
  • 565 МБ WAL (564679581 bytes) и 98910 полных образов страниц на таблицу в 44 248 страниц. Откуда берутся полные образы, разобрано в статье про full_page_writes.

Что видно в pg_stat_progress_vacuum (колонки: phase, heap_blks_total, heap_blks_scanned, heap_blks_vacuumed, index_vacuum_count, max_dead_tuple_bytes, dead_tuple_bytes, num_dead_item_ids, indexes_total, indexes_processed):

scanning heap|44248|83|0|0|67108864|1048576|5627|0|0
scanning heap|44248|25175|0|0|67108864|2097152|1706933|0|0
vacuuming indexes|44248|44248|0|0|67108864|4194304|3000000|2|0
vacuuming heap|44248|44248|2285|1|67108864|4194304|3000000|0|0
vacuuming heap|44248|44248|19810|1|67108864|4194304|3000000|0|0

Фазы идут строго по порядку: сначала scanning heap до 44 248 страниц, при этом num_dead_item_ids растёт до 3 000 000; затем vacuuming indexes с indexes_total = 2; затем vacuuming heap, где heap_blks_vacuumed ползёт от 2 285 вверх, а index_vacuum_count = 1 говорит, что один проход по индексам уже завершён. Две других фазы, cleaning up indexes и truncating heap, за 0,4 секунды опроса не попались: они короткие.

Второй проход по таблице: LP_DEAD → LP_UNUSED

Почему таблицу нужно просматривать дважды? Строка удалена, но на неё всё ещё ссылаются индексы. Освободить указатель в таблице до того, как все индексы забыли о нём, нельзя: новая строка могла бы занять тот же (страница, номер), и индекс привёл бы к чужим данным. Поэтому на первом проходе lazy_scan_prune (pruning страницы) оставляет вместо тела строки пустой указатель LP_DEAD и записывает его адрес в список, а освобождает указатель (LP_UNUSED) только вторая фаза, после чистки индексов. Тот же комментарий в vacuumlazy.c (строки 301–306) говорит об этом буквально: dead_items хранит TID, указывающие на LP_DEAD, и lazy_vacuum_heap_rel позже помечает их LP_UNUSED.

Проверим на маленькой таблице с VACUUM (INDEX_CLEANUP off): он делает фазу 1 и пропускает чистку индексов, а значит и второй проход. Устройство указателей строк (lp_flags: 1 = LP_NORMAL, 3 = LP_DEAD, 0 = LP_UNUSED) разобрано в статье про страницу:

CREATE EXTENSION pageinspect;
CREATE TABLE s (id int, k int) WITH (autovacuum_enabled = false);
INSERT INTO s SELECT g, g FROM generate_series(1, 1000) g;
CREATE INDEX s_id ON s (id);
DELETE FROM s WHERE id % 2 = 1;
VACUUM (INDEX_CLEANUP off) s;
SELECT lp, lp_flags FROM heap_page_items(get_raw_page('s', 0)) WHERE lp IN (1, 2, 3, 4);
VACUUM (VERBOSE) s;
SELECT lp, lp_flags, lp_len FROM heap_page_items(get_raw_page('s', 0)) WHERE lp IN (1, 2, 3, 4);
 lp | lp_flags 
----+----------
  1 |        3
  2 |        1
  3 |        3
  4 |        1
...
tuples: 0 removed, 500 remain, 0 are dead but not yet removable
index scan needed: 5 pages from table (100.00% of total) had 500 dead item identifiers removed
...
 lp | lp_flags | lp_len 
----+----------+--------
  1 |        0 |      0
  2 |        1 |     32
  3 |        0 |      0
  4 |        1 |     32

После фазы 1 указатели нечётных строк в состоянии LP_DEAD (3): они ещё занимают номер на странице. Полный VACUUM нашёл 500 таких указателей, почистил индекс и перевёл их в LP_UNUSED (0), у чётных строк LP_NORMAL (1) остался. Отсюда и два числа в VERBOSE: tuples: 0 removed во втором запуске, потому что версии уже убраны на фазе 1, и 500 dead item identifiers removed, указатели, освобождённые во втором проходе.

Где хранятся мёртвые TID и что с памятью

До PostgreSQL 17 список мёртвых TID был массивом по 6 байт на TID (в базе это проверяется: SELECT pg_column_size('(0,1)'::tid) вернул 6), и размер массива упирался в 1 ГБ. По примечаниям к релизу 17, VACUUM теперь «более эффективно хранит ссылки на строки» и больше не ограничен молча одним гигабайтом при большем maintenance_work_mem. Вот что написано в tidstore.c, строки 6–8:

 * TidStore is a in-memory data structure to store TIDs (ItemPointerData).
 * Internally it uses a radix tree as the storage for TIDs. The key is the
 * BlockNumber and the value is a bitmap of offsets, BlocktableEntry.

Ключ дерева — номер страницы, значение — битовая карта смещений на ней, поэтому плотные страницы стоят мало. В опыте выше 3 000 000 TID заняли dead_tuple_bytes = 4194304, то есть 4 МБ, а массив из них занял бы 3 000 000 × 6 = 18 000 000 байт (расчёт, не измерение). Память дерева растёт ступенями (1, 2, 4 МБ в выводе).

Лимит maintenance_work_mem никуда не делся: он ограничивает размер TidStore. В lazy_scan_heap, строки 1278–1280, перед каждой страницей:

		if (vacrel->dead_items_info->num_items > 0 &&
			TidStoreMemoryUsage(vacrel->dead_items) > vacrel->dead_items_info->max_bytes)
		{

Если памяти больше лимита, цикл прерывается: чистка индексов, второй проход, TidStore пересоздаётся пустым и сканирование продолжается. Каждый такой круг читает все индексы заново. Повторим удаление ещё 30 % строк при лимите в 1 МБ:

DELETE FROM v WHERE id % 10 IN (3, 4, 5);
SET maintenance_work_mem = '1MB';
VACUUM (VERBOSE) v;
INFO:  finished vacuuming "postgres.public.v": index scans: 5
tuples: 3000000 removed, 4000000 remain, 0 are dead but not yet removable
system usage: CPU: user: 0.96 s, system: 0.59 s, elapsed: 4.80 s

Пять проходов вместо одного и 4,80 секунды вместо 2,74. Опрос pg_stat_progress_vacuum показал, почему: четыре круга из пяти заканчивались на 666 405–666 408 TID при dead_tuple_bytes = 1310720, то есть чуть выше лимита в 1 048 576: условие проверяется перед очередной страницей, а память растёт блоками. Старому массиву в 1 МБ хватило бы на 1 048 576 / 6 = 174 762 TID, то есть на 18 кругов вместо 5 (расчёт). Практический вывод простой: в версии 17 и новее список TID плотнее (в опыте 4 МБ против 18 МБ), но если он всё равно не помещается в maintenance_work_mem (или autovacuum_work_mem), растёт число проходов по индексам: 1 при 64 МБ и 5 при 1 МБ.

Пропуск индексов, когда мёртвого мало

Каждый круг по индексам дорог, поэтому при малом числе мёртвых строк VACUUM их вообще не трогает. В lazy_vacuum, строки 2530–2532, условие: lpdead_item_pages < rel_pages × 0,02 (BYPASS_THRESHOLD_PAGES) и TidStoreMemoryUsage < 32 МБ. Удалим четыре строки на одной странице:

DELETE FROM v WHERE id BETWEEN 6 AND 9;
VACUUM (VERBOSE) v;
INFO:  finished vacuuming "postgres.public.v": index scans: 0
index scan bypassed: 1 pages from table (0.00% of total) have 4 dead item identifiers

index scans: 0: индексы не читались, а четыре указателя ждут следующего запуска.

Усечение хвоста: единственная фаза с блокировкой

Усечение (возврат пустых страниц в конце файла операционной системе) требует AccessExclusiveLock. В lazy_truncate_heap, строки 3264–3275, VACUUM берёт блокировку условно, ждёт и сдаётся:

			if (++lock_retry > (VACUUM_TRUNCATE_LOCK_TIMEOUT /
								VACUUM_TRUNCATE_LOCK_WAIT_INTERVAL))
			{
				/*
				 * We failed to establish the lock in the specified number of
				 * retries. This means we give up truncating.
				 */
				ereport(vacrel->verbose ? INFO : DEBUG2,
						(errmsg("\"%s\": stopping truncate due to conflicting lock request",
								vacrel->relname)));
				return;
			}

Константы: 5 000 мс на 50 мс, то есть 100 попыток (строки 179–181). Попытка вообще начинается только если пустой хвост не короче 1 000 страниц или 1/16 таблицы. Удалим всё после id = 8 000 000. Во второй сессии открой транзакцию, которая держит таблицу (AccessShareLock), и сразу запусти VACUUM; затем повтори без транзакции:

-- сессия 2
BEGIN; SELECT count(*) FROM v WHERE id = 1; SELECT pg_sleep(15); COMMIT;

-- сессия 1
DELETE FROM v WHERE id > 8000000;
VACUUM (VERBOSE) v;
VACUUM (VERBOSE) v;
SELECT pg_relation_size('v')/8192 AS heap_pages, pg_size_pretty(pg_relation_size('v'));
INFO:  "v": stopping truncate due to conflicting lock request
pages: 0 removed, 44248 remain, 8850 scanned (20.00% of total), 0 eagerly scanned
Time: 5326.744 ms (00:05.327)

INFO:  table "v": truncated 44248 to 35399 pages
pages: 8849 removed, 35399 remain, 1 scanned (0.00% of total), 0 eagerly scanned
Time: 38.119 ms

 heap_pages | pg_size_pretty 
------------+----------------
      35399 | 277 MB

Первый запуск потратил на ожидание блокировки 5 секунд из 5,3 и файл не уменьшил. Второй, без конкурента, просмотрел 1 страницу (остальные пропущены по карте видимости), отрезал 8 849 пустых страниц за 38 миллисекунд, и таблица стала 277 МБ. Обычный VACUUM оставляет свободное место внутри файла для повторного использования, а системе возвращает его только из пустого хвоста.

Цена: cost-based delay

Фазы выполняют много чтений и записей, поэтому VACUUM по умолчанию притормаживает сам себя: каждая страница из кеша стоит 1 единицу (vacuum_cost_page_hit), с диска 2 (vacuum_cost_page_miss), изменённая 20 (vacuum_cost_page_dirty); накопив vacuum_cost_limit (по умолчанию 200), он спит vacuum_cost_delay. У ручного VACUUM задержка 0, у автовакуума 2 мс. Перед каждым запуском удаляем по 800 тыс. строк (DELETE FROM v WHERE id % 10 = 6 AND id <= 8000000, затем 7 и 8) и запускаем VACUUM (VERBOSE, PARALLEL 0) с тремя настройками (SET vacuum_cost_delay, SET vacuum_cost_limit). Вывод сокращён до трёх строк:

delay=0ms limit=200:     buffer usage: 72763 hits, 93863 reads, 88625 dirtied   elapsed: 1.19 s
delay=2ms limit=2000:    buffer usage: 73069 hits, 93557 reads, 89369 dirtied   elapsed: 3.88 s
delay=2ms limit=200:     buffer usage: 73049 hits, 93577 reads, 95780 dirtied   elapsed: 24.35 s

Работа одна и та же, разница в паузах. Для третьего запуска: 73 049 × 1 + 93 577 × 2 + 95 780 × 20 = 2 175 803 единиц стоимости, делим на 200, получаем 10 879 пауз по 2 мс, это 21,8 секунды; плюс около секунды самой работы и накладные расходы сна дают 24,35. Пауза проверяется в vacuum_delay_point, которую VACUUM вызывает примерно раз на страницу.

DBA / 01

Один запуск VACUUM: четыре фазы и список мёртвых TID

Фазы одного запуска VACUUM и список мёртвых TIDФаза scanning heap просматривает 44 248 страниц и собирает 3 000 000 мёртвых TID в TidStore, радиксное дерево на 4 МБ, тогда как массив по 6 байт занял бы 18 МБ. Фаза vacuuming indexes проходит оба индекса один раз по этому списку. Фаза vacuuming heap переводит указатели LP_DEAD в LP_UNUSED на 44 248 страницах. Фаза truncating heap отрезает пустой хвост с 44 248 до 35 399 страниц, но берёт AccessExclusiveLock и сдаётся через 5 секунд. При лимите maintenance_work_mem 64 МБ индексы читаются 1 раз и запуск занимает 2,7 секунды, при лимите 1 МБ индексы читаются 5 раз и запуск занимает 4,8 секунды.1 scanning heap2 vacuuming indexes3 vacuuming heap4 truncating heap44 248 страниц просмотрено,3 000 000 TID собрано в TidStore2 индекса, проход: 1чистка по списку из TidStore44 248 страниц: LP_DEAD → LP_UNUSED44 248 → 35 399 страницAccessExclusiveLock, до 5 с ожиданияTidStoreрадиксное дерево3 000 000 TID = 4 МБмассив по 6 байт: 18 МБлимит памяти:maintenance_work_mem64 МБ: 1 проход по индексам, 2,7 с · 1 МБ: 5 проходов, 4,8 с
Фаза 1 собирает мёртвые TID в TidStore, фаза 2 чистит по ним индексы, фаза 3 освобождает указатели в таблице; когда TidStore переполняет maintenance_work_mem, круг 1–3 повторяется и индексы читаются заново. Усечение хвоста — единственный шаг с AccessExclusiveLock.

Что из этого следует

Число проходов по индексам важнее числа индексов. index scans в VERBOSE должен быть 0 или 1. Если там 4–5, как в опыте с лимитом 1 МБ, поднимай maintenance_work_mem или autovacuum_work_mem: в 17 и новее лимит в 1 ГБ снят, а список TID плотнее.

Мёртвые указатели переживают запуск, если индексы пропущены. После index scan bypassed четыре LP_DEAD остались, пока их не уберёт следующий запуск. Проблемой это не считается: порог 2 % страниц и 32 МБ защищает от лишнего круга.

Усечение не гарантировано. Таблица после массового DELETE может не уменьшиться на диске, потому что VACUUM отказался от AccessExclusiveLock через 5 секунд. Смотри VERBOSE: сообщение stopping truncate due to conflicting lock request объясняет, почему размер остался прежним. Длинные транзакции, держащие таблицу, и их влияние на мёртвые версии разобраны в статье про MVCC; замораживание старых xmin на той же фазе сканирования — в статье про wraparound.

Скорость определяют паузы, а не работа. Одна и та же очистка заняла 1,19 с без задержки и 24,35 с при 2 мс и лимите 200. Если автовакуум не успевает за нагрузкой, сначала смотри на autovacuum_vacuum_cost_limit, а не на железо.

Чек-лист

  1. После крупного DELETE запусти VACUUM (VERBOSE) и прочитай index scans: больше 1 означает, что maintenance_work_mem мал для списка мёртвых TID.
  2. Для долгого VACUUM смотри фазу в pg_stat_progress_vacuum: index_vacuum_count растёт, пока цикл повторяется.
  3. Если таблица не уменьшилась, поищи в выводе stopping truncate и проверь, нет ли долгих транзакций с AccessShareLock.
  4. Для автовакуума сравни autovacuum_vacuum_cost_limit и объём изменённых страниц: в опыте при лимите 200 и 2 мс на паузы ушло около 90 % времени запуска (21,8 из 24,35 с).

Контейнер удаляется командой docker rm -f -v dba-vacuum-internals.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru