Вы заметили, что таблица на диске занимает 10 ГБ, хотя реальных данных там в три раза меньше. Или PostgreSQL вдруг начинает тормозить без видимой причины. Скорее всего, дело в bloat — накопившихся мёртвых строках. Разберём, почему это происходит и как с этим справляться.
Одна страница таблицы, шесть слотов под версии строк. UPDATE не стирает старую версию: она остаётся в странице мёртвой, а новая дописывается в свободный слот — так файл и растёт. VACUUM мёртвые слоты освобождает и заносит в карту свободного места, но файл не укорачивает: место переиспользуется внутри него, и следующая строка садится в освободившийся слот.
Почему строки не удаляются сразу
PostgreSQL использует подход, который называется MVCC (Multi-Version Concurrency Control — управление конкурентным доступом через версии). Идея такая: когда вы делаете UPDATE или DELETE, старая строка не стирается немедленно. Она помечается как «мёртвая» и остаётся в файле таблицы.
Зачем? Потому что в этот момент другая транзакция может читать старую версию данных — и должна видеть её консистентной. Пока хоть одна открытая транзакция может видеть старую строку, PostgreSQL не имеет права её удалить.
В итоге после интенсивной работы с данными в таблицах накапливаются мёртвые строки (dead tuples). Именно от них и раздувается размер таблицы — это и есть bloat.
Что делает VACUUM
VACUUM — это команда уборки. Она проходит по таблице и делает три вещи:
Освобождает место от мёртвых строк. Место не возвращается операционной системе — файл таблицы не уменьшается. Зато освобождённые страницы заносятся в FSM (Free Space Map, карту свободного места), и новые строки записываются туда, не раздувая файл дальше. Исключение одно: пустые страницы в самом хвосте файла VACUUM отрезает, взяв на короткое время исключительную блокировку.
Обновляет visibility map. Это карта страниц, где все строки гарантированно видны всем транзакциям. Она нужна для эффективной работы Index Only Scan — без неё планировщик вынужден лезть в саму таблицу даже при запросе только по индексу.
Предотвращает XID wraparound. Об этом подробнее в отдельном разделе ниже.
VACUUM не блокирует чтение и запись — он работает с блокировкой типа SHARE UPDATE EXCLUSIVE. Ждать придётся только командам DDL вроде ALTER TABLE.
Три варианта команды
Разница между вариантами одна. Обычный VACUUM убирает мёртвые версии внутри файла таблицы и оставляет место самой таблице под новые строки. VACUUM FULL переписывает таблицу заново в новый файл и возвращает место операционной системе, но на всё это время держит таблицу под полной блокировкой.
-- Обычный VACUUM: освобождает мёртвые строки, не блокирует таблицу
VACUUM order_doc;
-- С анализом: заодно обновляет статистику для планировщика
VACUUM ANALYZE order_doc;
-- Полная перезапись таблицы: возвращает место ОС, но блокирует всё
VACUUM FULL order_doc;
VACUUM и VACUUM ANALYZE — безопасно запускать в любое время.
VACUUM FULL — принципиально другая операция. Она полностью перезаписывает таблицу с нуля и при этом берёт блокировку ACCESS EXCLUSIVE. Это значит: пока идёт VACUUM FULL, таблица недоступна ни для чтения, ни для записи. На больших таблицах это могут быть часы. В продакшне почти никогда не применяют.
Альтернатива VACUUM FULL без блокировки — утилита pg_repack (или pg_squeeze). Она перестраивает таблицу в фоне, не мешая работе приложения:
pg_repack -d mydb -t order_doc
VACUUM ANALYZE стоит запускать вручную после массового UPDATE или DELETE — не ждите, пока сработает автоматика: планировщик может долго работать с устаревшей статистикой.
autovacuum — автоматическая уборка
PostgreSQL запускает VACUUM автоматически через демон autovacuum. Он следит за каждой таблицей и запускает уборку, когда накопилось достаточно мёртвых строк.
Порог срабатывания по умолчанию:
мёртвые строки > 50 + 0.2 × количество живых строк
На таблице в миллион строк autovacuum запустится при ~200 000 мёртвых. Для большинства таблиц этого достаточно.
Но для больших и активных таблиц дефолтные 20% — это слишком много. Настройку можно задать прямо на таблице:
ALTER TABLE order_doc SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.05
);
Теперь autovacuum запустится при ~50 000 мёртвых строк вместо 200 000 — bloat будет меньше.
Как обнаружить bloat
Быстрый взгляд через системную статистику:
живой пример
SELECT relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 1) AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_live_tup > 1000
ORDER BY n_dead_tup DESC
LIMIT 10;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
На что смотреть:
dead_pct > 20%— таблица кандидат на ручной VACUUM.last_autovacuumдавно (больше суток на горячей таблице) — autovacuum не успевает.
Для точной картины есть расширение pgstattuple:
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('order_doc');
-- dead_tuple_percent > 30% — таблица явно распухла
Индексы тоже распухают — отдельно от таблиц. Лечится командой REINDEX CONCURRENTLY (доступна с PostgreSQL 12) — не блокирует таблицу во время перестройки.
Что VACUUM делает прямо сейчас
Отдельный вопрос, на который pg_stat_user_tables не отвечает: уборка уже идёт — и на каком она этапе. Показывает это pg_stat_progress_vacuum:
живой пример
SELECT p.pid, p.relid::regclass AS tablica, p.phase,
p.heap_blks_total, p.heap_blks_scanned, p.heap_blks_vacuumed,
round(100.0 * p.heap_blks_scanned / nullif(p.heap_blks_total, 0), 1) AS percent
FROM pg_stat_progress_vacuum p;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Колонка phase называет этап: scanning heap — ищет мёртвые версии, vacuuming indexes — чистит индексы (на таблице с восемью индексами это самая долгая часть), vacuuming heap — освобождает место в таблице. Проценты по блокам дают честную оценку оставшегося времени, а повторяющиеся циклы «сканирование — индексы — сканирование» означают, что памяти под список мёртвых версий не хватило и уборка идёт в несколько заходов: помогает увеличение maintenance_work_mem (или autovacuum_work_mem).
Рядом живут pg_stat_progress_analyze, pg_stat_progress_create_index и pg_stat_progress_cluster — они отвечают на тот же вопрос про свои операции.
Чему верить в статистике
Диагностика выше построена на n_dead_tup, и про это число надо знать главное: это оценка сборщика статистики, а не точный счётчик. Оно копится из отчётов рабочих процессов, сбрасывается при pg_stat_reset() и может заметно расходиться с действительностью сразу после массовых операций.
Для решения «пора ли убирать» этой точности достаточно — autovacuum сам работает по этим же числам. А вот когда надо понять, сколько места реально занято мусором, берут pgstattuple: расширение читает таблицу и считает честно.
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('orders'); -- по таблице
SELECT * FROM pgstatindex('ix_orders_customer'); -- по индексу
Цена — полное чтение объекта, поэтому на большой таблице это делают в спокойное время (у pgstattuple_approx есть быстрый приблизительный вариант). Зато ответ точный, и именно им проверяют, помогла ли перестройка.
Индексы: чем мерить и когда перестраивать
Раздувание индекса — отдельная беда от раздувания таблицы: VACUUM убирает из индекса ссылки на мёртвые строки, но не возвращает освободившиеся страницы наружу и не уплотняет дерево. На таблице, которую часто обновляют, индекс со временем становится заметно больше, чем нужно.
Мерят его pgstatindex (avg_leaf_density заметно ниже 70 % и высокий leaf_fragmentation — признаки), а лечат REINDEX CONCURRENTLY. Важная поправка к старым советам: с PostgreSQL 13 у B-дерева работает дедупликация — одинаковые значения ключа хранятся один раз со списком ссылок, — и индексы по колонкам с повторами (статус, флаг, идентификатор клиента) раздуваются в разы меньше, чем раньше. Так что прежде чем перестраивать по привычке, стоит измерить.
TOAST убирают отдельно
У таблицы с длинными значениями (текст, jsonb, массивы) есть вторая, скрытая таблица — TOAST, и у неё своя уборка со своими порогами. Обновление документа в jsonb создаёт новую версию не только в основной таблице, но и в TOAST, поэтому половина «непонятного» раздувания на таких таблицах живёт именно там.
Посмотреть размер и настроить пороги можно так:
SELECT relname, pg_size_pretty(pg_relation_size(reltoastrelid)) AS toast_size
FROM pg_class WHERE relname = 'events';
ALTER TABLE events SET (toast.autovacuum_vacuum_scale_factor = 0.05);
Параметры TOAST задают с префиксом toast. — обычные настройки таблицы на неё не распространяются. Это первое, что проверяют, когда таблица «весит втрое больше данных», а по основной части всё чисто.
Партиционированная таблица
Сама партиционированная таблица данных не хранит — значит, и убирать в ней нечего: autovacuum работает по каждой части отдельно, со своими счётчиками и порогами. Отсюда два практических следствия.
Первое: пороги, выставленные на родительской таблице, новые части не наследуют автоматически — их задают в шаблоне создания или прописывают каждой части. Второе, приятное: старые части журнальных таблиц обычно не меняются вовсе, и уборка их не трогает; а когда часть перестаёт быть нужной, её не чистят, а отцепляют и удаляют — DROP TABLE на части возвращает место мгновенно и без всякого VACUUM FULL. Это и есть главный эксплуатационный довод за разбиение на части, о котором говорит статья про партиционирование.
Когда раздувание — это норма
Порог «20 % мёртвых строк» стоит читать вместе с оговоркой, иначе он провоцирует лечить здоровое. У таблицы, которую постоянно обновляют, всегда есть слой мёртвых версий — это устройство MVCC, а не авария. Уборка их убирает, место остаётся в таблице и заполняется новыми версиями; так оно и должно работать, и стабильные 20 % на горячей таблице — рабочее состояние.
Тревожит не уровень, а динамика: доля мёртвых версий растёт неделями, размер таблицы растёт быстрее данных, last_autovacuum давно не обновлялся. Вот тогда ищут причину — долгую транзакцию, зависший слот репликации, отключённую уборку, — а не запускают VACUUM FULL по расписанию.
Когда autovacuum не справляется
Мёртвых версий всё больше, хотя last_autovacuum показывает, что уборщик прошёл минуту назад. Так выглядит любая из ситуаций, когда autovacuum работает, а мёртвые строки всё равно накапливаются, и различаются они причиной.
Ему просто не хватает скорости. Это самая частая причина на нагруженной базе, и она не про «кто-то мешает удалять» — уборщик физически не успевает пройти таблицу. Autovacuum намеренно работает медленно, чтобы не мешать запросам: он считает условную стоимость прочитанных страниц и, набрав autovacuum_vacuum_cost_limit (по умолчанию берётся vacuum_cost_limit = 200), засыпает на autovacuum_vacuum_cost_delay — 2 мс начиная с PostgreSQL 12. Плюс параллельно работают всего autovacuum_max_workers = 3 процесса на весь кластер: если таблиц, требующих уборки, десяток, они выстраиваются в очередь.
Признак ровно этот: n_dead_tup растёт, last_autovacuum не пустой, но уборка на одной таблице идёт часами. Лечится увеличением autovacuum_vacuum_cost_limit (до 1000–2000 на быстром диске), уменьшением задержки и — осторожно — добавлением воркеров: их бюджет стоимости делится на всех, так что одни воркеры без поднятого лимита ничего не ускорят.
живой пример
SELECT pid, relid::regclass, phase, heap_blks_scanned, heap_blks_total
FROM pg_stat_progress_vacuum;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Долгая транзакция. MVCC запрещает удалять строки, которые видит хоть одна открытая транзакция. Если у вас висит транзакция часами — bloat будет расти независимо от autovacuum.
живой пример
SELECT pid,
age(now(), xact_start) AS xact_age,
state,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC
LIMIT 10;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если xact_age больше 5 минут — стоит разобраться, что там. Типичные причины: незакрытая сессия в коде, зависший фоновый процесс.
Хорошая практика — установить таймаут idle_in_transaction_session_timeout = '30s': он автоматически закрывает транзакции, которые начали, но ничего не делают.
И две вещи, которые делают после перестройки, — их пропускают чаще всего. После VACUUM FULL (и после pg_repack) статистика по таблице оказывается неактуальной, потому что физически это уже другая таблица: следом обязательно ANALYZE, иначе планы поедут на ровном месте. А перед pg_repack проверяют два условия: ему нужен первичный ключ или уникальный индекс (иначе он просто откажется работать) и свободное место на диске размером с саму таблицу плюс её индексы — он строит копию рядом и переключает на неё, и если места не хватит, операция прервётся уже на середине.
Глубже: Как это выглядит на моделирасширенное
Механику видно и без базы. Страница здесь — шесть слотов под версии строк: UPDATE помечает старую версию мёртвой и дописывает новую в свободный слот, а уборка освобождает те мёртвые версии, которых уже никто не видит.
живой пример
public class VacuumDemo {
static final int[] slot = new int[6];
static final long[] deletedBy = new long[6];
static long xid = 100;
public static void main(String[] args) {
for (int row = 1; row <= 4; row++) {
insert(row);
}
update(2);
update(4);
System.out.println("после UPDATE строк 2 и 4: " + page());
System.out.println("VACUUM при открытой транзакции 101: освобождено " + vacuum(101) + " " + page());
System.out.println("VACUUM после её завершения: освобождено " + vacuum(xid + 1) + " " + page());
insert(5);
System.out.println("после INSERT строки 5: " + page());
System.out.println("слотов в файле было и осталось: " + slot.length);
}
static void insert(int row) {
int free = 0;
while (slot[free] != 0) {
free++;
}
slot[free] = row;
}
static void update(int row) {
long now = ++xid;
for (int i = 0; i < slot.length; i++) {
if (slot[i] == row && deletedBy[i] == 0) {
deletedBy[i] = now;
}
}
insert(row);
}
static int vacuum(long horizon) {
int freed = 0;
for (int i = 0; i < slot.length; i++) {
if (deletedBy[i] != 0 && deletedBy[i] < horizon) {
slot[i] = 0;
deletedBy[i] = 0;
freed++;
}
}
return freed;
}
static String page() {
StringBuilder page = new StringBuilder();
for (int i = 0; i < slot.length; i++) {
page.append(slot[i] == 0 ? "[ ·]" : deletedBy[i] == 0 ? "[ " + slot[i] + " ]" : "[ " + slot[i] + "†]");
}
return page.toString();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Показательны два прогона уборки. При открытой транзакции 101 не освобождается ни один слот: она ещё может увидеть удалённые версии — ровно так долгая транзакция и копит bloat в настоящей базе. После её завершения оба мёртвых слота освобождаются, и следующая вставка садится в один из них: файл не вырос.
Глубже: Порог для таблиц, в которые только вставляютрасширенное
Стандартный порог autovacuum не ловит один вид таблиц.
Порог выше считает мёртвые строки, а в журнале событий или таблице сообщений их не бывает вовсе: туда только вставляют. Такая таблица годами не видела бы уборки — а она ей нужна: заморозить старые строки и обновить visibility map, без которой не работает Index Only Scan.
Для этого с PostgreSQL 13 есть вторая пара порогов, и считает она вставленные строки:
вставленные строки > autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor × живых строк
По умолчанию это 1000 + 0.2 × живых. Настраивается так же, на уровне таблицы:
ALTER TABLE event_log SET (autovacuum_vacuum_insert_scale_factor = 0.02);
На PostgreSQL 12 и ниже такого порога нет — там журнальным таблицам уборку назначают расписанием снаружи.
Глубже: fillfactor — место под обновлениярасширенное
По умолчанию PostgreSQL заполняет страницы данными до конца (fillfactor = 100). При UPDATE обновлённая строка чаще всего не помещается на ту же страницу и пишется на новую — а в индексах появляется лишняя запись.
Если таблица активно обновляется, имеет смысл оставить на страницах запас:
ALTER TABLE order_doc SET (fillfactor = 85);
С fillfactor = 85 страницы заполняются только на 85%. Когда приходит UPDATE, обновлённая строка часто помещается на ту же страницу — это называется HOT update (Heap-Only Tuple). Индекс при этом не трогается, нагрузка меньше.
Новый fillfactor действует на страницы, которые база заполняет после команды; уже лежащие данные он не переукладывает. Чтобы применить его ко всей таблице сразу, её надо переписать — и тут работает та же оговорка, что и выше: VACUUM FULL возьмёт ACCESS EXCLUSIVE и на большой таблице остановит её на часы. На живой базе для этого берут pg_repack, а VACUUM FULL оставляют на случай, когда таблицу и правда можно закрыть.
И самое важное про HOT: одного места на странице мало. Второе условие — UPDATE не должен менять ни одной колонки, по которой построен индекс. Стоит появиться индексу по обновляемой колонке, и HOT выключается целиком, сколько места на странице ни оставляй. Это легко проверить: две одинаковые таблицы с fillfactor = 70, по 5 000 строк, обе получают UPDATE ... SET status = 'PAID'. В той, где по status индекса нет, n_tup_hot_upd показывает 2 106 обновлений «на месте». В той, где индекс по status есть, — ровно 0.
Отсюда практический вывод: снижать fillfactor имеет смысл только для таблиц, где часто обновляют неиндексированные колонки — счётчики, статусы обработки, отметки времени, по которым никто не ищет.
Глубже: Зависший слот, prepared-транзакции и выключенный autovacuumрасширенное
Если долгих транзакций нет, а мёртвые строки всё равно копятся, причина живёт рядом с базой: в слотах репликации, подготовленных транзакциях или просто в выключенном autovacuum.
Зависший replication slot. Если реплика или подписчик сильно отстала, PostgreSQL держит WAL-файлы до момента, пока они не будут прочитаны, — и диск заполняется. Дальше слоты ведут себя по-разному.
Физический слот (обычная потоковая реплика) уборке строк сам по себе не мешает — это начинается, только если на реплике включён hot_standby_feedback: тогда она просит мастер придержать версии строк, нужные её собственным запросам.
Логический слот (CDC, Debezium, логическая подписка) мешает всегда, без всякой обратной связи. Ему нужно уметь разобрать старые записи журнала, а для этого — знать, как тогда выглядела схема. Поэтому он держит catalog_xmin и запрещает убирать старые версии строк в системных каталогах. Симптом узнаваемый: pg_class, pg_attribute и соседи распухают, запросы к схеме и планирование начинают тормозить, а обычные таблицы при этом чистые. Смотреть надо сюда:
живой пример
SELECT slot_name, slot_type, active,
age(xmin) AS xmin_age,
age(catalog_xmin) AS catalog_xmin_age
FROM pg_replication_slots;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Prepared-транзакции. Команды PREPARE TRANSACTION создают «подвешенные» транзакции, которые живут до явного COMMIT PREPARED или ROLLBACK PREPARED.
живой пример
SELECT * FROM pg_prepared_xacts;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если там что-то зависло — это блокирует уборку так же, как долгая обычная транзакция.
autovacuum выключен. Иногда его отключают перед массовой загрузкой данных и забывают включить обратно.
живой пример
SELECT name, setting FROM pg_settings WHERE name = 'autovacuum';
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
А так — таблицы, которым автовакуум настроили отдельно:
живой пример
SELECT relname, reloptions
FROM pg_class
WHERE relkind = 'r' AND reloptions::text LIKE '%autovacuum%';
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Глубже: XID wraparound — критическая ситуациярасширенное
Худшее, что бывает с запущенной уборкой: база перестаёт принимать запись и работает только на чтение, пока администратор не проведёт уборку вручную. Причина в 32-битном счётчике транзакций (XID). Исчерпав ~2,1 миллиарда значений, он «проходит круг», и база могла бы перепутать старые и новые транзакции; чтобы этого не произошло, VACUUM «замораживает» старые строки, помечает их как видимые всем навсегда, а если заморозка отстаёт, база останавливает запись заранее.
Проверить, насколько близко до опасной отметки:
живой пример
SELECT datname,
age(datfrozenxid) AS xid_age,
2147483647 - age(datfrozenxid) AS xids_left
FROM pg_database;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Рабочая отметка на этой шкале вовсе не у самого края. Она называется autovacuum_freeze_max_age и по умолчанию равна 200 миллионам: как только возраст таблицы её переходит, PostgreSQL запускает антиwraparound-уборку. Особенность у неё две. Во-первых, она запустится даже на таблице с выключенным autovacuum. Во-вторых, её нельзя просто отменить: убьёте процесс — база запустит его снова, а на большой таблице такая уборка сама способна забить диск вводом-выводом. Поэтому оповещение ставят кратно этой отметке — скажем, на age(datfrozenxid) > 400 млн, — а не на 1,5 миллиарда: к полутора миллиардам вы приходите уже с аварией.
Если xid_age всё-таки дорос до 1.5 миллиарда — autovacuum не справляется, нужно вмешаться вручную. Сначала PostgreSQL пишет в лог всё более настойчивые предупреждения. Если их проигнорировать до конца, он не «замедлит» запись, а просто перестанет её принимать: новые транзакции начать будет нельзя, пока не отработает уборка. База при этом продолжит отвечать на чтение, но для приложения это полноценная авария.
Коротко
- PostgreSQL не удаляет строки сразу:
UPDATEиDELETEоставляют старые версии (MVCC), пока их может видеть хоть одна открытая транзакция; из них и растёт bloat. - VACUUM освобождает место внутри файла, обновляет visibility map и замораживает старые строки от XID wraparound; файл при этом не уменьшается — отрезается только пустой хвост. Чтение и запись он не блокирует.
VACUUM FULLместо операционной системе возвращает, но берётACCESS EXCLUSIVEна часы: в продакшне вместо негоpg_repack -d mydb -t order_doc. После массовогоUPDATEилиDELETE—VACUUM ANALYZEвручную.- autovacuum срабатывает на «50 + 0.2 × живых строк»; на больших таблицах
autovacuum_vacuum_scale_factorснижают до 0.05 прямо на таблице. Для таблиц, куда только вставляют, с PostgreSQL 13 работает отдельный порог по вставкам —1000 + 0.2 × живых. - Bloat смотрят в
pg_stat_user_tables, ноn_dead_tupтам оценка: точные числа даётpgstattuple, а фазу идущей уборки —pg_stat_progress_vacuum. Стабильные 20 % мёртвых версий на горячей таблице норма, тревожит динамика; индексы распухают отдельно и лечатсяREINDEX CONCURRENTLY. - Чаще всего autovacuum не «не может», а не успевает: он спит
autovacuum_vacuum_cost_delay(2 мс) каждыеvacuum_cost_limit(200) единиц работы и делит кластер на три воркера. Это первое, что крутят на нагруженной базе. - Уборке мешают долгие транзакции (
idle_in_transaction_session_timeout = '30s'против забытых), prepared-транзакции, реплика сhot_standby_feedbackи логический слот, держащийcatalog_xmin. - fillfactor 80–90 даёт HOT update, но только если
UPDATEне трогает ни одной индексированной колонки. У TOAST своя уборка со своими порогами (toast.autovacuum_*), а в партиционированной таблице autovacuum считает каждую часть отдельно. - За
age(datfrozenxid)следят отдельно: оповещение ставят отautovacuum_freeze_max_age(200 млн), а не от края шкалы в 2,1 миллиарда — конец это не тормоза, а остановка записи. - После
VACUUM FULLиpg_repackобязателенANALYZE, а самомуpg_repackнужны первичный ключ и двойное место на диске.
Что почитать дальше
- Индексы в PostgreSQL — как работает Index Only Scan и visibility map.
- EXPLAIN ANALYZE — откуда в плане берутся Heap Fetches, которые лечит VACUUM.
- Уровни изоляции транзакций — MVCC и видимость строк.
- Мониторинг PostgreSQL — pg_stat_user_tables и другие метрики.