Learning Platform
Урок 02.03 · 22 мин
Продвинутый
fillfactorHOTUPDATE internalsMVCCIndex maintenance

В первых двух уроках мы посмотрели на статическую картину: страница, кортежи, заголовки. Теперь — на динамику. Что происходит, когда ты делаешь UPDATE?

В Postgres UPDATE — это никогда не in-place. Из-за MVCC старая версия должна остаться видимой для транзакций, читающих старый snapshot. Поэтому каждый UPDATE физически создаёт новую копию строки и помечает старую как удалённую (xmax = текущая транзакция). В типичной OLTP-нагрузке это означает миллионы новых tuples в день, и без двух механизмов — fillfactor и HOT — производительность катастрофически проседала бы.

fillfactor: «оставь место для будущих UPDATE’ов»

fillfactor
— это параметр уровня таблицы или индекса, который задаёт максимальный процент страницы, заполняемый при первичной вставке. Значение 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:

PostgreSQL

counters_loose ожидаемо больше — мы намеренно тратим 30% места. Это инвестиция, которая окупится при апдейтах. Посмотрим как.

HOT: Heap Only Tuple

Главная проблема UPDATE’а в Postgres — это не сам новый кортеж в heap. Это то, что каждый индекс на таблице тоже должен получить новую запись, указывающую на новый ctid. Если у тебя 5 индексов на таблице, один UPDATE одной строки = 1 запись в heap + 5 записей в индексах. Плюс bloat в каждом индексе.

HOT
(Heap Only Tuple) — это оптимизация PostgreSQL, которая позволяет обновлять строку без обновления индексов. Условия:

  1. Ни одна индексированная колонка не меняется в этом UPDATE.
  2. Новая версия помещается на ту же страницу, что и старая.

Если оба выполнены — Postgres:

  • создаёт новый кортеж на той же странице;
  • связывает его со старым через ctid старой версии: old.ctid → new.ctid;
  • помечает старый как HEAP_HOT_UPDATED, новый — как HEAP_ONLY_TUPLE;
  • никакой записи в индексы не делает.

Когда индекс пытается найти строку, он попадает на старый ctid, видит флаг HOT, и идёт по цепочке ctid → ctid → ctid, пока не найдёт живую версию. Это называется HOT chain.

HOT update внутри одной страницы

UPDATE не меняет индексированных колонок и помещается на ту же страницу — индексы не трогаются.

страница 0 (heap)line pointers и tuples
(0,1) HOT_UPDATEDhits=10, ctid → (0,8)
(0,8) HEAP_ONLY_TUPLEhits=11 (новая версия)
индекс на idуказывает на (0,1) — не обновлялся!
экономиянет записи в индекс, нет bloat

Видишь экономию? 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'ах одной строки:

PostgreSQL

В реальной БД (без 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 (по не-индексируемой колонке), посмотрим на счётчики:

PostgreSQL

Соотношение 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 применится только к новым страницам.

Проверка знанийKnowledge check
У тебя OLTP-таблица user_sessions со столбцами id (PK), user_id (FK + индекс), last_seen (timestamptz, без индекса), heartbeat_count (int, без индекса). Большая часть нагрузки — это UPDATE last_seen и heartbeat_count раз в секунду на тысячах сессий. fillfactor сейчас default (100), и таблица заметно растёт. Какие два изменения дадут наибольший эффект на HOT ratio и общий размер?
ОтветAnswer
Первое: установить fillfactor 70-80 — это даст место на странице для in-page UPDATE'ов и включит HOT, поскольку обновляемые колонки (last_seen, heartbeat_count) не индексируются. Команда: ALTER TABLE user_sessions SET (fillfactor = 75); VACUUM FULL user_sessions. Второе: убедиться, что нет лишних индексов на user_id, last_seen или heartbeat_count — каждый индекс на изменяемой колонке мгновенно убивает HOT. После применения мониторить n_tup_hot_upd / n_tup_upd: должен подняться до 80%+, и таблица перестанет так быстро расти из-за того, что dead tuples будут убираться page pruning при следующем SELECT/UPDATE той же страницы, без участия VACUUM.
Антипаттерны: что убивает перфоманс Денормализация и широкие таблицы в ClickHouse

Чек-лист

  • 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.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какие два условия должны выполняться, чтобы UPDATE был HOT?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5