Диск под PostgreSQL заполнен на 90%. Один предлагает удалить старые данные, второй — запустить VACUUM FULL, третий — докинуть места. Всё это решения, а причина ещё неизвестна.

Высокая занятость диска — симптом, а не диагноз. Сначала нужно ответить на один вопрос: что именно занимает место? Ниже порядок, в котором я бы это выяснял.

Поводом послужила статья PostgreSQL Storage Troubleshooting: The DBA’s 10-Minute Investigation Guide (Medium, 18 сентября 2026). Это мой разбор, а не перевод: часть запросов доработана, добавлено то, чего в оригинале нет. Инфографика — моя перерисовка иллюстрации из статьи на русском. В оригинале раскладка «куда уходит место» в сумме давала 1000 ГБ при занятых 900 — здесь доли пересчитаны.

Инфографика: PostgreSQL занял 90% диска. Слева занятость тома, в центре файл таблицы до и после VACUUM — размер одинаковый, мёртвые строки превращаются в свободное место. Ниже раскладка занятого места по таблицам, индексам, WAL и TOAST, топ таблиц по размеру и роль WAL и слотов репликации

Шаг 0. Что видит операционная система

Статьи о расследовании обычно начинают с SQL. Но если диск заполнен полностью, подключиться к базе может не получиться, а место может занимать вовсе не база. Поэтому сначала — уровень файловой системы:

BASH
df -h "$PGDATA"
du -sh "$PGDATA"/base "$PGDATA"/pg_wal "$PGDATA"/pg_replslot "$PGDATA"/log 2>/dev/null
du -sh "$PGDATA"/base/pgsql_tmp 2>/dev/null
Нажмите, чтобы развернуть и увидеть больше

Что смотреть:

Шаг 1. Какая база растёт

SQL
SELECT datname,
       pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database
ORDER BY pg_database_size(datname) DESC;
Нажмите, чтобы развернуть и увидеть больше

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

Шаг 2. Какие таблицы самые большие

pg_total_relation_size в одной цифре смешивает данные, индексы и TOAST. Для диагностики полезнее увидеть их раздельно:

SQL
SELECT c.oid::regclass                                  AS "table",
       pg_size_pretty(pg_relation_size(c.oid))          AS data,
       pg_size_pretty(pg_indexes_size(c.oid))           AS indexes,
       pg_size_pretty(pg_table_size(c.oid)
                      - pg_relation_size(c.oid))        AS toast_etc,
       pg_size_pretty(pg_total_relation_size(c.oid))    AS total
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;
Нажмите, чтобы развернуть и увидеть больше

Колонка toast_etc — это TOAST (вынесенные длинные значения: большие text, jsonb, bytea) плюс служебные карты свободного места и видимости. Если она сравнима с данными или больше их — место съедают крупные значения, а не число строк.

Секционированные таблицы покажутся по секциям: у родительской таблицы собственного размера нет. Суммарный размер секционированной таблицы считается через pg_partition_tree.

Шаг 3. Мёртвые строки и раздувание

Здесь главная неточность в популярных чек-листах, включая исходную статью. Обычно предлагают смотреть на число мёртвых строк:

SQL
SELECT relname,
       n_live_tup,
       n_dead_tup,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
       last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
Нажмите, чтобы развернуть и увидеть больше

Запрос полезный, но он показывает не раздувание. n_dead_tup — оценка по статистике, и после VACUUM она падает почти до нуля. Файл таблицы при этом не уменьшается: место, которое занимали мёртвые строки, становится свободным внутри файла. Именно это и показывает инфографика: до и после VACUUM файл весит одинаково.

Получается, что таблица может занимать 100 ГБ при 30 ГБ живых данных, а n_dead_tup будет показывать ноль. Чтобы увидеть реальную долю свободного места, нужно расширение pgstattuple:

SQL
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT table_len,
       approx_tuple_percent,
       dead_tuple_percent,
       approx_free_percent
FROM pgstattuple_approx('public.events');
Нажмите, чтобы развернуть и увидеть больше

approx_free_percent — доля пустого места в файле. Для индексов есть pgstatindex(): низкая avg_leaf_density означает раздутое B-дерево. Обе функции читают данные с диска, поэтому на больших таблицах их лучше запускать вне пиковой нагрузки.

Если мёртвых строк много и они не убираются, причина почти всегда в одном: что-то не даёт VACUUM их удалить. Это шаг 6.

Шаг 4. Индексы

SQL
SELECT schemaname,
       relname,
       indexrelname,
       idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;
Нажмите, чтобы развернуть и увидеть больше

Колонка idx_scan показывает, сколько раз индекс использовался. Ноль у большого индекса — кандидат на удаление, но с тремя проверками:

Шаг 5. WAL

SQL
SELECT pg_size_pretty(coalesce(sum(size), 0)) AS wal_size
FROM pg_ls_waldir();
Нажмите, чтобы развернуть и увидеть больше

Нужны права суперпользователя или роль pg_monitor.

WAL размером до max_wal_size между чекпоинтами — норма. Если он заметно больше, PostgreSQL что-то держит. Причин три.

Слот репликации. Неактивный слот не даёт удалять WAL, который ещё не забрал его потребитель. Запрос показывает, сколько держит каждый слот (выполнять на primary):

SQL
SELECT slot_name,
       slot_type,
       active,
       wal_status,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC NULLS LAST;
Нажмите, чтобы развернуть и увидеть больше

Слот с active = false и растущим retained_wal — самая частая причина разросшегося pg_wal. Защита на будущее — параметр max_slot_wal_keep_size (PostgreSQL 13+): при превышении слот инвалидируется, и диск не заполняется. Цена — потребителя слота придётся переинициализировать.

Архивирование. Если archive_command падает, PostgreSQL копит WAL, пока сегменты не будут заархивированы:

SQL
SELECT archived_count, failed_count, last_failed_wal, last_failed_time
FROM pg_stat_archiver;
Нажмите, чтобы развернуть и увидеть больше

Растущий failed_count — значит, архивирование сломано, а WAL копится.

wal_keep_size. Если параметр выставлен большим, PostgreSQL держит столько WAL всегда. Это осознанная настройка, но о ней стоит помнить.

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

Шаг 6. Кто не даёт VACUUM работать

Мёртвые строки удаляются только тогда, когда их не может увидеть ни одна транзакция. Самая старая «точка зрения» в системе называется горизонтом xmin, и держать его могут три вещи. Исходная статья упоминает только первую.

Долгие транзакции, особенно idle in transaction — приложение открыло транзакцию и забыло её закрыть:

SQL
SELECT pid,
       usename,
       state,
       backend_xmin,
       now() - xact_start AS xact_age,
       left(query, 60)    AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 10;
Нажмите, чтобы развернуть и увидеть больше

Забытые подготовленные транзакции — после PREPARE TRANSACTION, которую так и не завершили. Они переживают перезапуск сервера и держат горизонт бесконечно:

SQL
SELECT gid, prepared, owner, database
FROM pg_prepared_xacts
ORDER BY prepared;
Нажмите, чтобы развернуть и увидеть больше

Слоты репликации — через xmin и catalog_xmin, а также реплики с hot_standby_feedback = on, на которых выполняются долгие запросы:

SQL
SELECT slot_name, xmin, catalog_xmin
FROM pg_replication_slots
WHERE xmin IS NOT NULL OR catalog_xmin IS NOT NULL;
Нажмите, чтобы развернуть и увидеть больше

Пока горизонт держится, autovacuum работает впустую, а таблицы раздуваются. В PostgreSQL 19 очередь autovacuum станет приоритетной, и посмотреть, какие таблицы он обработает первыми, можно будет через pg_stat_autovacuum_scores — подробнее о новых представлениях.

Шаг 7. Временные файлы и логи

Две причины, которых нет в исходной статье, хотя заполняют диск они регулярно.

Временные файлы. Сортировка или хеш, не поместившиеся в work_mem, уходят на диск. Сколько временных файлов создано с момента сброса статистики:

SQL
SELECT datname, temp_files, pg_size_pretty(temp_bytes) AS temp_size
FROM pg_stat_database
ORDER BY temp_bytes DESC;
Нажмите, чтобы развернуть и увидеть больше

Текущие временные файлы показывает pg_ls_tmpdir(). Чтобы найти запросы-виновники, включите log_temp_files = 0 — каждое создание временного файла попадёт в лог вместе с запросом.

Логи. Размер каталога логов — pg_ls_logdir(). Логирование всех запросов или отсутствие ротации заполняет диск незаметно.

Пять действий, которые делают хуже

1. «Удалим старые данные»

Помимо очевидного риска удалить нужное, у DELETE есть техническая проблема: он не освобождает диск. Удалённые строки становятся мёртвыми, файл таблицы остаётся того же размера, а сама операция пишет WAL. При заполненном диске массовый DELETE увеличивает занятость.

Если данные действительно пора убрать, место освобождают TRUNCATE или DROP/DETACH секции — именно поэтому большие таблицы с данными по времени стоит секционировать заранее (подробнее — в разборе записи).

2. «Запустим VACUUM FULL»

VACUUM FULL переписывает таблицу в новый файл и действительно возвращает место операционной системе. Но:

Альтернатива для продакшена — pg_repack: он перестраивает таблицу без долгих блокировок, хотя место под копию нужно и ему. В PostgreSQL 19 готовилась встроенная команда REPACK, но она оказалась в списке фич под вопросом.

Уточнение к расхожей фразе «VACUUM не уменьшает файл»: обычный VACUUM всё-таки может отдать ОС пустые страницы в конце файла. Но мёртвое место в середине он только помечает свободным.

3. «Удалим самый большой индекс»

Большой индекс не обязательно лишний: он может обслуживать критичный запрос и избавлять от полного сканирования. Сначала проверьте idx_scan на всех узлах — шаг 4.

4. «Почистим pg_wal руками»

Никогда не удаляйте файлы из pg_wal. Это прямой путь к базе, которая не запустится, и к сломанной репликации.

Правильный порядок: найти, кто держит WAL (шаг 5), убрать причину — удалить ненужный слот через pg_drop_replication_slot() или починить архивирование, — и выполнить CHECKPOINT. Лишние сегменты PostgreSQL удалит сам.

Если диск заполнен на 100% и PostgreSQL уже упал, места не хватит даже на запуск. Освобождайте место вне pg_wal — старые логи, посторонние файлы на томе — или расширяйте том, затем запускайте базу и устраняйте причину.

5. «Добавим места и забудем»

Расширение диска — нормальная временная мера. В Kubernetes это расширение PVC, если StorageClass разрешает allowVolumeExpansion (о томах в задаче про StatefulSet). Но оно не отвечает на вопрос, почему место кончилось, — и через неделю ситуация повторится.

Чтобы до этого не доходило

Порядок расследования

  1. df и du по каталогам PGDATA: данные, WAL, логи или временные файлы?
  2. Если данные — какие базы и таблицы растут, отдельно данные, индексы и TOAST.
  3. Раздувание через pgstattuple, а не только n_dead_tup.
  4. Если WAL — слоты, архивирование, wal_keep_size.
  5. Кто держит горизонт xmin: долгие и подготовленные транзакции, слоты, реплики.
  6. Только после этого — действие, и в первую очередь то, что не требует блокировок и свободного места.

Сначала измерить, потом действовать: это быстрее, чем кажется, и почти всегда быстрее, чем исправлять последствия поспешного VACUUM FULL.

Авторские права

Автор: Vasiliy Koshkin

Ссылка: https://notes.melancholic.tech/posts/postgresql-disk-space-investigation/

Лицензия: CC BY-NC-SA 4.0

Использование материалов блога разрешается при условии: указания авторства/источника, некоммерческого использования и сохранения лицензии.

Начать поиск

Введите ключевые слова для поиска статей

↑↓
↵
ESC
⌘K Горячая клавиша