У многих аварий есть предупреждающие признаки. Диск заполняется постепенно, память тает на глазах, нагрузка ползёт вверх. Но есть одна неисправность PostgreSQL, которая подкрадывается совершенно бесшумно. Нет ни замедления, ни ошибок, ни единого сигнала на дашборде, пока в один момент база не отказывается принимать запись и не объявляет себя доступной только для чтения. Это исчерпание идентификаторов транзакций, оно же wraparound. Хорошая новость в том, что эта беда абсолютно предсказуема и предотвратима, если знать, за каким числом следить и какие транзакции мешают защитному механизму делать свою работу.

Откуда берётся проблема и почему она долго остаётся незаметной

Внутри PostgreSQL каждая транзакция получает номер, и пространство этих номеров не бесконечно. Когда база израсходует около двух миллиардов идентификаторов без обслуживания старых строк, она физически не может безопасно выдавать новые. Дальше у неё два пути: либо рискнуть целостностью данных, либо остановить запись. PostgreSQL выбирает второе, и это осознанное решение разработчиков. База предпочтёт остановить приложение, чем тихо испортить данные.

Коварство ситуации в её незаметности. Возраст идентификаторов растёт скрытно. Нет деградации производительности, нет ошибок, нет вообще ничего, на что можно было бы навесить тревогу, пока система не упрётся в жёсткий предел. Защита PostgreSQL от этого называется заморозкой. Когда обслуживание замораживает строку, оно помечает её заголовок как видимый всем будущим транзакциям независимо от их номера. Замороженная строка фактически выпадает из расчёта возраста. В нормальном режиме всем этим занимается автоматическая очистка, которая периодически сканирует таблицы, убирает мёртвые версии строк и замораживает старые.

Механизм работает сам по себе ровно до тех пор, пока ему не мешают. А мешать ему могут несколько вещей, и именно они превращают штатный процесс в надвигающуюся аварию.

За каким числом нужно следить, чтобы поймать беду заранее

Главная метрика умещается в один запрос, и его стоит держать под рукой. Он показывает возраст самого старого незамороженного идентификатора по каждой базе.

SELECT datname,
       age(datfrozenxid),
       current_setting('autovacuum_freeze_max_age') AS freeze_max_age
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

Возраст здесь это разница между текущим идентификатором и самым старым незамороженным в базе. Параметр предельного возраста заморозки по умолчанию равен двумстам миллионам, и когда возраст любой таблицы достигает этого порога, на ней запускается агрессивная заморозка повышенного приоритета. Такую сессию легко узнать в списке активности по характерной пометке о предотвращении wraparound.

Жёсткий предел находится около двух миллиардов, но ждать его приближения нельзя ни в коем случае. Разумная метрика для тревоги это процент пройденного пути к аварийной заморозке. Берётся возраст самой проблемной базы и делится на предельный возраст. Если значение перевалило за разумную долю бюджета, пора действовать. Тревогу стоит настроить заранее, особенно для систем, где за этим раньше никто не присматривал, потому что именно такие системы и взрываются первыми.

Что мешает заморозке работать и где искать виновника

Сам факт растущего возраста почти всегда означает, что заморозке кто-то мешает. Дело в правилах многоверсионного управления конкурентным доступом. PostgreSQL не может заморозить ни один идентификатор, появившийся после начала самой старой из ныне работающих транзакций. Иначе старая транзакция увидела бы данные в противоречивом состоянии. Поэтому одна-единственная древняя транзакция способна заблокировать заморозку для всего диапазона в два миллиарда идентификаторов и в крайнем случае остановить всю систему.

В журнале при этом появляется характерное предупреждение о том, что самый старый xmin далеко в прошлом, с подсказкой закрыть открытые транзакции, завершить старые подготовленные транзакции или удалить устаревшие слоты репликации. Эта подсказка перечисляет ровно четыре главных источника проблемы.

Первый и самый частый виновник это долгие транзакции, особенно сессии в состоянии простоя внутри транзакции. Найти их можно прицельным запросом.

SELECT pid,
       age(backend_xid) AS xid_age,
       now() - xact_start AS duration,
       state,
       left(query, 60) AS query_preview
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 10;

Виновную сессию, держащую горизонт, либо просят завершить запрос, либо принудительно обрывают соединение через завершение процесса по его идентификатору. Делать это нужно с пониманием, потому что обрыв транзакции откатит её работу.

-- Завершить зависшую сессию по её идентификатору процесса
SELECT pg_terminate_backend(12345);

Второй источник это осиротевшие подготовленные транзакции из двухфазного фиксирования. Они встречаются нечасто, но если такая транзакция зависла, она становится самым старым xmin. Их находят в специальном представлении и устраняют откатом по имени.

SELECT gid, prepared, owner, database, age(transaction) AS xid_age
FROM pg_prepared_xacts
ORDER BY age(transaction) DESC;

-- Откат зависшей подготовленной транзакции
ROLLBACK PREPARED 'имя_транзакции';

Почему слоты репликации это самый незаметный из блокеров

Третий и самый коварный источник это слоты репликации. Слот существует, чтобы удерживать неотправленные изменения, пока их не заберёт целевой сервер. Если репликация отстаёт или резервный сервер упал, слот не даёт удалять старые журнальные файлы и держит горизонт на месте. Особенно опасен неактивный слот, который остался от давно отключённой подписки: подписчика уже нет, а слот всё ещё на месте и блокирует очистку служебных таблиц каталога.

Проверить слоты по возрасту удерживаемого идентификатора можно отдельным запросом.

SELECT slot_name, slot_type, active,
       age(xmin) AS xmin_age,
       age(catalog_xmin) AS catalog_xmin_age
FROM pg_replication_slots
ORDER BY age(xmin) DESC NULLS LAST;

Здесь важна тонкость. Колонка обычного xmin имеет значение при физической потоковой репликации с включённой обратной связью резерва. А вот колонка каталожного xmin держит горизонт даже при логической репликации, и именно она часто оказывается виновата в постоянной очистке служебных таблиц. Классический сценарий: на основном сервере добавили колонку, на подписчике её нет, логическая репликация встала, и каталожный xmin намертво застрял. Лечится это либо починкой репликации, либо удалением заведомо ненужного слота.

-- Удалить неиспользуемый слот репликации
SELECT pg_drop_replication_slot('unused_slot');

Четвёртый источник связан с резервными серверами. При включённой обратной связи резерва долгий запрос на реплике удерживает горизонт на основном сервере через тот же механизм. Самый старый нужный реплике идентификатор виден в представлении репликации на основном сервере, и чтобы сдвинуть горизонт, останавливают держащий его запрос на реплике.

Как действовать, если база уже встала в режим только чтение

Если профилактику упустили и база перешла в защитный режим, начинается уже не тюнинг, а спасательная операция. Главное правило: не паниковать и не запускать наугад что-то тяжёлое в надежде, что поможет. Так рекуперабельную ситуацию превращают в полноценную аварию.

Сначала устраняют застрявший идентификатор, и только потом переходят к оптимизации. Долгие транзакции обрывают, осиротевшие подготовленные откатывают, мёртвые слоты удаляют. Лишь после этого имеет смысл прогонять заморозку вручную на самых пострадавших таблицах.

-- Заморозка конкретной таблицы
VACUUM FREEZE app_events;

-- Или всей базы целиком
VACUUM FREEZE;

На больших таблицах эта операция читает и переписывает каждую страницу, занимает часы и порождает серьёзную нагрузку на ввод-вывод. В настоящей аварии, когда нужна максимальная скорость, временно убирают дросселирование, давая очистке поглощать столько ресурсов, сколько она способна взять.

SET vacuum_cost_delay = 0;
VACUUM FREEZE app_events;
RESET vacuum_cost_delay;

Обнуление задержки стоимости стоит применять только в экстренном случае, потому что в обычном режиме заморозка начнёт жёстко конкурировать с запросами приложения. После того как обслуживание сдвинуло возраст идентификаторов за пределы опасной зоны, запись возобновляется, и система стабилизируется.

Какие привычки навсегда снимают эту угрозу

Один разобранный инцидент почти всегда вскрывает системную причину. В одной показательной истории автоматическую очистку отключили на таблицах, которые сочли неиспользуемыми. Раз очистка выключена, эти таблицы перестали замораживаться, их состояние идентификаторов застыло, и через какое-то время именно они утянули всю базу в защитный режим. Мораль простая: отключать автоматическую очистку на таблице это всегда сознательное решение с последствиями, а не безобидная мелочь.

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

Невидимый счётчик перестаёт быть угрозой ровно в тот момент, когда становится видимым. Один запрос в систему мониторинга, понимание четырёх блокеров заморозки и дисциплина в обращении с долгими транзакциями превращают потенциальную остановку всего сервиса в строку графика, которая спокойно колеблется в безопасном диапазоне. База, за которой следят, никогда не дойдёт до режима только чтение, потому что её вовремя поправят задолго до края.