В прошлых уроках мы выяснили, что любой UPDATE — это INSERT новой версии. Это значит, что каждый UPDATE по умолчанию должен:
- Найти место для новой версии (возможно на другой странице).
- Записать новый tuple.
- Обновить все индексы на таблице — добавить запись с новым ctid.
- Старая запись в индексе остаётся (станет «dead» в индексе) — её уберёт VACUUM.
Шаг 3 — это и есть write amplification от индексов. Если у таблицы 5 индексов, то каждый UPDATE затрагивает 6 страниц минимум (heap + 5 индексов). Это огромная цена.
Heap Only Tuple (HOT) — оптимизация, которая в большинстве случаев устраняет шаги 3 и 4. Это одна из самых важных вещей, которые надо понимать про write-нагрузку Postgres.
Условия HOT update
UPDATE становится HOT, если выполнены оба условия:
- Ни одна индексированная колонка не изменилась. То есть в
SET col1 = ..., col2 = ...все колонки не покрыты ни одним индексом (в т.ч. expression-индексами и partial-индексами). - На той же странице heap есть достаточно свободного места для новой версии.
Если оба выполнены — Postgres делает HOT-обновление: новая версия пишется на той же странице, индексы не трогаются вообще.
Слева: обычный UPDATE — новая версия на другой странице, индексы получают новую запись. Справа: HOT — новая версия рядом, индексы продолжают показывать на старый ctid, через который dance ведёт к новому.
HOT chain: как индекс находит новую версию
Если индекс ссылается на старый ctid (5, 1), а данные «переехали» в (5, 4) на той же странице — откуда Postgres знает, что надо идти дальше?
Ответ: на старом line pointer’е выставлен флаг HEAP_HOT_UPDATED, а в его t_ctid лежит указатель на line pointer следующей версии. Это и есть HOT chain:
Индекс показывает на line pointer 1. Через t_ctid он ведёт к line pointer 4 (новая версия). При следующем UPDATE цепочка может удлиниться: 1 → 4 → 7. Все на одной странице.
При чтении: индекс показывает на (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 — высокий.
n_tup_hot_upd — счётчик HOT-обновлений. n_tup_upd — всех UPDATE’ов. Если их соотношение около 100% — HOT работает идеально. Если 0% — что-то сломано (есть индекс на обновляемой колонке или fillfactor не оставляет места).
Теперь добавим индекс на counter и сделаем UPDATE counter — HOT перестанет работать, n_tup_hot_upd не вырастет.
Сравни 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’ами.
LP 1 теперь redirect → LP 7. LP 4 помечен LP_DEAD, его место можно использовать для следующего HOT update. Индексы всё ещё ссылаются на (5, 1), но теперь через redirect ведут к (5, 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 и как его мониторить.
Чек-лист
- 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%.
- HOT chain «выпрямляется» автоматически через single-page pruning при доступе к странице.