Мёртвые кортежи и раздувание таблиц

Каждый UPDATE и DELETE оставляет после себя мёртвую версию строки — и если её вовремя не убрать, таблица распухает балластом, который замедляет всё.

Мёртвый кортеж (dead tuple) — версия строки, которую уже не видит ни одна транзакция, но которая ещё занимает место на странице. Раздувание (bloat) — накопление таких мёртвых версий и пустот, из-за которого таблица или индекс физически крупнее, чем нужно для живых данных.

Из прошлого урока мы знаем: UPDATE и DELETE в PostgreSQL не стирают данные, а помечают версии удалёнными. Пока мёртвых кортежей немного — это норма и плата за многоверсионность. Но если их порождают быстрее, чем убирают, таблица растёт «вхолостую»: на диске десятки гигабайт, а живых строк — на единицы. Этот урок — про то, как bloat возникает, как его заметить и почему он бьёт по скорости.

Зачем это на практике

Bloat — одна из самых частых причин необъяснимого замедления PostgreSQL под нагрузкой. Запросы, которые раньше летали, начинают читать в разы больше страниц, потому что между живыми строками лежит «мусор». Раздувается и место на диске, и кеш: в shared_buffers попадают страницы, наполовину состоящие из мёртвых версий. Особенно коварен bloat индексов — он замедляет даже точечные выборки по ключу. Умение увидеть раздувание вовремя экономит и деньги на диске, и нервы на разборе «почему вдруг всё тормозит».

Откуда берутся мёртвые кортежи

Источников ровно три, и все — следствие MVCC:

ОперацияЧто оставляет после себя
DELETEверсию строки с проставленным xmax — она станет мёртвой, как только её перестанут видеть все снимки
UPDATEстарую версию (мёртвую после коммита и ухода снимков) + новую живую версию
откат (ROLLBACK)версию, созданную отменённой транзакцией: она сразу мёртвая, её xmin принадлежит непринятой транзакции

Ключевой нюанс: версия становится мёртвой не в момент UPDATE, а когда её перестаёт видеть самая старая живая транзакция. Пока открыт чей-то старый снимок, способный сослаться на эту версию, она считается ещё нужной и удалить её нельзя. Поэтому долгие транзакции — главный ускоритель bloat: они «замораживают» горизонт видимости, и мёртвые версии копятся, не имея права исчезнуть.

Как заметить раздувание

PostgreSQL ведёт статистику по каждой таблице. Первый индикатор — счётчик мёртвых строк в pg_stat_user_tables.

SELECT relname,
       n_live_tup,            -- примерно живых строк
       n_dead_tup,            -- примерно мёртвых версий
       last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 5;

   relname   | n_live_tup | n_dead_tup |     last_autovacuum
-------------+------------+------------+------------------------
 orders      |     120000 |     480000 | 2026-06-20 11:03:00+00
 sessions    |      5000  |      90000 |

Если n_dead_tup сопоставим с n_live_tup или превышает его (как у orders выше — мёртвых вчетверо больше живых) — это явный bloat. Колонка last_autovacuum подскажет, доходила ли вообще автоуборка до таблицы.

Физический размер объекта смотрят функциями размера:

SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total,   -- таблица + индексы + TOAST
       pg_size_pretty(pg_relation_size('orders'))       AS heap;    -- только куча таблицы

Само по себе число гигабайт ничего не доказывает — важно сравнить «сколько занято» с «сколько нужно живым строкам». Точную оценку даёт расширение pgstattuple: оно реально проходит по страницам и считает долю мусора.

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT * FROM pgstattuple('orders');
--  table_len | tuple_count | dead_tuple_count | dead_tuple_percent | free_percent
--  900000000 |      120000 |           480000 |              52.13 |        18.40

-- для индекса есть отдельная, более дешёвая функция
SELECT * FROM pgstatindex('orders_pkey');

dead_tuple_percent и free_percent здесь говорят прямо: половина таблицы — мёртвые версии, ещё почти пятая часть — пустоты. Это и есть раздувание в чистом виде.

Bloat индексов

Индексы раздуваются по той же причине: при UPDATE индексированного столбца (или когда не сработал HOT) в индекс добавляется ссылка на новую версию, а старая остаётся, пока её не вычистят. Раздутый индекс не только занимает место, но и удлиняет обход дерева. Лечится он отдельно — перестроением (REINDEX), о котором в следующем уроке.

Чем bloat вредит производительности

Урон не «эстетический», а вполне измеримый:

  • Больше операций ввода-вывода. Sequential scan и обходы индекса читают страницы, наполовину забитые мёртвыми версиями, — полезных строк на страницу приходится меньше, страниц читается больше.
  • Загрязнение кеша. В shared_buffers попадают «мусорные» страницы, вытесняя горячие данные — падает кеш-хит.
  • Деградация плана. Раздутые таблицы и устаревшая статистика сбивают планировщик: он недооценивает стоимость и выбирает неоптимальные планы.
  • Рост диска. Место, занятое надгробиями, не возвращается ОС автоматически обычным VACUUM — только переиспользуется внутри файла.

Как это работает под капотом

Граница между «ещё нужной» и «уже мёртвой» версией задаётся горизонтом — самым старым снимком среди всех активных транзакций, открытых prepared-транзакций и сдерживающих репликационных слотов. Всё, что старше горизонта и не видно никому, — кандидат на уборку. Поэтому одна забытая транзакция в состоянии idle in transaction или зависший слот логической репликации тормозят весь сбор мусора в базе: горизонт не двигается, и VACUUM не имеет права освобождать мёртвые версии, даже регулярно запускаясь. Увидеть, кто держит горизонт, помогает pg_stat_activity (старые xact_start, статус idle in transaction) и представления о слотах.

Частые ошибки

  • Считать рост таблицы «нормальным ростом данных». Если строк столько же, а файл вырос вдвое, это не данные, а bloat — проверяйте n_dead_tup и pgstattuple.
  • Забытые транзакции idle in transaction. Приложение открыло транзакцию и не закрыло — горизонт замер, мусор копится по всей базе, а не только в «той» таблице.
  • Массовые UPDATE всей таблицы. Один UPDATE без WHERE мгновенно удваивает число версий — все старые становятся мёртвыми разом.
  • Игнорировать bloat индексов. Таблицу почистили, а индекс остался раздутым — точечные запросы по ключу по-прежнему медленные.

Итоги

  • Мёртвый кортеж — невидимая никому старая версия строки; bloat — накопление таких версий и пустот сверх нужного живым данным объёма.
  • Источники: DELETE, UPDATE (оставляет старую версию) и откаты; версия становится мёртвой, лишь когда уходит за горизонт видимости.
  • Замечают bloat по n_dead_tup в pg_stat_user_tables, функциям размера и расширению pgstattuple (dead_tuple_percent).
  • Вред: лишний ввод-вывод, загрязнение кеша, плохие планы и рост диска; индексы раздуваются отдельно.
  • Долгие и «забытые» транзакции и зависшие репликационные слоты держат горизонт и ускоряют раздувание во всей базе.
Проверьте себя
1. Что такое мёртвый кортеж (dead tuple) в PostgreSQL?
AСтрока с значением NULL во всех столбцах
BВерсия строки, которую уже не видит ни одна транзакция, но которая всё ещё занимает место на странице
CСтрока, заблокированная другой транзакцией
DЗапись в журнале упреждающей записи (WAL)
2. По какому показателю удобнее всего быстро заподозрить раздувание таблицы?
AПо числу столбцов в таблице
BПо соотношению n_dead_tup и n_live_tup в pg_stat_user_tables
CПо количеству индексов на таблице
DПо версии сервера PostgreSQL
3. Почему забытая транзакция в состоянии 'idle in transaction' ускоряет раздувание во всей базе?
AОна блокирует все таблицы на запись
BЕё старый снимок удерживает горизонт видимости, и VACUUM не может удалять мёртвые версии, даже регулярно запускаясь
CОна отключает autovacuum полностью
DОна переводит все запросы на уровень SERIALIZABLE