В уроке про 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, по нему проверяется MVCC. После freeze добавляется флаг HEAP_XMIN_FROZEN — кортеж видим всем. Когда страница полностью frozen, она выходит из обычного vacuum-цикла.
Ключевые параметры
| Параметр | Default | Что значит |
|---|---|---|
vacuum_freeze_min_age | 50_000_000 | xmin старше этого — VACUUM морозит при обычном проходе |
vacuum_freeze_table_age | 150_000_000 | при достижении — VACUUM становится агрессивным (сканит даже all-visible) |
autovacuum_freeze_max_age | 200_000_000 | при достижении — autovacuum принудительно запускает aggressive vacuum, даже если dead tuples нет |
vacuum_failsafe_age | 1_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:
- На отметке
vacuum_failsafe_age(~1.6B) VACUUM включает failsafe mode: игнорирует cost limit, не чистит индексы, морозит как можно быстрее. Это последний шанс автоматически выкрутиться. - Если 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. - Чтобы починить — приходится останавливать сервер, запускать в 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 кортеж:
age(relfrozenxid) возвращает разность между текущим xid и relfrozenxid — это и есть «возраст» таблицы в транзакциях. На свежесозданной таблице это число близко к нулю; на старой production-таблице с активным workload оно может быть в десятках миллионов.
Топ-5 таблиц с самым большим xid_age во всей базе (на production это типичный запрос alert'а):
Чтобы не дойти до 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-шторм.
Чек-лист
- 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) — медленнее обычного. Планировать в окно.