Кластер 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.
Кольцо XID: окно 2³¹ от relfrozenxid и 40 млн до остановки
Опыт 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 млн.
Чек-лист
SELECT datname, age(datfrozenxid) FROM pg_database— раз в сутки в мониторинг, по всем базам, включаяtemplate0иtemplate1.- Порог тревоги ставь ниже
autovacuum_freeze_max_age(по умолчанию 200 млн): строкаmust be vacuumed within 40000000 transactionsв логе означает, что планировать поздно. - При предупреждении сначала найди, что держит
datfrozenxid, потомVACUUM (FREEZE)в каждой базе, не только в основной. - Флаг заморозки — пара
HEAP_XMIN_COMMITTEDиHEAP_XMIN_INVALIDвinfomask(HEAP_XMIN_FROZENвpageinspect);xminу замороженной строки прежний. pg_resetwal— только на копии, после остановки сервера и с ручным созданиемpg_xact; на проде не трогай.
Контейнер и том удаляются командами docker rm -f dba-transaction-id-wraparound и docker volume rm dba-txid-data.