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 вызывает примерно раз на страницу.
Один запуск VACUUM: четыре фазы и список мёртвых TID
Что из этого следует
Число проходов по индексам важнее числа индексов. 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, а не на железо.
Чек-лист
- После крупного
DELETEзапустиVACUUM (VERBOSE)и прочитайindex scans: больше 1 означает, чтоmaintenance_work_memмал для списка мёртвых TID. - Для долгого
VACUUMсмотри фазу вpg_stat_progress_vacuum:index_vacuum_countрастёт, пока цикл повторяется. - Если таблица не уменьшилась, поищи в выводе
stopping truncateи проверь, нет ли долгих транзакций сAccessShareLock. - Для автовакуума сравни
autovacuum_vacuum_cost_limitи объём изменённых страниц: в опыте при лимите 200 и 2 мс на паузы ушло около 90 % времени запуска (21,8 из 24,35 с).
Контейнер удаляется командой docker rm -f -v dba-vacuum-internals.