Агрессивный autovacuum и заморозка транзакций в PostgreSQL – Pglens.

Детективная история про агрессивный autovacuum и заморозку транзакций в PostgreSQL

С чего всё началось

В нашу техподдержку прилетела заявка.

Добрый день, коллеги!

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

К заявке приложили лог за день инцидента и дамп WAL-файла с моментом, когда стартовал вакуум.

Что было на руках

Самое важное из лога – момент падения:

10:15:28 [1005255] PANIC: could not write to file "pg_wal/xlogtemp.1005255": No space left on device
10:15:28 [1005255] STATEMENT: COMMIT
cp: error writing '.../archivelog/00000001000000C5000000A5': No space left on device
10:15:28 [1005622] FATAL: the database system is in recovery mode

Картина однозначная: процессу не хватило места, чтобы дописать очередной кусок WAL, он словил PANIC и завалил весь инстанс.

После логов решили посмотреть на предоставленный WAL-файл. Сначала в нём идёт нормальная жизнь базы – обычные вставки:

rmgr: Heap tx: 190069644 desc: INSERT off: 56 ... blkref #0: rel 1663/117092/232645 blk 2836495
rmgr: Btree tx: 190069644 desc: INSERT_LEAF off: 217 ...
rmgr: Btree tx: 190069644 desc: INSERT_LEAF off: 109 ... FPW
rmgr: Transaction tx: 190069644 desc: COMMIT 2025-09-16 10:03:46 MSK
... много таких INSERT-ов – обычная активность приложения ...

А затем характер записей резко меняется:

rmgr: Heap2 tx: 0 desc: PRUNE_VACUUM_SCAN snapshotConflictHorizon: 2991380 ... blkref #0: rel 1663/117092/232645 blk 0 FPW
rmgr: Heap2 tx: 0 desc: VISIBLE snapshotConflictHorizon: 0, flags: 0x03 ... fork vm blk 0 FPW, blk 0
rmgr: Heap2 tx: 0 desc: PRUNE_VACUUM_SCAN snapshotConflictHorizon: 2991447 ... blk 1 FPW
rmgr: Heap2 tx: 0 desc: VISIBLE ... fork vm blk 0, blk 1
... и так страница за страницей ...

Тут стоит остановиться и обратить внимание на пару деталей, которые сразу задают направление:

  • Записи идут с tx: 0. У обычных вставок выше был реальный номер транзакции (190069644), а здесь нуль – это служебная активность, то есть вакуум, а не пользовательский запрос.
  • Чередуются PRUNE_VACUUM_SCAN и VISIBLE, и идут они подряд по блокам: blk 0, blk 1, blk 2… То есть вакуум честно идёт по таблице страница за страницей с самого начала. Это и есть тот самый «сканирует её всю», о котором писал заказчик.
  • Почти на каждой записи висит флаг FPW – full page write, полный образ страницы в WAL. Вот он, главный пожиратель места (к нему ещё вернёмся).

Вывод на этом этапе: да, по большой таблице (rel …232645) пошёл вакуум, который читает её целиком и при этом генерирует гору WAL. Осталось понять – почему он вообще запустился в таком режиме.

Первичный анализ: три версии

Зная только текст заявки, логи за день и один WAL-файл, мы предложили три сценария, при которых вакуум начинает агрессивно сканировать большую таблицу и заливать диск WAL-ом:

  1. Кто-то руками запустил VACUUM FULL или VACUUM FREEZE. Обе команды заставляют перелопатить таблицу целиком.
  2. Первый вакуум по большому объекту после долгого простоя. Если вакуум по таблице давно не приходил, вся накопившаяся работа рано или поздно выполняется разом. Сюда же относится случай с долго висевшей транзакцией: пока она была открыта, вакуум не мог двигаться дальше, а как только она завершилась – он добрался до всего накопившегося сразу.
  3. Достигнут порог заморозки счётчика транзакций. Таблица «состарилась» настолько, что практически все строки в ней оказались старше порога заморозки, и PostgreSQL принудительно запустил заморозку.

В первом ответе мы перечислили все три и попросили заказчика обратить внимание на параметры vacuum_freeze_min_age, vacuum_freeze_table_age и autovacuum_freeze_max_age.

Уже тогда у нас был фаворит – третья версия так как первые два варианта выглядят не шибко реалистично, плюс в заявке прямым текстом сказано: таблица, «по которой до этого либо редко, либо никогда не запускался» вакуум. Но чтобы доказать это, нужно было снять одно важное возражение от заказчика.

VACUUM FREEZE не было – судя по отсутствию сообщений вида FREEZE_PAGE при обработке страниц вакуумом. Полный вакуум – тоже не наш случай, его там просто некому было запустить. Важный момент: в эту таблицу ведётся исключительно запись, обновлений и удалений по ней не делается. Параметры автовакуума по вставкам – дефолтные: autovacuum_vacuum_insert_scale_factor = 0.2, autovacuum_vacuum_insert_threshold = 1000. Размер таблицы – порядка 28 ГБ.

В этом сообщении есть один неверный вывод: «нет записей FREEZE_PAGE, следовательно, заморозки не было». Логика понятная, но в ходе наших попыток воспроизвести кейс на локальном стенде мы выяснили что отсутствие записей FREEZE_PAGE в WAL – не аргумент против версий выше.

В PostgreSQL 17 формат WAL для вакуума был переработан: записи о вырезании мёртвых строк (prune), заморозке (freeze) и второй фазе вакуума объединены в одну записьPRUNE_VACUUM_SCAN. Отдельной записи FREEZE_PAGE, как в 16-й версии, в 17-й уже просто не существует. Поэтому и при VACUUM FREEZE, и при срабатывании заморозки по возрасту транзакций в дампе будет одна и та же картина: PRUNE_VACUUM_SCAN с флагом FPW, за которой идёт VISIBLE. Последовательность идентичная.

И вот здесь всплыл тот самый подводный камень. Оказалось, что заказчик тоже пытался воспроизвести инцидент на демостендено на PostgreSQL 16, а не 17. На 16-й версии заморозка действительно отображается в WAL-файлах в виде FREEZE_PAGE записей.

Классическая ловушка: воспроизводить инцидент нужно ровно на той же мажорной версии, что и на проде. Между мажорными релизами PostgreSQL не гарантирует совместимость формата WAL – и внутренняя кухня вакуума как раз из тех мест, которые активно меняются от версии к версии.

Так мы закрыли первую версию (VACUUM FREEZE/VACUUM FULL руками – некому) и сняли главное возражение. Осталось разобраться, как именно «небольшая» таблица привела к падению кластера.

Как вообще работает заморозка

Чтобы дальнейшее было понятным, коротко опишем механизм заморозки.

Зачем вообще что-то морозить

Идентификатор транзакции (XID) в PostgreSQL – 32-битный. Это около 4 миллиардов значений, но «видимое окно» в прошлое – примерно 2 миллиарда. Когда счётчик XID проходит полный круг (wraparound), старые транзакции из «далёкого прошлого» рискуют внезапно оказаться «в будущем» – и строки, которые они создали, пропадут из видимости. А такую ситуацию допускать нельзя.

Чтобы этого не случилось, очень старые строки замораживают – помечают как «видимы всегда, вне зависимости от счётчика».

За заморозку отвечает вакуум. У каждой таблицы есть граница relfrozenxid – гарантия, что всё старше неё уже заморожено. Возраст таблицы – это текущий XID − relfrozenxid. Чем он больше, тем ближе таблица к опасной черте.

Регулируют процесс заморозки три ключевых параметра:

  • vacuum_freeze_min_age (по умолчанию 50 млн) – насколько старой должна стать строка, чтобы вакуум вообще взялся её морозить. Свежие строки моложе этого порога вакуум не трогает.
  • vacuum_freeze_table_age (по умолчанию 150 млн) – когда возраст таблицы переваливает за это значение, ближайший же вакуум переключается в агрессивный режим: он обязан пройтись по всем страницам, чтобы продвинуть relfrozenxid.
  • autovacuum_freeze_max_age (по умолчанию 200 млн) – это последний рубеж. Когда возраст таблицы дотягивает сюда, PostgreSQL принудительно запускает автовакуум по этой таблице в агрессивном режиме. Причем даже если автовакуум в принципе выключен.

Ещё одна важная деталь – карта видимости (visibility map). На каждую страницу таблицы в ней есть два бита: all-visible (все строки на странице видны всем транзакциям) и all-frozen (все строки на странице заморожены). Причём all-frozen всегда подразумевает all-visible: замороженная страница по определению видна всем.

И вот тут принципиальная разница между двумя режимами. Обычный, не агрессивный вакуум пропускает страницы с битом all-visible – мёртвых строк там нет, чистить нечего, поэтому он их даже не читает и тем самым экономит работу. А агрессивный вакуум так поступить не может: ему нужно заморозить старые строки. Поэтому он пропускает только полностью замороженные страницы (all-frozen) и обязан зайти на каждую, что помечена как all-visible, но ещё не all-frozen.

Почему INSERT-only таблица – это мина замедленного действия

Вот теперь соберём всё вместе и поймём, что же произошло.

В таблицу идёт только вставка: ни UPDATE, ни DELETE. Начиная с PostgreSQL 13, появился отдельный триггер – автовакуум по вставкам (autovacuum_vacuum_insert_threshold и autovacuum_vacuum_insert_scale_factor). Соответственно, он и должен приходить на такую таблицу. Казалось бы, вакуум ходит – всё хорошо? Не совсем.

Когда возраст таблицы был ниже vacuum_freeze_table_age (150 млн), вакуум работал в обычном режиме. Другими словами, он не заходил в страницы, помеченные как all-visible.

В промежутке между vacuum_freeze_table_age и autovacuum_freeze_max_age (150 — 200 млн) вакуум не запускался по таблице поскольку из-за характера нагрузки он мог запуститься автоматически только при достижении порога числа вставленных строк с последней очистки. Этот порог высчитывается по формуле:

autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor × число строк в таблице

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

Откуда взялась лавина WAL

Остался последний штрих – почему этот вакуум так быстро забил диск. Ответ – в флаге FPW (full page write).

Когда full_page_writes включён (а это значение по умолчанию), при первой модификации страницы после контрольной точки (checkpoint) PostgreSQL пишет в WAL полный образ страницы целиком, а не только дельту. Это защита от частично записанных страниц при сбое.

Теперь представьте: агрессивный вакуум за один проход трогает каждую страницу 28-гигабайтной таблицы – и почти для каждой это первое касание после чекпойнта. Значит, почти каждая страница улетает в WAL целым образом.

Однако здесь скрыт ещё один слой – и именно он превращает локальную проблему одной таблицы в массовый инцидент. Рассмотрим маленькую модель. Пусть у нас одна большая таблица и десяток других рядом:

Таблица Возраст (age(relfrozenxid))
----------------- ---------------------------
table_00 200 млн <-- достигла autovacuum_freeze_max_age
table_01 199 млн
table_02 198 млн
table_03 195 млн
...

table_00 достигла возраста в 200 млн – по ней немедленно запускается принудительный агрессивный вакуум. Но у нас есть другие таблицы, которые приближаются к 200 млн.

Всё дело в том, как считается возраст. age(relfrozenxid) зависит не от активности самой таблицы, а от глобального счётчика транзакций всей базы. А значит, у всех таблиц возраст растёт с одной и той же скоростью – её задаёт суммарный поток транзакций по кластеру, а не запись в конкретную таблицу. Разница в возрасте между таблицами определяется только тем, когда каждую из них в последний раз заморозили. А создаются и впервые морозятся таблицы обычно пачками: общий деплой схемы, разовая миграция, массовая первоначальная загрузка, нарезка партиций по расписанию. Стартуют они с близким relfrozenxid и дальше идут по возрасту ноздря в ноздрю.

Получается каскад: несколько крупных агрессивных вакуумов сходятся почти одновременно, их всплески WAL складываются. На инстансе, где свободного места на диске оставалось немного, это гарантированный No space left on device.

Выводы

Этот случай хорош тем, что в нём сошлось сразу несколько типичных граблей:

1. Неправильная настройка autovacuum для INSERT-only таблиц. С настройками по умолчанию вакуум по вставкам срабатывает лишь после ~20% долитых строк – для таблицы в десятки гигабайт это редко, и relfrozenxid тихо доезжает до стены autovacuum_freeze_max_age, где прилетает разовый агрессивный проход по всей таблице. Лечится настройкой под конкретную таблицу: снижаем порог по вставкам, чтобы вакуум ходил часто и морозил порциями.

2. Отсутствие конкретной записи в WAL – не доказательство. «Нет FREEZE_PAGE, следовательно, заморозки не было» – ложный вывод. В PostgreSQL 17 prune, freeze и vacuum объединили в одну запись PRUNE_VACUUM_SCAN, и отдельного FREEZE_PAGE там просто нет. Формат WAL меняется между мажорными версиями.

3. Воспроизводить инцидент нужно на той же мажорной версии. Это, пожалуй, главная практическая ошибка в этой истории: заказчик тестировал гипотезу на PG 16, а прод у него на PG 17. Разное поведение вакуума и разный формат WAL увели его в сторону на несколько дней. Версия стенда должна совпадать с продом до мажора.

4. Мониторьте возраст транзакций, не дожидаясь порога. Простой запрос, который стоит повесить на алерт:

SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind IN ('r', 'm', 't')
ORDER BY xid_age DESC
LIMIT 20;

И на уровне баз целиком:

SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY xid_age DESC;

Если что-то подбирается к autovacuum_freeze_max_age – это сигнал заранее запланировать заморозку, а не ловить её в самый неудобный момент.

5. Размазывайте заморозку во времени и держите запас по диску. Большой агрессивный вакуум – это всплеск WAL, сопоставимый с размером таблицы (спасибо full page writes).

Хорошая практика – планово запускать VACUUM (FREEZE) по крупным append-only таблицам в окна низкой нагрузки, чтобы заморозка шла порциями, а не одним каскадом у самого порога. И, конечно, держать в pg_wal достаточный запас места под такие всплески – диск, заполненный «под завязку», превращает рутинную операцию обслуживания в массовый инцидент.

Самое поучительное здесь: проблема была не в «странном» поведении вакуума. Вакуум делал ровно то, что должен – спасал базу от wraparound. Инцидент случился потому, что эту работу никто не отслеживал и не размазывал во времени, а под неё не оставили места на диске.

Бесплатный 7‑дневный аудит PostgreSQL с PGLens

PGLens остается у вас для полноценного тестирования еще до 90 дней БЕСПЛАТНО