15.1 32 бита и круг вместо линии
Каждой транзакции, которая меняет данные, PostgreSQL выдаёт xid -
следующее число счётчика. Видимость версии решается сравнением её
xmin/xmax с номерами транзакций в снимке: меньше - в прошлом,
больше - в будущем.
Проблема в том, что 32-битный счётчик вмещает чуть больше четырёх миллиардов значений (2³²). На активной базе это не «никогда» - это месяцы или даже недели. Когда счётчик доходит до предела, он обнуляется и начинает заново.
Чтобы сравнения продолжали работать, PostgreSQL считает их не на
прямой, а на круге. Для любого xid половина значений (примерно два
миллиарда) считается «прошлым», вторая половина - «будущим».
Вот где ловушка: если версия строки осталась с xmin, который
старше текущего более чем на два миллиарда, круг «перевернёт» её -
она начнёт выглядеть как версия из будущего, ещё не наступившая. А
значит, перестанет быть видимой. Данные на месте, но СУБД считает их
несуществующими.
15.2 Заморозка: вынуть версию из круга
Решение - не дать ни одной версии состариться больше чем на два
миллиарда транзакций. Для этого её xmin нужно «заморозить»: пометить
как «эта версия старше всего на свете, видна всем безусловно, в
сравнениях по кругу не участвует».
Исторически заморозка переписывала xmin в специальное значение
FrozenTransactionId (число 2). Современный PostgreSQL не трогает само
xmin, а выставляет в заголовке кортежа бит-подсказку
HEAP_XMIN_FROZEN: версия считается замороженной, а исходный xid
остаётся виден для отладки. Результат тот же - замороженная версия
выпадает из круга и больше никогда не «перевернётся».
Замораживает версии VACUUM. Обычный проход замораживает те, что уже
достаточно стары (старше vacuum_freeze_min_age, по умолчанию 50
миллионов). А когда таблица в целом стареет опасно - запускается
агрессивный проход, который проходит даже те страницы, что карта
видимости считает «всё видно всем», и замораживает всё подряд.
15.3 relfrozenxid: возраст таблицы
Как PostgreSQL понимает, что таблица стареет? У каждой таблицы есть
relfrozenxid - граница, гарантирующая: все версии старше неё уже
заморожены. Хранится в pg_class.
Полезнее смотреть не на само число, а на его возраст - на сколько транзакций оно отстало от текущего счётчика:
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY age(relfrozenxid) DESC
LIMIT 10;
age(relfrozenxid) растёт с каждой новой транзакцией в базе и
сбрасывается вниз, когда VACUUM проходит таблицу и продвигает
relfrozenxid вперёд. Если возраст какой-то таблицы упорно растёт и
приближается к 200 миллионам - её autovacuum по какой-то причине не
обрабатывает, и пора разбираться.
На уровне всей базы то же самое показывает datfrozenxid в
pg_database - это минимум по всем таблицам базы. Именно он
определяет, насколько база близка к опасной черте. Подробности
счётчиков - в freeze.
15.4 Лестница защиты от wraparound
PostgreSQL не ждёт катастрофы пассивно. По мере роста возраста включаются всё более жёсткие меры - лестница из четырёх ступеней:
Разберём ступени:
- 200 млн (
autovacuum_freeze_max_age) - запускается анти-wraparound autovacuum. Он стартует даже на таблице, которую autovacuum обычно не трогает, и даже если autovacuum выключен целиком. Это первая и обычно достаточная линия обороны; - 1.6 млрд (
vacuum_failsafe_age) - failsafe. VACUUM понимает, что времени мало, и переходит в режим выживания: пропускает очистку индексов и задержки, всё ради скорейшей заморозки; - меньше 40 млн до края (примерно возраст 2.1 млрд) - сервер начинает писать в лог настойчивые WARNING с обратным отсчётом;
- меньше 3 млн до края - сервер отказывается выдавать новые
xid. База перестаёт принимать запросы, меняющие данные. Выход один: остановить сервер и запустить VACUUM в однопользовательском режиме.
15.5 Однопользовательский режим: дно лестницы
Если все предохранители проигнорированы и база встала, спасение выглядит так:
# сервер остановлен; запускаем его в single-user режиме
postgres --single -D /var/lib/postgresql/data <имя_базы>
backend> VACUUM;
В этом режиме к базе подключён ровно один процесс, никаких клиентов.
VACUUM проходит и замораживает старые версии, datfrozenxid
продвигается, и после рестарта база снова принимает запросы.
Это аварийный сценарий, и попадать в него не нужно. Вся лестница выше существует именно для того, чтобы анти-wraparound autovacuum разобрался с заморозкой задолго до дна. До single-user доходят обычно те, кто намеренно отключил autovacuum «ради производительности» - классическая дорогая ошибка. Подробнее про сам механизм круга - в wraparound.
15.6 Multixact: второй счётчик, который тоже переполняется
У wraparound есть младший брат, про которого часто забывают. Когда
одну строку одновременно блокируют несколько транзакций (например,
SELECT ... FOR SHARE или проверки внешних ключей), PostgreSQL не
может записать в xmax один xid - блокировщиков несколько.
Вместо этого он заводит multixact: отдельный идентификатор, за
которым стоит список транзакций-участников.
Multixact-идентификаторы - тоже 32-битный счётчик со своим кругом и
своим переполнением. У таблицы для него есть relminmxid
(аналог relfrozenxid), а пороги называются
autovacuum_multixact_freeze_max_age (по умолчанию 400 миллионов).
VACUUM замораживает и multixact тоже.
Коварство в том, что мониторинг часто следит только за обычным
age(relfrozenxid) и упускает age(relminmxid). На нагрузке с
большим количеством блокировок строк или внешних ключей второй
счётчик может побежать к краю быстрее первого. Смотреть надо за
обоими. Разбор - в multixact.
Копнуть глубже: заморозка в PostgreSQL 17 стала дешевле. VACUUM хранит список «мёртвых» указателей в памяти, и в прежних версиях этот буфер был ограничен одним гигабайтом, из-за чего большим таблицам приходилось делать несколько проходов по индексам. В 17-й версии структуру заменили на компактное дерево, которое вмещает на порядки больше указателей в той же памяти. На больших таблицах это заметно ускоряет и заморозку, и обычную уборку.
Уроки в sandbox
lab-15.1. Старение и freeze
Понаблюдаем за возрастом таблицы. Посмотрим age(relfrozenxid),
искусственно состарим базу (нагенерим транзакций), увидим рост
возраста, потом прогоним VACUUM FREEZE и убедимся, что возраст
упал почти до нуля. Сначала предскажи, как изменится age() после
каждого шага.
Посмотри текущий
age(relfrozenxid)для таблицыflightsчерез запрос кpg_class.Сгенерируй много коротких транзакций (например, цикл с
SELECT txid_current();) и снова снимиage()- предскажи, вырастет ли он.Запусти
VACUUM FREEZE flights;и снимиage(relfrozenxid)ещё раз - он должен резко упасть.Сравни
age(relminmxid)до и после - проверь, что multixact-граница тоже на месте.Найди таблицу с самым большим возрастом в базе через
ORDER BY age(relfrozenxid) DESC.
sandbox с автопроверкой - открыть в песочнице
Резюме
- `xid` - 32-битное число; счётчик транзакций переполняется примерно через 4 млрд транзакций, поэтому сравнения видимости идут по кругу, а не по прямой.
- Версия старше текущего `xid` более чем на ~2 млрд «переворачивается» в будущее и становится невидимой - это и есть wraparound и молчаливая потеря данных.
- Заморозка (freeze) помечает версию как видимую всем безусловно (бит `HEAP_XMIN_FROZEN`), выводя её из круга навсегда; делает это VACUUM.
- `age(relfrozenxid)` - возраст таблицы в транзакциях; растёт со временем, падает после прохода VACUUM; на уровне базы то же показывает `datfrozenxid`.
- Лестница защиты: 200 млн → анти-wraparound autovacuum; 1.6 млрд → failsafe; <40 млн до края → WARNING; <3 млн → база встаёт.
- Из остановившейся базы выходят через VACUUM в однопользовательском режиме; туда обычно попадают те, кто отключил autovacuum.
- Multixact - второй 32-битный счётчик (блокировки строк, внешние ключи) со своим `relminmxid`; за `age(relminmxid)` нужно следить отдельно.
Контрольные вопросы
Почему сравнение транзакций в PostgreSQL идёт по кругу, а не по прямой, и чем это опасно?
Показать ответ
Потому что
xid- 32-битное число и счётчик неизбежно переполняется примерно через 4 млрд транзакций. Чтобы сравнения «старше/новее» продолжали работать после обнуления, PostgreSQL делит круг пополам: ~2 млрд значений назад считаются прошлым, ~2 млрд вперёд - будущим. Опасность: версия строки, чейxidотстал больше чем на 2 млрд, «перепрыгивает» в будущее и становится невидимой. Данные физически на месте, но СУБД их не показывает - это и есть wraparound.Что значит «заморозить» версию строки и кто это делает?
Показать ответ
Заморозить - значит пометить версию как старше всего на свете и видимую всем безусловно, чтобы она больше не участвовала в круговых сравнениях и не могла «перевернуться» в будущее. Современный PostgreSQL выставляет для этого бит-подсказку
HEAP_XMIN_FROZENв заголовке кортежа, не переписывая самxmin. Делает заморозку VACUUM: обычный проход морозит версии старшеvacuum_freeze_min_age, а агрессивный/анти-wraparound проход - всё подряд, включая страницы, помеченные в карте видимости как «всё видно».На что смотреть, чтобы вовремя заметить приближение wraparound?
Показать ответ
На
age(relfrozenxid)по таблицам (черезpg_class) и наage(datfrozenxid)по базам (черезpg_database). Возраст - это на сколько транзакций граница заморозки отстала от текущего счётчика. Он должен держаться заметно ниже 200 млн (autovacuum_freeze_max_age). Если возраст какой-то таблицы упорно растёт к этому порогу и выше - autovacuum её не успевает или не трогает. Отдельно нужно следить заage(relminmxid)- возрастом multixact-счётчика, про который часто забывают.Опишите лестницу защиты от wraparound по ступеням.
Показать ответ
Первая ступень - возраст 200 млн (
autovacuum_freeze_max_age): запускается анти-wraparound autovacuum, причём даже если autovacuum отключён. Вторая - 1.6 млрд (vacuum_failsafe_age): failsafe-режим, VACUUM пропускает очистку индексов и паузы, гонит только заморозку. Третья - меньше 40 млн транзакций до края: сервер пишет в лог настойчивые WARNING с обратным отсчётом. Четвёртая - меньше 3 млн до края: сервер перестаёт выдавать новыеxid, база встаёт, и спасает только VACUUM в однопользовательском режиме.Чем multixact-wraparound отличается от обычного и почему его легко проглядеть?
Показать ответ
Обычный wraparound - про счётчик
xid, который тратится на каждую изменяющую транзакцию. Multixact - отдельный 32-битный счётчик, который тратится, когда одну строку одновременно блокируют несколько транзакций (FOR SHARE, проверки внешних ключей). У него свой круг, своя границаrelminmxidи свой порогautovacuum_multixact_freeze_max_age(400 млн). Проглядеть легко, потому что типовой мониторинг следит только заage(relfrozenxid). На нагрузке с обилием блокировок строк multixact может добежать до края раньше обычного счётчика.