13.1 Цена обычного UPDATE
Возьмём таблицу с индексом и обновим неиндексируемое поле. Что должно произойти по той модели, которую мы построили раньше?
CREATE TABLE acc (id int PRIMARY KEY, balance int, note text);
CREATE INDEX acc_note_idx ON acc (note);
INSERT INTO acc VALUES (1, 100, 'alice');
UPDATE acc SET balance = 200 WHERE id = 1;
Наивно: появилась новая версия строки с новым ctid. Значит, оба
индекса - и acc_pkey, и acc_note_idx - должны получить новую
запись, указывающую на этот ctid. Два индекса на одно обновление,
хотя ни id, ни note не менялись.
Это и была боль ранних версий: таблица с пятью индексами платила пятью записями в индексы за каждый UPDATE, даже когда менялось одно число. Индексы росли, как на дрожжах, и VACUUM едва успевал.
Логика подсказывает: раз id и note те же, старые записи в
индексах всё ещё корректны - они и так указывают «куда-то в эту
строку». Надо только научить индекс находить актуальную версию,
пройдя по цепочке внутри страницы. Именно это и делает HOT.
13.2 HOT: обновление без записи в индекс
HOT-обновление срабатывает при двух условиях одновременно:
- ни одна колонка, входящая хоть в один индекс, не изменилась;
- новая версия строки помещается в ту же страницу, где лежит старая.
Когда оба выполнены, PostgreSQL не пишет в индексы ничего. Новая
версия живёт в той же странице, а старая получает пометку: «иди
дальше по ctid». Получается цепочка версий внутри страницы -
HOT-chain.
Индекс по-прежнему указывает на самую первую (корневую) версию.
Когда мы читаем строку через индекс, PostgreSQL приходит на корень
цепочки и идёт по ctid до версии, видимой нашему снимку. Один
лишний шаг по странице вместо записи в N индексов - выгодный обмен.
Проверить, что обновление было HOT, можно по счётчику:
SELECT n_tup_upd, n_tup_hot_upd
FROM pg_stat_all_tables WHERE relname = 'acc';
Если n_tup_hot_upd вырос вместе с n_tup_upd - индексы не
тронуты. Подробный разбор условий и счётчиков - в hot-updates.
13.3 Кто разрывает HOT-цепочку
HOT хрупок. Достаточно изменить любую индексируемую колонку - и обновление перестаёт быть HOT, новая версия получает свои записи во всех индексах.
-- HOT: note в индексе, но мы его не трогаем
UPDATE acc SET balance = 300 WHERE id = 1;
-- НЕ HOT: note входит в acc_note_idx и меняется
UPDATE acc SET note = 'alice2' WHERE id = 1;
Второй UPDATE не может быть HOT, потому что новая запись в
acc_note_idx обязана появиться - старый ключ 'alice' больше не
ведёт к актуальной версии.
Отсюда практический вывод, к которому мы вернёмся в главе про проектирование индексов: чем больше колонок вы покрываете индексами, тем реже обновления остаются HOT и тем дороже каждая запись. Индекс на часто меняющуюся колонку - это плата не только за место, но и за потерю HOT.
13.4 Подводный камень: HOT-цепочка ломается даже без индекса на колонке
Можно подумать: «у меня нет индекса на balance, значит UPDATE
balance всегда HOT». Не всегда. Второе условие - помещается ли
новая версия в ту же страницу - не зависит от индексов вовсе.
Если страница заполнена под завязку, новой версии просто негде
разместиться рядом. PostgreSQL кладёт её в другую страницу, и тогда
HOT невозможен по определению: цепочка не может пересекать границу
страницы. Индексам приходится получить новую запись с ctid из
другой страницы.
В PostgreSQL 16 для такого случая завели отдельный счётчик
n_tup_newpage_upd - «обновление ушло в новую страницу». Если он
растёт, а n_tup_hot_upd стоит, значит страницам не хватает места
под обновления. Лечится это fillfactor - о нём следующий раздел.
13.5 fillfactor: оставить место под обновления
По умолчанию PostgreSQL набивает страницу таблицы данными
полностью - fillfactor = 100. Логично для таблицы, которую только
читают: ни байта впустую. Но для таблицы с частыми UPDATE это
означает, что почти любое обновление выталкивает новую версию в
другую страницу - и HOT не работает.
fillfactor говорит: «заполняй страницу только на N процентов, а
остаток придержи под будущие версии».
ALTER TABLE acc SET (fillfactor = 75);
-- новые страницы заполняются на 75%, 25% в резерве под HOT
Значение 75-90 - типичный выбор для часто обновляемых таблиц. Чем
больше резерв, тем чаще обновления остаются HOT, но тем больше
места таблица занимает на диске «вхолостую». Это компромисс, и
настраивают его под конкретную нагрузку, измеряя hot_update_ratio.
SELECT relname,
n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1)
AS hot_ratio_pct
FROM pg_stat_all_tables WHERE relname = 'acc';
Копнуть глубже:
fillfactorне трогает существующие страницы.ALTER TABLE ... SET (fillfactor = ...)влияет только на страницы, которые PostgreSQL заполняет после изменения. Уже плотно набитые страницы останутся такими, пока их не перепишетVACUUM FULLилиCLUSTER. Поэтому эффект от сниженияfillfactorна живой таблице проявляется постепенно, по мере того как страницы перерабатываются.
13.6 Внутристраничная очистка (prune)
HOT-цепочки не растут бесконечно. Если строку обновить десять раз подряд в одной странице, образуется цепочка из десяти версий, из которых актуальна одна, а девять - мусор. Места под новые версии скоро не останется.
Здесь включается второй механизм - внутристраничная очистка, или prune. Это лёгкая, локальная уборка: она затрагивает ровно одну страницу и не лезет в индексы. PostgreSQL запускает её оппортунистически - прямо во время чтения или обновления страницы, если видит, что та забита мёртвыми версиями.
Prune проходит по HOT-цепочкам страницы и выкидывает версии, которые уже не видны ни одному снимку. Освободившееся место возвращается в страницу под новые версии. При этом указатели (line pointers) перестраиваются: вместо длинной цепочки остаётся короткая, а ненужные указатели помечаются как мёртвые или переиспользуются.
Важное ограничение: prune убирает мусор внутри страницы, но не может тронуть индексы - для этого нужен полноценный VACUUM (глава 14). Поэтому указатель, на который ещё может ссылаться индекс, prune не удаляет, а превращает в redirect.
13.7 Redirect-указатель: как индекс не теряет строку
Вот тонкость, ради которой существует prune в его нынешнем виде.
Индекс ссылается на корневую версию HOT-цепочки по её ctid,
например (0,1). Если prune просто удалит корневую версию как
мусор, индекс начнёт указывать в пустоту.
Чтобы этого не случилось, корневой указатель не удаляется, а
превращается в redirect: line pointer с флагом LP_REDIRECT,
который сам не ведёт к данным, а перенаправляет на следующий живой
указатель в цепочке.
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT lp, lp_flags, t_ctid
FROM heap_page_items(get_raw_page('acc', 0));В выводе lp_flags = 2 (LP_REDIRECT) - это и есть
перенаправляющий указатель. Индекс приходит на него, видит
«не здесь, иди на указатель N» и идёт дальше по цепочке. Так
физический мусор убран, место освобождено, а индекс по-прежнему
находит актуальную версию, не зная, что внутри страницы всё
переехало. Значения lp_flags подробно - в heap-pruning.
13.8 Как всё это сходится
Сложим картину. Три механизма работают вместе, чтобы UPDATE не разорял индексы и страницы не пухли:
fillfactor управляет веткой «есть место в странице»: оставляя
резерв, мы держим обновления в HOT-русле. Prune не даёт цепочкам
переполнить страницу. А индексы остаются нетронутыми, пока мы не
трогаем индексируемые колонки.
Чего этот тандем не умеет - убирать мусор, на который ссылаются индексы из других страниц, и продвигать горизонт заморозки. Это работа VACUUM, и ей посвящены следующие три главы.
Уроки в sandbox
lab-13.1. HOT и fillfactor своими глазами
Соберём таблицу с индексом и сделаем два вида обновлений. Один
UPDATE оставим HOT, другой сломаем, и сверим счётчики
pg_stat_all_tables. Потом снизим fillfactor и увидим, как
обновления возвращаются в HOT-русло.
Создай таблицу
acc(id int primary key, balance int, note text)и индекс поnote; вставь одну строку.Сбрось статистику:
SELECT pg_stat_reset();- чтобы счётчики считались с нуля.Обнови
balance(неиндексируемая колонка) и посмотриn_tup_hot_updвpg_stat_all_tables- предскажи заранее, вырастет ли он.Обнови
note(индексируемая колонка) и снова сверь счётчики - этот UPDATE не должен быть HOT.Вскрой страницу через
heap_page_items(get_raw_page('acc', 0))и найдиlp_flagsredirect-указателя после серии обновлений.Поставь
fillfactor = 70, перезалей данные, прогони серию UPDATE и сравниhot_update_ratioс прежним.
sandbox с автопроверкой - открыть в песочнице
Резюме
- UPDATE в PostgreSQL создаёт новую версию строки; без HOT каждая такая версия требует записи во все индексы таблицы.
- HOT-обновление не пишет в индексы, если ни одна индексируемая колонка не изменилась и новая версия помещается в ту же страницу.
- Версии в пределах страницы образуют HOT-цепочку; индекс указывает на её корень и проходит по `ctid` до видимой версии.
- Любое изменение индексируемой колонки или нехватка места в странице делает обновление не-HOT (`n_tup_newpage_upd` в PG16+).
- `fillfactor` ниже 100 оставляет в странице резерв под новые версии и удерживает обновления в HOT-русле; влияет только на новые страницы.
- Внутристраничный prune оппортунистически убирает мёртвые версии в одной странице при чтении/записи, не трогая индексы.
- Корень HOT-цепочки при prune не удаляется, а становится redirect-указателем (`LP_REDIRECT`), чтобы индекс не потерял строку.
Контрольные вопросы
При каких двух условиях UPDATE остаётся HOT-обновлением?
Показать ответ
Первое: ни одна колонка, входящая хотя бы в один индекс таблицы, не изменилась этим обновлением. Второе: новая версия строки помещается в ту же страницу, где лежит старая. Если выполнены оба - индексы не получают новых записей, версии выстраиваются в HOT-цепочку внутри страницы. Нарушено любое - обновление становится обычным, со вставкой во все индексы.
Почему индекс на часто обновляемую колонку обходится дороже, чем кажется?
Показать ответ
Дело не только в месте под сам индекс и не только в записи новой индексной строки. Как только колонка попадает в индекс, любой UPDATE этой колонки перестаёт быть HOT - а значит, новую версию строки получают все индексы таблицы, не только тот, где колонка состоит. То есть один «лишний» индекс на горячую колонку увеличивает стоимость каждого её обновления на запись во все остальные индексы.
Зачем prune превращает корень HOT-цепочки в redirect, а не удаляет его?
Показать ответ
Потому что на корневой
ctidссылаются индексы из других страниц, и prune не имеет права их трогать (он работает в пределах одной страницы). Если бы корень просто удалили, индексная запись стала бы указывать в пустоту. Redirect (LP_REDIRECT) сохраняет точку входа: индекс приходит на неё и перенаправляется на актуальную версию. Полностью убрать такой указатель сможет только VACUUM, который сначала вычистит ссылку на него из индексов.Я снизил fillfactor до 70 на большой живой таблице, но HOT-доля почти не выросла. Почему?
Показать ответ
fillfactorдействует только на страницы, которые PostgreSQL заполняет после изменения настройки. Уже существующие, плотно набитые страницы остаются такими же - резерва в них не появляется, пока их физически не перепишут (VACUUM FULL,CLUSTERили естественное обновление с переездом строк). Поэтому на живой таблице эффект приходит постепенно. Чтобы увидеть его сразу, таблицу нужно переписать.Чем внутристраничный prune отличается от VACUUM?
Показать ответ
Prune - лёгкая локальная уборка одной страницы, запускается оппортунистически прямо при чтении или записи и не трогает индексы. Он убирает мёртвые версии внутри страницы и перестраивает указатели. VACUUM - полноценный проход: он сканирует таблицу (и индексы), удаляет указатели на мёртвые версии из индексов, освобождает redirect-указатели, обновляет карту видимости и продвигает горизонт заморозки. Prune откладывает необходимость VACUUM, но не заменяет его.