Learning Platform
Урок 05.04 · 23 мин
Продвинутый
HOTHeap Only Tuplefillfactorwrite amplificationindex maintenancectid chain

В прошлых уроках мы выяснили, что любой UPDATE — это INSERT новой версии. Это значит, что каждый UPDATE по умолчанию должен:

  1. Найти место для новой версии (возможно на другой странице).
  2. Записать новый tuple.
  3. Обновить все индексы на таблице — добавить запись с новым ctid.
  4. Старая запись в индексе остаётся (станет «dead» в индексе) — её уберёт VACUUM.

Шаг 3 — это и есть write amplification от индексов. Если у таблицы 5 индексов, то каждый UPDATE затрагивает 6 страниц минимум (heap + 5 индексов). Это огромная цена.

Heap Only Tuple (HOT) — оптимизация, которая в большинстве случаев устраняет шаги 3 и 4. Это одна из самых важных вещей, которые надо понимать про write-нагрузку Postgres.

Условия HOT update

UPDATE становится HOT, если выполнены оба условия:

  1. Ни одна индексированная колонка не изменилась. То есть в SET col1 = ..., col2 = ... все колонки не покрыты ни одним индексом (в т.ч. expression-индексами и partial-индексами).
  2. На той же странице heap есть достаточно свободного места для новой версии.

Если оба выполнены — Postgres делает HOT-обновление: новая версия пишется на той же странице, индексы не трогаются вообще.

Обычный UPDATE vs HOT UPDATE

Слева: обычный UPDATE — новая версия на другой странице, индексы получают новую запись. Справа: HOT — новая версия рядом, индексы продолжают показывать на старый ctid, через который dance ведёт к новому.

Обычный UPDATE
heap page 5старая v1 [xmax=200]
heap page 12новая v2 [xmin=200]
индекс idx_emailновая запись → ctid(12, n)
индекс idx_countryновая запись → ctid(12, n)
итог3-5 страниц write × 5 индексов
HOT UPDATE
heap page 5v1 [xmax=200, HEAP_HOT_UPDATED]
heap page 5v2 [xmin=200, HEAP_ONLY_TUPLE]
индекс idx_emailне трогается — указывает на v1
индекс idx_countryне трогается — указывает на v1
итог1 страница write — без правок индексов

HOT chain: как индекс находит новую версию

Если индекс ссылается на старый ctid (5, 1), а данные «переехали» в (5, 4) на той же странице — откуда Postgres знает, что надо идти дальше?

Ответ: на старом line pointer’е выставлен флаг HEAP_HOT_UPDATED, а в его t_ctid лежит указатель на line pointer следующей версии. Это и есть HOT chain:

HOT chain внутри страницы

Индекс показывает на line pointer 1. Через t_ctid он ведёт к line pointer 4 (новая версия). При следующем UPDATE цепочка может удлиниться: 1 → 4 → 7. Все на одной странице.

страница heap 5
line pointer 1HEAP_HOT_UPDATED, t_ctid → (5, 4)
v1 (старая)[xmin=100, xmax=200]
line pointer 4HEAP_HOT_UPDATED, t_ctid → (5, 7)
v2 (средняя)[xmin=200, xmax=300, HEAP_ONLY_TUPLE]
line pointer 7self-pointer, no chain
v3 (свежая)[xmin=300, HEAP_ONLY_TUPLE]
индексы → ctid(5, 1)всегда. При access Postgres идёт по chain'у до видимой версии.

При чтении: индекс показывает на (5, 1). Postgres читает страницу 5, item 1; видит флаг HEAP_HOT_UPDATED + t_ctid → (5, 4). Идёт туда. Видит, что её xmax = 300 committed → невидима. Видит HEAP_HOT_UPDATED → идёт дальше → (5, 7). Эта видима → возвращает её. Эта цепочка — называется HOT chain, проходится за O(длина цепочки), обычно 1-3 шага.

Огромная выгода

Численно: представь таблицу users с 5 индексами и UPDATE’ами по 100 raw/sec одного и того же last_seen_at (колонка, которая не в индексе).

  • Без HOT: 100 × (1 heap + 5 index) = 600 write-операций/сек, плюс 5 dead tuples в индексах на каждый UPDATE = ~30K dead в час, нужен агрессивный VACUUM.
  • С HOT: 100 × 1 heap = 100 write-операций/сек, индексы не трогаются вообще.

Это 6x меньше write activity, 6x меньше WAL, 5x меньше index bloat. Если ты добавил «случайный» индекс на колонку, которая часто меняется — ты сломал HOT для всех UPDATE’ов, даже тех, что эту колонку не трогают (потому что условие «ни одна колонка не индексирована» уже нарушается). Это прямая причина деградации write-throughput.

fillfactor: оставлять место на странице

Второе условие HOT — наличие места на странице. По умолчанию Postgres заполняет страницу до 90% и оставляет 10% «про запас». Этим управляет fillfactor:

ALTER TABLE customers SET (fillfactor = 80);

fillfactor = 80 означает «при INSERT пиши в страницу до тех пор, пока заполнение не дойдёт до 80%». Оставшиеся 20% — буфер для HOT updates той же странице.

Если таблица «горячая» (много UPDATE’ов того же кортежа) — стоит снизить fillfactor до 70-80. Если таблица append-only (INSERT-heavy, без UPDATE) — оставь default 100 (или ставь явно 100), чтобы максимизировать плотность данных.

Создадим тестовую таблицу с fillfactor=80, сделаем UPDATE и посмотрим pg_stat_user_tables. n_tup_hot_upd должен расти, n_tup_upd тоже, но процент HOT — высокий.

PostgreSQL

n_tup_hot_upd — счётчик HOT-обновлений. n_tup_upd — всех UPDATE’ов. Если их соотношение около 100% — HOT работает идеально. Если 0% — что-то сломано (есть индекс на обновляемой колонке или fillfactor не оставляет места).

Теперь добавим индекс на counter и сделаем UPDATE counter — HOT перестанет работать, n_tup_hot_upd не вырастет.

PostgreSQL

Сравни n_tup_hot_upd до и после: новый UPDATE не пошёл по HOT (counter в индексе), поэтому n_tup_hot_upd не увеличился, а n_tup_upd прирос.

Когда HOT chain «выпрямляется»

HOT chain не растёт бесконечно. Когда транзакции, видевшие старые версии, завершаются — и когда VACUUM проходит — старые HEAP_HOT_UPDATED’ы становятся dead. Их line pointer переводится в специальное состояние LP_DEAD (или LP_REDIRECT для головы цепочки), и место освобождается:

  • LP_REDIRECT: line pointer, на который указывают индексы, превращается в указатель на текущую видимую версию (минуя промежуточные).
  • LP_DEAD: промежуточные dead line pointers помечаются как мёртвые; их можно перепереиспользовать.

Это происходит при следующем page access (если есть, что почистить) — называется single-page pruning или opportunistic vacuum. Это бесплатная операция, которая поддерживает страницы более компактными между full VACUUM’ами.

После page pruning

LP 1 теперь redirect → LP 7. LP 4 помечен LP_DEAD, его место можно использовать для следующего HOT update. Индексы всё ещё ссылаются на (5, 1), но теперь через redirect ведут к (5, 7) за один прыжок.

line pointer 1LP_REDIRECT → (5, 7)
line pointer 4LP_DEAD — место свободно
line pointer 7актуальная версия

Анти-pattern: ленивые индексы

Самый частый способ сломать HOT — добавить индекс «на всякий случай»:

-- BAD: индекс на updated_at колонку, которую часто меняешь
CREATE INDEX ON users (updated_at);

Теперь каждый UPDATE users SET updated_at = now() (а это часто часть application logic — touch the row) превращается из 1-страничной HOT-операции в 6-страничную с обновлением 6 индексов. На 1000 UPDATE/sec это разница между 1000 IOPS и 6000 IOPS.

Правило: индексируй только то, что реально selectable. Если ты сам не написал WHERE updated_at > now() - interval '1 hour' хотя бы раз в месяц — индекс не нужен.

В следующем уроке мы посмотрим на тёмную сторону MVCC — dead tuples, bloat и как его мониторить.

Проверка знанийKnowledge check
У тебя таблица orders с индексами на (customer_id), (placed_at), (status). Какие из UPDATE'ов ниже могут пойти через HOT (при достаточном fillfactor)? 1) UPDATE orders SET status = 'shipped' 2) UPDATE orders SET total_cents = total_cents - 100 3) UPDATE orders SET status = 'delivered', placed_at = now()
ОтветAnswer
Только (2). В (1) меняется status — он в индексе → не HOT. В (3) меняются status и placed_at — оба в индексах → не HOT. В (2) меняется total_cents — он НЕ в индексе → может пойти HOT, если на странице есть место. Это типичный пример: для read-heavy таблицы orders index на status имеет смысл (для частых SELECT WHERE status = 'pending'), но он ломает HOT для всех UPDATE'ов, меняющих status. В реальной БД это причина выбора — оставить индекс ради чтения или убрать ради записи; иногда решается через partial-index только на 'pending' (тогда UPDATE с 'pending' на 'shipped' тоже ломает HOT, но это уже более узкий случай).

Чек-лист

  • HOT update = UPDATE без изменения индексированных колонок + место на той же странице.
  • HOT не трогает индексы вообще — это огромная экономия write и WAL.
  • Цепочка версий помечается флагами HEAP_HOT_UPDATED + HEAP_ONLY_TUPLE; индекс ссылается на голову chain, далее проходим по t_ctid.
  • fillfactor < 100 оставляет место на странице — критично для HOT-friendly таблиц.
  • Лишние индексы ломают HOT: индекс на часто-обновляемой колонке = write amplification × N.
  • Мониторинг: pg_stat_user_tables.n_tup_hot_upd / n_tup_upd — процент HOT updates. Цель — близко к 100%.
Constraints: PRIMARY KEY, FOREIGN KEY, CHECK, UNIQUE
  • HOT chain «выпрямляется» автоматически через single-page pruning при доступе к странице.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. При каких условиях UPDATE может пойти по HOT (Heap Only Tuple) пути?

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

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

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

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