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

Статья 2.2 · раздел 2 «Транзакции и MVCC» · 9 из 109

Wraparound XID в PostgreSQL: сравнение по кольцу, relfrozenxid и остановка за 3 млн

опубликовано
читать
≈ 10 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

Кластер PostgreSQL, который накопил 2,1 млрд транзакций без полной заморозки, сначала пишет в лог must be vacuumed within 40000000 transactions, а за 3 млн номеров до границы перестаёт выдавать новые: читать можно, писать нельзя. Причина в том, что 32-битный номер транзакции (XID) сравнивается по кольцу, а не по числовой оси.

Что такое t_xmin, t_xmax и хинт-биты, разобрано в статье про заголовок строки; как очистка убирает мёртвые версии, — в VACUUM и autovacuum. Здесь — что происходит, когда номера в t_xmin стареют.

Опыт 1: возраст XID растёт с каждой транзакцией

Каталог данных лежит на именованном томе: он понадобится в опыте 3.

docker run -d --name dba-transaction-id-wraparound -v dba-txid-data:/var/lib/postgresql -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-transaction-id-wraparound psql -U postgres -c "SELECT version()" \
  -c "SELECT name, setting FROM pg_settings WHERE name IN ('autovacuum_freeze_max_age','vacuum_freeze_min_age','vacuum_freeze_table_age','vacuum_failsafe_age') ORDER BY setting::bigint" \
  -c "SELECT pg_current_xact_id()" -c "SELECT age(datfrozenxid) FROM pg_database WHERE datname='postgres'"
docker exec dba-transaction-id-wraparound pgbench -U postgres -i -s 10 postgres
docker exec dba-transaction-id-wraparound pgbench -U postgres -c 4 -T 10 postgres
docker exec -i dba-transaction-id-wraparound psql -U postgres -c "SELECT pg_current_xact_id()" \
  -c "SELECT age(datfrozenxid) FROM pg_database WHERE datname='postgres'" \
  -c "SELECT relname, relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname='pgbench_accounts'"
           name            |  setting   
---------------------------+------------
 vacuum_freeze_min_age     | 50000000
 vacuum_freeze_table_age   | 150000000
 autovacuum_freeze_max_age | 200000000
 vacuum_failsafe_age       | 1600000000

 pg_current_xact_id 
--------------------
                753
 age 
-----
   9

 pg_current_xact_id 
--------------------
              14004
  age  
-------
 13260

     relname      | relfrozenxid |  age  
------------------+--------------+-------
 pgbench_accounts |          761 | 13244

version() вернул PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu; четыре параметра выше понадобятся дальше. pgbench обработал 13 237 транзакций (1324 tps): счётчик ушёл с 753 до 14 004, возраст базы вырос с 9 до 13 260. pg_class.relfrozenxid — номер, старше которого в таблице нет незамороженных t_xmin; pg_database.datfrozenxid — минимум relfrozenxid по базе. У pgbench_accounts relfrozenxid остался 761: таблицу только загрузили, и ничто не требовало её замораживать.

Сравнение по кольцу и заморозка

Номеров 2³² = 4 294 967 296; номера 0–2 служебные (FrozenTransactionId = 2), обычные идут от FirstNormalTransactionId = 3 до MaxTransactionId = 0xFFFFFFFF, затем счётчик возвращается к началу. Вопрос «раньше ли id1, чем id2» решает TransactionIdPrecedes. В 18.6 это обычная функция в src/backend/access/transam/transam.c, а не макрос и не inline; в transam.h лежит только объявление. Строки 286–292:

	int32		diff;

	if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
		return (id1 < id2);

	diff = (int32) (id1 - id2);
	return (diff < 0);

Для служебных номеров сравнение обычное беззнаковое, поэтому FrozenTransactionId всегда раньше любого нормального. Для нормальных разность берётся по модулю 2³² и читается как знаковое число: каждому номеру соответствуют 2³¹ номеров позади и 2³¹ впереди. age(xid) показывает ту же арифметику. Проверка на четырёх номерах вокруг границы при текущем номере 14 018:

BEGIN;
SELECT pg_current_xact_id()::text::bigint AS cur \gset
SELECT x, x - :cur AS dist, age(x::text::xid) AS age, CASE WHEN age(x::text::xid) > 0 THEN 'прошлое' ELSE 'будущее' END AS side
FROM (VALUES (:cur::bigint + 2147483646), (:cur::bigint + 2147483647), (:cur::bigint + 2147483648), (:cur::bigint + 2147483649)) v(x);
COMMIT;
     x      |    dist    |     age     |  side   
------------+------------+-------------+---------
 2147497664 | 2147483646 | -2147483646 | будущее
 2147497665 | 2147483647 | -2147483647 | будущее
 2147497666 | 2147483648 | -2147483648 | будущее
 2147497667 | 2147483649 |  2147483647 | прошлое
(4 rows)

Номера на 2 147 483 646, 2 147 483 647 и 2 147 483 648 впереди текущего получили age −2 147 483 646, −2 147 483 647 и −2 147 483 648: это «будущее». Номер на 2 147 483 649 впереди получил age 2 147 483 647: он уже «прошлое». Граница кольца там, где int32 меняет знак. Для строки это значит: пока счётчик ушёл от её t_xmin меньше чем на 2³¹ − 1, она видна как созданная в прошлом; дальше тот же t_xmin читается как «из будущего», и строка исчезает из снимков всех транзакций. Данные на диске целы, но прочесть их нельзя.

Чтобы строка не состарилась, её t_xmin объявляют постоянно «прошлым» — замораживают. Таблица из трёх строк, затем VACUUM (FREEZE):

CREATE EXTENSION pageinspect;
CREATE TABLE fz (id int, v text);
INSERT INTO fz SELECT g, 'row' || g FROM generate_series(1,3) g;
SELECT * FROM fz;
SELECT lp, t_xmin, to_hex(t_infomask) infomask FROM heap_page_items(get_raw_page('fz',0));
VACUUM (FREEZE) fz;
SELECT lp, t_xmin, to_hex(t_infomask) infomask, (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags, (heap_tuple_infomask_flags(t_infomask, t_infomask2)).combined_flags FROM heap_page_items(get_raw_page('fz',0));
SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname='fz';

Результат после заморозки (строки 2 и 3 идентичны первой):

 lp | t_xmin | infomask |                                 raw_flags                                  |   combined_flags   
----+--------+----------+----------------------------------------------------------------------------+--------------------
  1 |  14021 | b02      | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID} | {HEAP_XMIN_FROZEN}

Все три строки вставила транзакция 14 021. После SELECT infomask стал 902 (хинт-бит HEAP_XMIN_COMMITTED, как в статье про заголовок строки), после VACUUM (FREEZE) — b02, и pageinspect назвал флаги HEAP_XMIN_FROZEN: в htup_details.h это сумма HEAP_XMIN_COMMITTED и HEAP_XMIN_INVALID. Номер не стёрт: t_xmin по-прежнему 14 021, xmin в запросе тоже. relfrozenxid таблицы сдвинулся с 14 020 на 14 022. Читатель, видя флаг, не сравнивает t_xmin с кольцом вообще.

Обычный VACUUM так не делает: на второй таблице он оставил строку незамороженной (combined_flags пуст), а у pgbench_accounts relfrozenxid стоял на 761 при возрасте 13 263. По умолчанию VACUUM замораживает только то, что старше vacuum_freeze_min_age (50 млн), а агрессивный просмотр всех страниц включается при возрасте таблицы больше vacuum_freeze_table_age (150 млн). VACUUM (FREEZE) по базе убирает порог: relfrozenxid всех таблиц стал 14 024, datfrozenxid базы postgres перескочил с 745 на 14 024. В 18-й версии обычный VACUUM ещё и замораживает часть страниц заранее (примечания к выпуску), но для таблицы из одной страницы это ничего не меняет.

Пороги в коде: 40 млн и 3 млн

Все пределы сервер считает от одного числа, самого старого datfrozenxid по кластеру, в SetTransactionIdLimit (varsup.c). Граница кольца xidWrapLimit — это oldest_datfrozenxid + (MaxTransactionId >> 1) (строка 389), принудительный autovacuum — oldest_datfrozenxid + autovacuum_freeze_max_age (строка 438), а остановка и предупреждения заданы так, строки 405–419:

	xidStopLimit = xidWrapLimit - 3000000;
	if (xidStopLimit < FirstNormalTransactionId)
		xidStopLimit -= FirstNormalTransactionId;

	/*
	 * We'll start complaining loudly when we get within 40M transactions of
	 * data loss.  This is kind of arbitrary, but if you let your gas gauge
	 * get down to 2% of full, would you be looking for the next gas station?
	 * We need to be fairly liberal about this number because there are lots
	 * of scenarios where most transactions are done by automatic clients that
	 * won't pay attention to warnings.  (No, we're not gonna make this
	 * configurable.  If you know enough to configure it, you know enough to
	 * not get in this kind of trouble in the first place.)
	 */
	xidWarnLimit = xidWrapLimit - 40000000;

При выдаче каждого номера GetNewTransactionId сверяет его с пределами. Отказ срабатывает только под управлением postmaster (IsUnderPostmaster): в однопользовательском режиме остановки нет, это запасной выход администратора. Текст отказа, строки 154–157:

				ereport(ERROR,
						(errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED),
						 errmsg("database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database \"%s\"",
								oldest_datname),

Если самый старый datfrozenxid равен rel, все пороги — сдвиги от rel + 2³¹ − 1. В опыте ниже rel = 14 028: граница 2 147 497 675, предупреждения с 2 107 497 675, отказ с 2 144 497 675, принудительный autovacuum с 214 014 028.

DBA / 01

Кольцо XID: окно 2³¹ от relfrozenxid и 40 млн до остановки

Кольцо из 2³² номеров транзакций с окном 2³¹ и зоной предупреждений и остановкиКольцо номеров XID. От relfrozenxid 14 028 по часовой стрелке идёт окно 2³¹ номеров. Первые 200 миллионов зелёные, дальше принудительный autovacuum. Граница окна 2 147 497 675. За 40 миллионов до неё, с номера 2 107 497 675, сервер пишет предупреждение must be vacuumed within 40000000 transactions. За 3 миллиона до границы, с номера 2 144 497 675, сервер отказывается выдавать новые XID. Справа примеры сравнения (int32)(a минус b): при расстоянии 2 147 483 648 номер считается будущим, при 2 147 483 649 прошлым.relfrozenxid 14 028+200 млн:anti-wrapautovacuum2³² номеровокно 2³¹ вперёдза ним — «будущее»next XID 2 144 497 678(int32)(a − b) < 0 значит «a раньше b». cur = 14 018:cur + 2 147 483 648 → age −2 147 483 648будущееcur + 2 147 483 649 → age 2 147 483 647прошлоеянтарный штрих внизу кольца в увеличении (1 млн = 10 px)2 107 497 675WARNING: must be vacuumedwithin 40000000 transactions2 144 497 675ERROR: not accepting commandsthat assign new transaction IDs2 147 497 675 = 14 028 + 2³¹ − 1VACUUM во всех трёх базах сдвинул datfrozenxid до 2 144 497 675, и XID снова выдаются.
Окно существования XID — половина кольца вперёд от relfrozenxid. Последние 40 миллионов номеров до границы делятся на зону предупреждений (37 млн) и зону отказа (3 млн); выйти из неё можно только заморозкой во всех базах кластера, включая template1 и template0.

Опыт 3: подойти к границе (только на копии)

Только на учебной копии. pg_resetwal сбрасывает журнал предзаписи и теряет всё, что в нём не дошло до файлов данных; документация оставляет его на крайний случай. На проде его не трогай.

Сначала заморозь все три базы. template0 не принимает подключений, поэтому на время заморозки их разрешают:

docker exec dba-transaction-id-wraparound vacuumdb -U postgres --all --freeze
docker exec -i dba-transaction-id-wraparound psql -U postgres -c "ALTER DATABASE template0 ALLOW_CONNECTIONS true"
docker exec dba-transaction-id-wraparound vacuumdb -U postgres -d template0 --freeze
docker exec -i dba-transaction-id-wraparound psql -U postgres -c "ALTER DATABASE template0 ALLOW_CONNECTIONS false" \
  -Atc "SELECT datname, datfrozenxid, age(datfrozenxid), datallowconn FROM pg_database ORDER BY 1"

Получилось postgres|14028|2|t, template0|14029|1|f, template1|14028|2|t.

Теперь сервер надо остановить, а pg_resetwal запустить во втором контейнере на том же томе:

r() { docker run --rm -u postgres -v dba-txid-data:/var/lib/postgresql postgres:18 "$@"; }
D=/var/lib/postgresql/18/docker
docker stop dba-transaction-id-wraparound
r pg_resetwal -D $D -u 14028 -x 2107497600
r sh -c "dd if=/dev/zero of=$D/pg_xact/07D9 bs=262144 count=1"   # без файла pg_xact для нового номера сервер не стартует
docker start dba-transaction-id-wraparound
docker exec dba-transaction-id-wraparound pgbench -U postgres -c 1 -t 120 postgres

После pg_resetwal pg_controldata показывал NextXID 0:2107497600 и oldestXID's DB 0. -x задаёт следующий номер, -u — самый старый (14 028, как в pg_controldata), чтобы пороги считались от реальной заморозки. Без 07D9 старт падает с FATAL: could not access status of transaction 2107497600 и Could not open file "pg_xact/07D9": сегмент считается как 2 107 497 600 / 32 768 / 32 = 2009 = 0x7D9. Первая строка лога с предупреждением:

2026-10-27 12:34:25.806 UTC [58] WARNING:  database "postgres" must be vacuumed within 40000000 transactions

Она пришла на номере 2 107 497 675, ровно на xidWarnLimit; к концу прогона таких строк было 48, по одной на каждую новую транзакцию. Заодно pgbench при старте очистил свои таблицы, и сервер ответил bypassing nonessential maintenance ... as a failsafe: при возрасте таблицы выше vacuum_failsafe_age (1,6 млрд) VACUUM пропускает необязательную работу.

Теперь так же поставь следующий номер на 2 144 497 000 (файл 07FD):

docker stop dba-transaction-id-wraparound
r pg_resetwal -D $D -u 14028 -x 2144497000
r sh -c "dd if=/dev/zero of=$D/pg_xact/07FD bs=262144 count=1"
docker start dba-transaction-id-wraparound
docker exec dba-transaction-id-wraparound pgbench -U postgres -n -c 1 -t 1000 postgres

Пока до границы больше 3 млн, идут предупреждения, а после 675 номеров клиент получил отказ:

pgbench: error: client 0 script 0 aborted in command 5 query 0: ERROR:  database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database with OID 0

Вместо имени базы стоит OID 0: pg_resetwal обнуляет в контрольном файле oldestXID's DB. Запрос без нового XID при этом работает: SELECT * FROM pgbench_branches LIMIT 1 вернул строку. Лечение из подсказки: VACUUM (FREEZE). В базе postgres его мало:

WARNING:  database "template1" must be vacuumed within 3000000 transactions
VACUUM
  datname  | datfrozenxid |    age     
-----------+--------------+------------
 postgres  |   2144497675 |          0
 template0 |        14029 | 2144483646
 template1 |        14028 | 2144483647

ERROR:  database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database "template1"

VACUUM (FREEZE) выполнился без нового XID, но пределы считаются по самой старой базе, и ошибка теперь называет template1. Заморозка template1 и template0 снимает остановку: pg_current_xact_id() снова вернул номер, 2 144 497 678. Одна забытая база держит предел всему кластеру, поэтому нужен vacuumdb --all.

В реальной базе принудительный autovacuum пришёл бы раньше: порог 14 028 + 200 млн давно пройден. В учебном кластере воркера в pg_stat_activity не было, а причины, по которым он не справляется (долгие транзакции, слоты репликации, prepared transactions), здесь не воспроизводились.

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

Мониторить нужно age(datfrozenxid) по всем базам, включая шаблоны. Сигнал должен приходить раньше порога принудительного autovacuum (200 млн по умолчанию), а не после предупреждений в логе: при темпе нашего прогона, 1324 транзакции в секунду, 40 млн номеров уходят за 8,4 часа, а 3 млн — за 38 минут.

Лечит заморозка, а не рестарт и не VACUUM FULL. VACUUM (FREEZE) в каждой базе сдвигает datfrozenxid; перезапуск сервера счётчик не обнуляет. Пока кто-то держит старый снимок, заморозка не продвинется дальше его границы, и подсказка в предупреждении называет виновников: долгие транзакции в pg_stat_activity, забытые pg_prepared_xacts, слоты в pg_replication_slots.

pg_resetwal -x — инструмент учебного стенда. Он теряет журнал, обнуляет oldestXID's DB и требует руками создавать pg_xact.

Таблицы без обновлений тоже стареют. pgbench_accounts не менялась, и relfrozenxid стоял на 761, пока возраст не вырос до 13 263. Ценой за рост autovacuum_freeze_max_age документация называет объём pg_xact: около полугигабайта при максимальных 2 млрд и около 50 МБ при умолчании в 200 млн.

Чек-лист

  1. SELECT datname, age(datfrozenxid) FROM pg_database — раз в сутки в мониторинг, по всем базам, включая template0 и template1.
  2. Порог тревоги ставь ниже autovacuum_freeze_max_age (по умолчанию 200 млн): строка must be vacuumed within 40000000 transactions в логе означает, что планировать поздно.
  3. При предупреждении сначала найди, что держит datfrozenxid, потом VACUUM (FREEZE) в каждой базе, не только в основной.
  4. Флаг заморозки — пара HEAP_XMIN_COMMITTED и HEAP_XMIN_INVALID в infomask (HEAP_XMIN_FROZEN в pageinspect); xmin у замороженной строки прежний.
  5. pg_resetwal — только на копии, после остановки сервера и с ручным созданием pg_xact; на проде не трогай.

Контейнер и том удаляются командами docker rm -f dba-transaction-id-wraparound и docker volume rm dba-txid-data.

Источники

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