Learning Platform
Урок 06.04 · 24 мин
Продвинутый
wraparoundxidFrozenTransactionIdfreezingdatfrozenxid

В уроке про MVCC мы видели: каждый кортеж несёт xmin (id транзакции, создавшей его) и xmax (id транзакции, удалившей или обновившей). По этим полям Postgres решает, видим ли кортеж снапшоту. Но xmin и xmax — это не bigint, а 32-битное число. У 32 бит есть теоретический предел: 2^32 ≈ 4.29 миллиарда. В Postgres из этих 4 миллиардов используется ровно половина — 2^31 = 2.15 миллиарда — потому что для сравнения «новее ли транзакция A, чем транзакция B» используется циклическая арифметика (modulo 2^31).

Это значит: после ~2 миллиардов транзакций номера начинают повторяться. И если в таблице остался кортеж с xmin = 5, а текущая транзакция тоже получила xmin = 5 (после полного круга), то старый кортеж внезапно выглядит «из будущего» — потому что 5 ≡ 5 (по кругу). Этот сценарий называется transaction ID wraparound, и без защиты он привёл бы к тихой потере данных: какие-то строки внезапно стали бы невидимыми, какие-то — наоборот, начали бы видеть транзакции, которые ещё не существовали.

PostgreSQL решает это через механизм freezing.

Freezing: «вечно живой» xmin

Идея: каждый кортеж, который существует достаточно давно (старше всех активных транзакций), можно пометить как «гарантированно видимый». В этот момент его xmin уже не несёт информации — его всё равно видят все. Можно безопасно заменить его на специальную константу FrozenTransactionId = 2, которая всегда сравнивается как «бесконечно давно».

В PostgreSQL 9.4+ это сделано ещё чуть умнее: вместо переписывания самого xmin’а в кортеж добавляется флаг HEAP_XMIN_FROZEN в t_infomask. Это позволяет VACUUM не модифицировать byte’ы кортежа без нужды (что важно для WAL’а), а семантически — то же самое: «этот кортеж видят все, проверять xmin не нужно».

VACUUM делает freezing автоматически. Когда xmin старее vacuum_freeze_min_age (по умолчанию 50 миллионов транзакций назад), VACUUM ставит флаг HEAP_XMIN_FROZEN. Когда все кортежи на странице frozen, VACUUM выставляет бит all-frozen в Visibility Map. Дальше эту страницу можно полностью пропускать при следующих vacuum-проходах.

Жизненный цикл xmin: live → frozen → ignored

Молодой кортеж имеет реальный xmin, по нему проверяется MVCC. После freeze добавляется флаг HEAP_XMIN_FROZEN — кортеж видим всем. Когда страница полностью frozen, она выходит из обычного vacuum-цикла.

Свежий кортежxmin = 42 (реальный xid)
Видимостьпроверяется по snapshot.xmin/xmax
VACUUM actionпроверить, не dead ли он
Старый кортеж (> vacuum_freeze_min_age)HEAP_XMIN_FROZEN в infomask
Видимостьвсегда видим
VACUUM actionничего, скипнуть
Страница все frozenall-frozen бит в VM
Видимостьне проверяется вообще
VACUUM actionвся страница скипается
relfrozenxid таблицыминимальный xmin среди НЕ-frozen кортежей. Этот xid не должен отставать от current_xid больше чем на 2^31 - autovacuum_freeze_max_age

Ключевые параметры

ПараметрDefaultЧто значит
vacuum_freeze_min_age50_000_000xmin старше этого — VACUUM морозит при обычном проходе
vacuum_freeze_table_age150_000_000при достижении — VACUUM становится агрессивным (сканит даже all-visible)
autovacuum_freeze_max_age200_000_000при достижении — autovacuum принудительно запускает aggressive vacuum, даже если dead tuples нет
vacuum_failsafe_age1_600_000_000при достижении — VACUUM включает failsafe mode (без cost limit, без index cleanup), чтобы успеть до wraparound

Числа выглядят пугающе большими, но на write-heavy кластере 200M транзакций — это несколько дней, а 1.6B — две недели максимум. На малой OLTP-БД с 10M транзакций в сутки эти пороги достигаются за 20+ дней — но они достигаются. Wraparound — это вопрос «когда», а не «если».

Самое важное число — relfrozenxid таблицы. Это минимальный xmin среди всех её НЕ-frozen кортежей. Можно посмотреть через pg_class.relfrozenxid. Когда current_xid отстаёт от relfrozenxid таблицы больше чем на autovacuum_freeze_max_age, autovacuum принудительно запускает aggressive vacuum на этой таблице — невзирая на n_dead_tup и user transactions.

datfrozenxid (для базы) = минимум по всем relfrozenxid в этой базе. Это число и сравнивается с current_xid для решения о wraparound-defense на уровне всей базы.

Что случится при wraparound

Если по каким-то причинам freezing не успел (vacuum застрял, диск переполнился, lock-конфликт с DDL), и xid отстаёт почти на 2^31 — PostgreSQL переходит в режим defensive shutdown:

  1. На отметке vacuum_failsafe_age (~1.6B) VACUUM включает failsafe mode: игнорирует cost limit, не чистит индексы, морозит как можно быстрее. Это последний шанс автоматически выкрутиться.
  2. Если xid доходит до 2^31 - 10_000_000 ≈ 2.1B — Postgres прекращает принимать новые транзакции:
    ERROR: database is not accepting commands to avoid wraparound data loss in database "mydb"
    HINT: Stop the postmaster and vacuum that database in single-user mode.
  3. Чтобы починить — приходится останавливать сервер, запускать в single-user mode (postgres --single), и вручную делать VACUUM. Это часы простоя.

В книжке случай: разработчики держали открытую long-running транзакцию (отладочный сеанс с psql, забыли ROLLBACK). xmin этой транзакции защищал миллиарды строк от freezing. Через две недели сервер встал. История переехала в посты на StackOverflow и блог @hnasr.

Поэтому мониторинг datfrozenxid — обязательная часть DBA-кита.

Демо: смотрим relfrozenxid и progress

В pglite freezing-флаги работают; aggressive vacuum мы вручную запустить не сможем (нет фонового триггера), но relfrozenxid доступен.

Смотрим relfrozenxid для пользовательских таблиц и оценим «возраст» — сколько транзакций назад был самый старый non-frozen кортеж:

PostgreSQL

age(relfrozenxid) возвращает разность между текущим xid и relfrozenxid — это и есть «возраст» таблицы в транзакциях. На свежесозданной таблице это число близко к нулю; на старой production-таблице с активным workload оно может быть в десятках миллионов.

Топ-5 таблиц с самым большим xid_age во всей базе (на production это типичный запрос alert'а):

PostgreSQL

Чтобы не дойти до wraparound, на production обычно alert’ят, когда age(relfrozenxid) превышает 100M (половина default autovacuum_freeze_max_age). Это значит «autovacuum скоро будет вынужден сделать aggressive vacuum» — желательно успеть подготовиться или сделать вручную в окно низкой нагрузки.

Aggressive vs обычный VACUUM

Когда age(relfrozenxid) превышает vacuum_freeze_table_age (150M по умолчанию), VACUUM на этой таблице становится aggressive:

  • Сканирует все страницы heap, включая помеченные all-visible в VM. Обычный VACUUM пропускает all-visible — aggressive не пропускает.
  • Морозит все кортежи, чей xmin старше vacuum_freeze_min_age — даже на «спокойных» страницах.
  • В итоге обновляет relfrozenxid таблицы вперёд.

Это в разы медленнее обычного VACUUM на большой таблице — поэтому aggressive vacuum имеет смысл планировать в окно. Если планы не реализуются, в один прекрасный день autovacuum сам запустит aggressive на топовой таблице, и вы получите неожиданный IO-шторм.

Проверка знанийKnowledge check
Утром вы видите алерт: на таблице orders age(relfrozenxid) = 180_000_000, при autovacuum_freeze_max_age = 200_000_000. Что это означает и какие действия?
ОтветAnswer
Это означает: до того момента, когда autovacuum принудительно запустит aggressive vacuum на orders, осталось 20M транзакций. На write-heavy сервере (10K-50K TPS) — это часы или дни. Aggressive vacuum просканирует ВСЕ страницы orders (включая all-visible), что на крупной таблице — массовое IO. Действия: (1) Сразу запустить VACUUM (FREEZE) orders вручную в окно низкой нагрузки — это перехватит инициативу у autovacuum, и вы контролируете время. (2) Параллельно проверить, нет ли долгих транзакций (idle in transaction старше часов), которые держали xmin и не давали морозить. (3) Долгосрочно — поднять для этой таблицы autovacuum_freeze_max_age до 500M через ALTER TABLE, чтобы дать больше окна (но не выше 2B!) и/или стагерить пороги по таблицам, чтобы избежать синхронных aggressive-штормов на всех больших таблицах одновременно. (4) Поставить alert на age > 100M в мониторинге, чтобы такие ситуации ловить заранее.

Чек-лист

  • xid 32-битный, используется ~2.15B значений (modulo 2^31 для сравнения «новее/старше»).
  • Freezing = пометить xmin как HEAP_XMIN_FROZEN. Кортеж становится «вечно видимым», его xmin больше не сравнивается.
  • relfrozenxid таблицы = минимальный xmin среди не-frozen кортежей. Не должен отставать от current_xid больше чем на ~200M (default).
  • При age(relfrozenxid) > autovacuum_freeze_max_age (200M) — autovacuum принудительно делает aggressive vacuum.
  • При age > vacuum_failsafe_age (1.6B) — VACUUM включает failsafe mode (без cost limit).
  • При xid близком к 2^31 — Postgres прекращает принимать транзакции, требуется single-user mode. Это катастрофа простоя.
  • Мониторить age(relfrozenxid) во всех таблицах. Alert на > 100M.
  • Aggressive vacuum сканит ВСЕ страницы (включая all-visible) — медленнее обычного. Планировать в окно.
Что такое Big-O: асимптотика, верхняя граница

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Почему PostgreSQL морозит старые кортежи (freezing)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5