В первых двух уроках мы посмотрели на статическую картину: страница, кортежи, заголовки. Теперь — на динамику. Что происходит, когда ты делаешь UPDATE?
В Postgres UPDATE — это никогда не in-place. Из-за MVCC старая версия должна остаться видимой для транзакций, читающих старый snapshot. Поэтому каждый UPDATE физически создаёт новую копию строки и помечает старую как удалённую (xmax = текущая транзакция). В типичной OLTP-нагрузке это означает миллионы новых tuples в день, и без двух механизмов — fillfactor и HOT — производительность катастрофически проседала бы.
fillfactor: «оставь место для будущих UPDATE’ов»
fillfactor = 80 означает: при INSERT’ах Postgres использует только 80% страницы (~6.5 KiB из 8 KiB) и оставляет 20% (~1.6 KiB) пустыми. На первый взгляд — мы что, специально расходуем диск?
Да — и осознанно. Это место зарезервировано под HOT updates, о которых через секунду. Если на странице нет места — новой версии придётся уехать на другую страницу, и тогда HOT не сработает.
Дефолты:
- Heap таблицы:
fillfactor = 100(вся страница заполняется). - B-tree индексы:
fillfactor = 90(10% зарезервировано под page splits). - Hash, GiST: разные, обычно 75-90%.
Дефолт 100% для heap — историческое наследие, но он оптимален только для append-only таблиц (логов, событий). Если в таблице регулярные UPDATE — этот параметр обычно стоит снижать. Типичные значения для OLTP: fillfactor = 70..90.
Создадим две таблицы — одну с fillfactor=100 (default), вторую с fillfactor=70:
counters_loose ожидаемо больше — мы намеренно тратим 30% места. Это инвестиция, которая окупится при апдейтах. Посмотрим как.
HOT: Heap Only Tuple
Главная проблема UPDATE’а в Postgres — это не сам новый кортеж в heap. Это то, что каждый индекс на таблице тоже должен получить новую запись, указывающую на новый ctid. Если у тебя 5 индексов на таблице, один UPDATE одной строки = 1 запись в heap + 5 записей в индексах. Плюс bloat в каждом индексе.
- Ни одна индексированная колонка не меняется в этом UPDATE.
- Новая версия помещается на ту же страницу, что и старая.
Если оба выполнены — Postgres:
- создаёт новый кортеж на той же странице;
- связывает его со старым через
ctidстарой версии:old.ctid → new.ctid; - помечает старый как
HEAP_HOT_UPDATED, новый — какHEAP_ONLY_TUPLE; - никакой записи в индексы не делает.
Когда индекс пытается найти строку, он попадает на старый ctid, видит флаг HOT, и идёт по цепочке ctid → ctid → ctid, пока не найдёт живую версию. Это называется HOT chain.
UPDATE не меняет индексированных колонок и помещается на ту же страницу — индексы не трогаются.
Видишь экономию? UPDATE одной строки — это ровно одна запись в heap, и ничего больше. Никаких индексных записей, никакого bloat. На таблице с 5-6 индексами это разница в производительности в разы.
Когда HOT ломается
Магия требует двух условий — оба легко нарушить.
Нарушение 1: меняется индексированная колонка
-- HOT работает: hits не индексирован
UPDATE counters SET hits = hits + 1 WHERE id = 42;
-- HOT не работает: id индексирован (primary key)
UPDATE counters SET id = id + 1000 WHERE id = 42;
Это очевидно: если меняется индексируемое значение, индекс обязан узнать о новой записи. HOT — это контракт «индекс не трогаем», и менять индексируемые данные нельзя.
Нарушение 2: нет места на странице
Если страница уже забита (fillfactor 100% + полно живых tuples), новой версии некуда положить — придётся уезжать на новую страницу. Тогда строка получает новый ctid из другого блока, и индекс должен узнать об этом. HOT отключается, индексы обновляются — это обычный «cold» UPDATE.
Именно поэтому fillfactor так важен: оставляя 20-30% страницы свободными, ты гарантируешь место для HOT-апдейтов. Без этого даже идеально написанный UPDATE на не-индексируемой колонке быстро дегенерирует в cold updates, как только страница заполнится.
Сравним поведение fillfactor=100 и fillfactor=70 при многократных UPDATE'ах одной строки:
В реальной БД (без pglite) ты бы увидел существенно меньший рост у counter_loose, потому что HOT-update’ы прибираются автоинкрементальным механизмом single-page cleanup — Postgres переиспользует место от dead tuples на той же странице, не дожидаясь VACUUM. На pglite автовакуум выключен или сильно урезан, так что разница будет менее наглядна; но логика остаётся та же.
Проверяем HOT через pg_stat_user_tables
Postgres ведёт счётчики HOT и не-HOT апдейтов. Они доступны в pg_stat_user_tables:
Сделаем не-HOT UPDATE (по primary key) и HOT UPDATE (по не-индексируемой колонке), посмотрим на счётчики:
Соотношение n_tup_hot_upd / n_tup_upd — это HOT ratio, ключевая метрика здоровья OLTP-таблицы. На правильно настроенной таблице с типичной нагрузкой она должна быть > 80%. Если упала ниже 50% — пора смотреть, какие индексы триггерят cold updates, и есть ли смысл понизить fillfactor.
Page pruning: уборка HOT-цепочек
Когда HOT-цепочка становится длинной (например, после нескольких UPDATE’ов одной строки), Postgres делает page pruning во время обычного SELECT или UPDATE: убирает все мёртвые версии из цепочки, оставляя только живую. Это не VACUUM — это локальная операция, на одной странице, и происходит автоматически без отдельной команды.
Pruning умеет:
- удалить мёртвые tuples из HOT-цепочки;
- редко — освободить line pointer (только если он не используется индексом);
- объединить освободившееся пространство.
Не умеет:
- удалить tuple, на который указывает индекс (это работа VACUUM);
- работать на странице, которую он не видит в snapshot.
Главное практическое следствие: правильно настроенный fillfactor + HOT-friendly UPDATE’ы → таблица «самоочищается» при обычном чтении/записи, и нагрузка на VACUUM минимальна.
Когда снижать fillfactor
Не нужно делать fillfactor = 50 на всех таблицах подряд. Это диск, и его реально жалко. Правила:
- Append-only (логи, events, audit):
fillfactor = 100. UPDATE’ов нет — резервировать нечего. - OLTP с частыми UPDATE’ами одних строк (счётчики, статусы):
fillfactor = 70-80. Высокий шанс HOT. - Узкие таблицы со widely-scattered UPDATE’ами:
fillfactor = 85-95. Резерв нужен, но небольшой. - B-tree индексы: оставляй default 90% для динамичных таблиц, можно поднять до 100 для read-only (после bulk load).
Менять fillfactor у уже существующей таблицы:
ALTER TABLE players SET (fillfactor = 80);
VACUUM FULL players; -- чтобы применилось ко всем существующим страницам
Без VACUUM FULL новый fillfactor применится только к новым страницам.
Чек-лист
UPDATEв Postgres всегда создаёт новый кортеж — in-place updates не существует.- fillfactor задаёт процент страницы, заполняемой при INSERT. Default: 100% для heap, 90% для B-tree.
- HOT update — UPDATE, который (1) не трогает индексированных колонок и (2) помещается на той же странице. Не пишет в индексы.
- HOT-цепочка:
old → newчерез ctid. Старая версия помеченаHEAP_HOT_UPDATED, новая —HEAP_ONLY_TUPLE. - HOT ratio (
n_tup_hot_upd / n_tup_upd) > 80% — здоровая OLTP-таблица. < 50% — диагностируй индексы и fillfactor. - Page pruning автоматически убирает dead tuples из HOT-цепочек при обычных запросах, без VACUUM.
- Изменения fillfactor применяются только к новым страницам; для уже существующих —
VACUUM FULL.