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

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

Заморозка в PostgreSQL: aggressive VACUUM, eager freezing и аварийный autovacuum

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

Autovacuum, который неделю не мешал, вдруг читает таблицу целиком и не слушается даже при autovacuum_enabled = false. Это не очистка мёртвых строк: он запущен по возрасту relfrozenxid и обязан пройти все страницы, где ещё есть незамороженные строки. Кольцо XID и остановку базы разбирает статья про wraparound; здесь — как PostgreSQL решает, что пора, и как перенести работу в удобное время.

Флаг заморозки в t_infomask описан в статье про заголовок строки, бит all-frozen карты видимости — в статье про Index Only Scan, обычный цикл очистки — в VACUUM и autovacuum.

Три порога и граница агрессивности

Значения по умолчанию в PostgreSQL 18.6 лежат в pg_settings.boot_val:

SELECT name, boot_val FROM pg_settings
WHERE name IN ('vacuum_freeze_min_age','vacuum_freeze_table_age','autovacuum_freeze_max_age',
               'vacuum_max_eager_freeze_failure_rate','vacuum_multixact_freeze_min_age',
               'vacuum_multixact_freeze_table_age','autovacuum_multixact_freeze_max_age') ORDER BY 1;
                 name                 |  boot_val
--------------------------------------+-----------
 autovacuum_freeze_max_age            | 200000000
 autovacuum_multixact_freeze_max_age  | 400000000
 vacuum_freeze_min_age                | 50000000
 vacuum_freeze_table_age              | 150000000
 vacuum_max_eager_freeze_failure_rate | 0.03
 vacuum_multixact_freeze_min_age      | 5000000
 vacuum_multixact_freeze_table_age    | 150000000

vacuum_freeze_min_age отвечает на вопрос «какие строки замораживать»: FreezeLimit равен текущему номеру XID минус этот возраст (vacuum.c, строка 1204), и замораживаются версии со старшим xmin. vacuum_freeze_table_age отвечает на вопрос «сколько страниц читать»: когда age(relfrozenxid) дошёл до него, VACUUM становится агрессивным. Условие в vacuum_get_cutoffs, vacuum.c, строки 1240–1249:

	if (freeze_table_age < 0)
		freeze_table_age = vacuum_freeze_table_age;
	freeze_table_age = Min(freeze_table_age, autovacuum_freeze_max_age * 0.95);
	Assert(freeze_table_age >= 0);
	aggressiveXIDCutoff = nextXID - freeze_table_age;
	if (!TransactionIdIsNormal(aggressiveXIDCutoff))
		aggressiveXIDCutoff = FirstNormalTransactionId;
	if (TransactionIdPrecedesOrEquals(cutoffs->relfrozenxid,
									  aggressiveXIDCutoff))
		return true;

Порог ограничен сверху 0.95 × autovacuum_freeze_max_age, чтобы плановый VACUUM успел раньше аварийного; freeze_min_age тем же кодом ограничен половиной autovacuum_freeze_max_age (строка 1200). Третий порог, autovacuum_freeze_max_age, запускает autovacuum принудительно. VACUUM (FREEZE) обнуляет оба возраста (vacuum.c, строки 404–409) и поэтому всегда агрессивен.

Опыт 1: обычный VACUUM, eager freezing и VACUUM (FREEZE)

Четыре одинаковые таблицы по 10 млн строк (422 МБ); перед опытом все страницы all-visible, но не заморожены. count(*) проставляет хинт-биты, CHECKPOINT сбрасывает их на диск, поэтому первый VACUUM только отмечает страницы. Autovacuum на таблицах выключен, чтобы он не вмешался:

docker run -d --name dba-freezing -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-freezing psql -U postgres <<'SQL'
CREATE EXTENSION pg_visibility;
CREATE TABLE t_a (id int, v text) WITH (autovacuum_enabled = false);
CREATE TABLE t_b (LIKE t_a) WITH (autovacuum_enabled = false);
CREATE TABLE t_c (LIKE t_a) WITH (autovacuum_enabled = false);
CREATE TABLE t_d (LIKE t_a) WITH (autovacuum_enabled = false);
INSERT INTO t_a SELECT g, 'row' || g FROM generate_series(1,10000000) g;
INSERT INTO t_b SELECT * FROM t_a;
INSERT INTO t_c SELECT * FROM t_a;
INSERT INTO t_d SELECT * FROM t_a;
SELECT count(*) FROM t_a; SELECT count(*) FROM t_b; SELECT count(*) FROM t_c; SELECT count(*) FROM t_d;
CHECKPOINT;
VACUUM t_a; VACUUM t_b; VACUUM t_c; VACUUM t_d;
SQL
docker exec dba-freezing sh -c "echo 'SELECT pg_current_xact_id();' > /tmp/burn.sql"
docker exec dba-freezing pgbench -U postgres -n -f /tmp/burn.sql -c 8 -j 4 -t 2500 postgres

Ждать 50 млн транзакций ради vacuum_freeze_min_age незачем: pgbench потратил 20 000 номеров, а пороги в сессии уменьшены пропорционально. Состояние перед опытом:

SELECT relname, relpages, relfrozenxid, age(relfrozenxid) age,
       (pg_visibility_map_summary(oid)).all_visible av, (pg_visibility_map_summary(oid)).all_frozen af
FROM pg_class WHERE relname LIKE 't\_%' ORDER BY 1;
 relname | relpages | relfrozenxid |  age  |  av   | af
---------+----------+--------------+-------+-------+----
 t_a     |    54055 |          761 | 20015 | 54055 |  0
 t_b     |    54055 |          762 | 20014 | 54055 |  0
 t_c     |    54055 |          763 | 20013 | 54055 |  0
 t_d     |    54055 |          764 | 20012 | 54055 |  0

Все 54 055 страниц all-visible, ни одна не all-frozen. Три запуска на трёх таблицах: t_a с выключенным eager scanning, t_b с настройками по умолчанию, t_c с FREEZE:

SET vacuum_freeze_min_age = 1000;
SET vacuum_max_eager_freeze_failure_rate = 0;
VACUUM (VERBOSE) t_a;
RESET vacuum_max_eager_freeze_failure_rate;
VACUUM (VERBOSE) t_b;
VACUUM (FREEZE, VERBOSE) t_c;

Из вывода VERBOSE для основной таблицы каждого запуска:

ЗапускПросмотрено страницЗаморожено страницWAL, байтВремя
t_a, eager выключен119 0840,00 с
t_b, по умолчанию10 903 (20,17 %)10 90390 285 8410,93 с
t_c, FREEZE54 055 (100 %)54 055447 584 4015,04 с

Обычный VACUUM без eager scanning не тронул ни одной страницы, кроме последней, которую просматривает всегда. С настройками по умолчанию он сам прочитал и заморозил пятую часть таблицы, а лог объяснил, почему остановился:

INFO:  disabling eager scanning after freezing 10811 eagerly scanned blocks of relation "postgres.public.t_b"
pages: 0 removed, 54055 remain, 10903 scanned (20.17% of total), 10902 eagerly scanned

10 811 = 0,2 × 54 055. Это потолок успешных заморозок за один проход, vacuumlazy.c, строки 234–241:

/*
 * An eager scan of a page that is set all-frozen in the VM is considered
 * "successful". To spread out freezing overhead across multiple normal
 * vacuums, we limit the number of successful eager page freezes. The maximum
 * number of eager page freezes is calculated as a ratio of the all-visible
 * but not all-frozen pages at the beginning of the vacuum.
 */
#define MAX_EAGER_FREEZE_SUCCESS_RATE 0.2

Второй предохранитель — доля неудач, vacuum_max_eager_freeze_failure_rate (0,03): если VACUUM прочитал страницу all-visible, но заморозить её не смог (в ней есть строка моложе FreezeLimit), попытка считается неудачей, и после 3 % неудач в области из 4096 страниц eager-просмотр в ней приостанавливается (документация). Здесь все страницы были «старыми», неудач не случилось. Для таблиц меньше 8192 страниц eager scanning не включается: rel_pages < 2 * EAGER_SCAN_REGION_SIZE в heap_vacuum_eager_scan_setup.

FREEZE назвал себя «aggressively vacuuming», прочёл все 54 055 страниц, записал 54 057 полных образов страниц и 447 584 401 байт WAL: около 8 280 байт на страницу, то есть каждая замороженная страница уходит в журнал целиком. В 4,96 раза больше WAL у FREEZE пришлись на 5,4 раза больше времени.

Опыт 2: агрессивный VACUUM читает всё, но замораживает не всё

В t_d добавлен 1 млн свежих строк (одна транзакция, XID 20 778), пороги в сессии: freeze_min_age 10 000, freeze_table_age 15 000 при возрасте таблицы около 20 000:

INSERT INTO t_d SELECT g, 'new' || g FROM generate_series(1,1000000) g;
SELECT count(*) FROM t_d;
CHECKPOINT;
SET vacuum_freeze_min_age = 10000;
SET vacuum_freeze_table_age = 15000;
VACUUM (VERBOSE) t_d;
INFO:  aggressively vacuuming "postgres.public.t_d"
pages: 0 removed, 59460 remain, 59460 scanned (100.00% of total), 0 eagerly scanned
new relfrozenxid: 20778, which is 20014 XIDs ahead of previous value
frozen: 54055 pages from table (90.91% of total) had 10000175 tuples frozen
visibility map: 5406 pages set all-visible, 54055 pages set all-frozen (54054 were all-visible)
WAL usage: 113519 records, 59466 full page images, 492089204 bytes, 13320 buffers full
system usage: CPU: user: 0.67 s, system: 0.61 s, elapsed: 4.25 s

Просмотрены все 59 460 страниц, заморожены только 54 055: строки с XID 20 778 моложе FreezeLimit (около 20 780 − 10 000), и страницы с ними стали all-visible без заморозки. Поэтому relfrozenxid сдвинулся не на текущий номер, а на 20 778 — самый старый оставшийся незамороженный xmin. Агрессивность задаёт охват просмотра, возраст строк — объём заморозки. Правило охвата лежит в heap_vac_scan_next_block, vacuumlazy.c, строки 1749–1761:

		/*
		 * All-frozen pages cannot contain XIDs < OldestXmin (XIDs that aren't
		 * already frozen by now), so this page can be skipped.
		 */
		if ((mapbits & VISIBILITYMAP_ALL_FROZEN) != 0)
			continue;

		/*
		 * Aggressive vacuums cannot skip any all-visible pages that are not
		 * also all-frozen.
		 */
		if (vacrel->aggressive)
			break;

Страницы all-frozen пропускают оба режима, а агрессивный не может пропустить страницу, которая только all-visible. Отсюда следствие: чем больше страниц заморожено, тем дешевле следующий агрессивный проход. Запас до порогов по всем таблицам считается запросом (значения взяты после опыта 3, где autovacuum_freeze_max_age уменьшен до 100 000, поэтому порог агрессивности равен 95 000):

SELECT relname, age(relfrozenxid) AS xid_age,
       least(current_setting('vacuum_freeze_table_age')::int,
             current_setting('autovacuum_freeze_max_age')::int * 0.95)::int - age(relfrozenxid) AS to_aggressive,
       current_setting('autovacuum_freeze_max_age')::int - age(relfrozenxid) AS to_forced
FROM pg_class WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace ORDER BY xid_age DESC;
 relname | xid_age | to_aggressive | to_forced
---------+---------+---------------+-----------
 t_c     |   96011 |         -1011 |      3989
 t_d     |   96010 |         -1010 |      3990
 t_a     |    6007 |         88993 |     93993

Отрицательный to_aggressive значит, что следующий VACUUM будет агрессивным, to_forced — сколько номеров осталось до принудительного autovacuum. VACUUM (VERBOSE) t_c на «просроченной» t_c, которая вся заморожена в опыте 1, показал цену:

INFO:  aggressively vacuuming "postgres.public.t_c"
pages: 0 removed, 54055 remain, 1 scanned (0.00% of total), 0 eagerly scanned
new relfrozenxid: 116788, which is 96011 XIDs ahead of previous value
WAL usage: 1 records, 0 full page images, 307 bytes, 0 buffers full

Агрессивный проход прочёл 1 страницу из 54 055 и сдвинул relfrozenxid на 96 011 номеров: остальные страницы all-frozen и пропущены.

DBA / 01

Шкала возраста таблицы: пороги и три прохода из опыта

Шкала возраста relfrozenxid с порогами заморозки и тремя проходами VACUUM из опытаГоризонтальная шкала возраста XID до 120 000. До 10 000 лежит vacuum_freeze_min_age, до 95 000 обычный VACUUM, с 95 000 vacuum_freeze_table_age, после него следующий VACUUM агрессивный, с 100 000 autovacuum_freeze_max_age и аварийный autovacuum. Ниже три прохода: агрессивный по уже замороженной t_c с возрастом 96 011 читает одну страницу из 54 055 за 0,00 с; аварийный по t_b с возрастом 110 019 читает 43 153 страницы за 25,20 с, потому что 10 903 страницы заморожены заранее; аварийный по t_a с возрастом 110 020 читает 54 055 страниц за 29,39 с и пишет 447 592 018 байт WAL.возраст XID: текущий номер минус номер транзакции, шкала до 120 000обычный VACUUM пропускает all-frozen10 000vacuum_freeze_min_age95 000vacuum_freeze_table_age100 000autovacuum_freeze_max_aget_c, возраст 96 011: aggressively vacuuming1 страница из 54 055, relfrozenxid +96 011, 0,00 сt_b, возраст 110 019: аварийный autovacuum43 153 страницы (79,83 %), 10 903 заморожены заранее, 25,20 сt_a, возраст 110 020: аварийный autovacuum54 055 страниц, 447 592 018 байт WAL, 29,39 сautovacuum_freeze_max_age = 100 000 — минимум параметра, по умолчанию 200 000 000
Агрессивный проход по уже замороженной t_c читает одну страницу из 54 055 за 0,00 с. Аварийный по t_b, где eager scanning заранее заморозил 10 903 страницы, просматривает 43 153 страницы за 25,20 с, а по t_a без этого — все 54 055 за 29,39 с. Возраст t_c, 96 011, попал в узкую зону между порогом агрессивности (95 000) и принудительным autovacuum (100 000).

Опыт 3: аварийный autovacuum

Минимум autovacuum_freeze_max_age — 100 000, параметр требует перезапуска. Контейнер перезапущен с этим значением, лог autovacuum включён, проверка идёт каждые 5 секунд; pgbench потратил ещё 90 000 номеров:

docker exec -i dba-freezing psql -U postgres -c "ALTER SYSTEM SET autovacuum_freeze_max_age = 100000" \
  -c "ALTER SYSTEM SET autovacuum_naptime = 5" -c "ALTER SYSTEM SET log_autovacuum_min_duration = 0"
docker restart dba-freezing
docker exec dba-freezing pgbench -U postgres -n -f /tmp/burn.sql -c 8 -j 4 -t 11250 postgres
docker exec -i dba-freezing psql -U postgres -Atc "SELECT pid, left(query,80) FROM pg_stat_activity WHERE backend_type='autovacuum worker'"
63|autovacuum: VACUUM ANALYZE public.t_a (to prevent wraparound)
64|autovacuum: VACUUM ANALYZE public.t_b (to prevent wraparound)

Таблицы t_a и t_b (возраст 110 020 и 110 019 при пороге 100 000) пошли на заморозку, хотя autovacuum_enabled = false. Из docker logs dba-freezing:

LOG:  automatic aggressive vacuum to prevent wraparound of table "postgres.public.t_b": index scans: 0
pages: 0 removed, 54055 remain, 43153 scanned (79.83% of total), 0 eagerly scanned
frozen: 43152 pages from table (79.83% of total) had 7983120 tuples frozen
WAL usage: 86305 records, 43156 full page images, 357326266 bytes, 0 buffers full
system usage: CPU: user: 0.78 s, system: 0.54 s, elapsed: 25.20 s
LOG:  automatic aggressive vacuum to prevent wraparound of table "postgres.public.t_a": index scans: 0
pages: 0 removed, 54055 remain, 54055 scanned (100.00% of total), 0 eagerly scanned
frozen: 54054 pages from table (100.00% of total) had 9999990 tuples frozen
WAL usage: 108109 records, 54057 full page images, 447592018 bytes, 0 buffers full
system usage: CPU: user: 0.95 s, system: 0.74 s, elapsed: 29.39 s

Тот же объём работы, что у ручного VACUUM (FREEZE) на t_c в опыте 1 (54 055 страниц, 447,6 МБ WAL), занял 29,39 с вместо 5,04: скорость чтения упала с 83,3 до 14,4 МБ/с. Объяснение — троттлинг: у ручного VACUUM vacuum_cost_delay равен 0, а у autovacuum по умолчанию autovacuum_vacuum_cost_delay 2 мс при autovacuum_vacuum_cost_limit −1, то есть общем vacuum_cost_limit 200 (значения прочитаны командой SHOW). Аварийный проход идёт с теми же настройками, что и обычный фоновый. К тому же оба прохода шли одновременно, и бюджет 200 делился между двумя воркерами, по 100 на каждого. Оценка: 54 055 страниц × (2 за чтение + 20 за изменение) / 100 × 2 мс ≈ 23,8 с сна, плюс около 5 с самой работы, — сходится с 29,39 с; отдельно это не измерялось.

У t_b разница другая. Eager scanning из опыта 1 успел заморозить 10 903 страницы, и аварийный проход их пропустил: просмотрено 43 153 страницы (79,83 %), 25,20 с. Сумма WAL двух проходов t_b, 90 285 841 + 357 326 266 = 447 612 107 байт, отличается от WAL одного прохода t_a на 20 089 байт. Eager freezing не уменьшает общий объём записи, а переносит часть работы в обычные проходы.

Мультитранзакции

Когда на одну строку претендуют несколько блокировщиков, xmax хранит не XID, а номер мультитранзакции со списком участников. Проверка внешнего ключа берёт FOR KEY SHARE на строку родителя, поэтому две одновременные вставки в дочернюю таблицу с одним родителем создают мультитранзакцию:

CREATE TABLE parent (id int PRIMARY KEY, v text) WITH (autovacuum_enabled = false);
CREATE TABLE child (id int PRIMARY KEY, parent_id int REFERENCES parent) WITH (autovacuum_enabled = false);
INSERT INTO parent VALUES (1, 'p');
-- сессия 1: BEGIN; INSERT INTO child VALUES (1,1); и пауза
-- сессия 2: BEGIN; INSERT INTO child VALUES (2,1); и пауза
SELECT lp, t_xmax, to_hex(t_infomask) infomask, (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags
FROM heap_page_items(get_raw_page('parent',0));
 lp | t_xmax | infomask |                                              raw_flags
----+--------+----------+-----------------------------------------------------------------------------------------------------
  1 |      1 | 1192     | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}

t_xmax = 1 — номер мультитранзакции (флаг HEAP_XMAX_IS_MULTI, 0x1000), а mxid_age(relminmxid) у parent вырос с 0 до 1. После коммита обеих вставок и VACUUM (FREEZE) parent в строке t_xmax 0, infomask b02 (флаги заморозки и HEAP_XMAX_INVALID), relminmxid сдвинулся с 1 на 2. Параметры мультитранзакций повторяют XID-набор с другими числами (таблица выше): 5 млн, 150 млн, 400 млн. Кроме возраста есть второй счётчик: MultiXactMemberFreezeThreshold (multixact.c, строки 3122–3185) сокращает autovacuum_multixact_freeze_max_age, когда занято много места под списки участников; по комментарию в коде в худшем случае значение доходит до нуля, и каждый VACUUM замораживает все мультитранзакции. Эту часть опыт не воспроизводил.

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

Заморозку надо планировать, а не ждать аварийного режима. Ручной VACUUM (FREEZE) в окне низкой нагрузки прошёл таблицу за 5,04 с, тот же объём аварийным autovacuum — за 29,39 с с постоянной нагрузкой на диск. WAL в обоих случаях 447,6 МБ.

Eager freezing в PostgreSQL 18 сглаживает пик, но не отменяет его. За один обычный проход заморожено 20,17 % страниц all-visible, и следующий агрессивный проход читал на столько же меньше. Если замороженные страницы обновляются снова, работа пропадает впустую: по документации поэтому доля неудач ограничена параметром vacuum_max_eager_freeze_failure_rate, его можно задать и на таблицу.

Заморозка пишет страницы в журнал целиком. 8 280 байт WAL на страницу в опыте 1 уходят в тот же журнал, который читают реплики и архив, поэтому ручной VACUUM (FREEZE) большой таблицы планируй с запасом под WAL.

Чек-лист

  1. Раз в сутки считай to_aggressive и to_forced по age(relfrozenxid) и mxid_age(relminmxid) для каждой таблицы; тревогу ставь на to_aggressive < 0, а не на to_forced.
  2. Таблицы, перешедшие порог vacuum_freeze_table_age, замораживай VACUUM (FREEZE) в окне низкой нагрузки, не дожидаясь принудительного autovacuum.
  3. Читай VACUUM (VERBOSE): eagerly scanned и frozen: N pages показывают, сколько страниц заморожено заранее.
  4. Не отключай autovacuum на таблице ради «тишины»: принудительный проход всё равно придёт, но по возрасту, а не по расписанию.
  5. После опыта удали стенд вместе с данными: docker rm -f -v dba-freezing. Он занял на диске 2,8 ГБ.

Источники

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