Мёртвые кортежи и раздувание таблиц
Каждый 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). - Вред: лишний ввод-вывод, загрязнение кеша, плохие планы и рост диска; индексы раздуваются отдельно.
- Долгие и «забытые» транзакции и зависшие репликационные слоты держат горизонт и ускоряют раздувание во всей базе.