5 Самоочистка и hot-обновления
PostgreSQL 18 · Издание 2026 года · Китайский перевод доступен для PostgreSQL 18
5.1. Самоочистка
При обращении к табличной странице — как при обновлении, так и при обычном чтении — может выполняться быстрая внутристраничная самоочистка1. Это происходит в одном из трех случаев:
Ранее выполненное обновление (
UPDATE) не обнаружило достаточно места, чтобы разместить новую версию строки на этой же странице. Такая ситуация запоминается в заголовке страницы.Табличная страница заполнена больше, чем на 90 %.
Табличная страница заполнена больше, чем на значение параметра хранения fillfactor.
PostgreSQL вставляет (INSERT) новую строку на страницу, только если эта страница заполнена менее, чем на fillfactor процентов. Остальное место приберегается для новых версий строк, которые получаются в результате обновлений (UPDATE). Со значением по умолчанию место в таблицах не резервируется. (по умолчанию: 100)
Самоочистка убирает со страницы версии строк, не видимые ни в одном снимке (находящиеся за горизонтом базы данных). Она никогда не выходит за одну табличную страницу, зато выполняется очень быстро. Указатели на вычищенные версии строк не освобождаются, так как на них могут вести ссылки из индексов, а индекс — это уже другая страница. Из этих же соображений не обновляются ни карта видимости, ни карта свободного пространства (получается, что освобожденное место используется для обновлений, а не для вставок). (с. 106)
Тот факт, что страница может очищаться при чтении, означает, что оператор SELECT может вызвать изменение страниц. Это еще один такой случай в дополнение к отложенному изменению битов-подсказок. (с. 86)
Посмотрим на примере, как работает самоочистка. Создадим таблицу с двумя столбцами и индексы по каждому из них:
Если в столбце s хранить только латинские буквы, каждая версия строки будет иметь фиксированный размер 2004 байта, не считая 24 байт заголовка. Параметр хранения fillfactor установлен в 75 %. Значит, места на странице будет хватать на четыре версии строки, но вставить получится только три.
Теперь вставим одну строку и несколько раз изменим ее:
В странице сейчас четыре версии строки:
Как и ожидалось, мы только что вышли за fillfactor. На это указывает разница между значениями pagesize и upper — она превышает порог в 75 % от размера страницы, составляющий 6144 байта: (с. 75)
При следующем обращении к странице срабатывает самоочистка, которая вычищает все неактуальные версии. Теперь освободившегося места хватает на новую версию (0,5):
Версии строк, оставшиеся после очистки, физически сдвигаются в сторону старших адресов страницы так, чтобы все свободное место было представлено одним непрерывным фрагментом. Соответствующим образом изменяются и указатели. Благодаря этому не возникает проблем с фрагментацией свободного места в странице.
Указатели на удаленные версии строк пока нельзя освободить, поскольку на них ведут ссылки c индексной страницы; статус таких указателей меняется с normal на dead («мертвая» версия). Заглянем в первую страницу индекса hot_s (нулевая занята метаинформацией): (с. 529)
Ту же картину мы увидим и в другом индексе:
При индексном доступе сервер может получить (0,1), (0,2) или (0,3) в качестве идентификатора версии строки. Тогда он попробует прочитать соответствующую версию строки из табличной страницы, но обнаружит, что указатель имеет статус dead, то есть версия уже не существует и должна игнорироваться. А заодно изменит статус указателя и в индексной странице, чтобы повторно не обращаться к таблице2.
Расширим функцию для просмотра индексной страницы, добавив признак «мертвого» указателя: (v. 13)
Пока все указатели на индексной странице активны. Но при первом же обращении к таблице по индексу статус указателей меняется:
Хотя табличная строка, на которую ссылается четвертый указатель, еще не вычищена и имеет статус normal, она уже ушла за горизонт базы данных. Поэтому и четвертый указатель в индексе помечен как мертвый.
5.2. Hot-обновления
Держать в индексе ссылки на все версии каждой строки неэффективно.
Во-первых, при любом изменении строки приходится обновлять все индексы, созданные для таблицы: раз появилась новая версия, необходимо иметь на нее ссылки из каждого индекса, даже если изменились поля, в этот индекс не входящие.
Во-вторых, в индексах накапливаются ссылки на исторические версии строк, которые потом приходится вычищать вместе с самими версиями. (с. 125)
Естественно, чем больше индексов создано на таблице, тем с большими сложностями приходится сталкиваться.
Однако если в результате обновления изменились только столбцы, не входящие ни в один индекс, то нет смысла создавать в индексах дополнительную запись, содержащую те же самые значения.
С появлением лишних индексных записей борется оптимизация, называемая hot-обновлением 3 (Heap-Only Tuple update). Такое обновление возможно в трех случаях:
на таблице не создан ни один индекс (в этом случае любое обновление будет hot-обновлением);
на таблице есть индексы, но обновляемые столбцы не используются ни в одном из них;
обновляемые столбцы используются в индексе, но их значения не изменились.
Считается, что столбец используется в индексе, если он хоть как-то упоминается в команде CREATE INDEX: либо индекс построен по самому столбцу или по выражению, содержащему этот столбец, либо столбец входит в include-индекс как неключевой. Исключение составляют лишь индексы BRIN: в них нет ссылок на табличные строки, и поэтому они никак не мешают hot-обновлениям. (v. 16) (с. 642)
При hot-обновлении в индексной странице присутствует лишь одна запись, и она ссылается на самую первую версию табличной строки. Все остальные версии той же строки внутри табличной страницы связаны в цепочку указателями ctid в заголовках версий.
Версии, на которые нет ссылок из индекса, маркируются битом Heap-Only Tuple («только табличная версия строки»). Если же версия входит в цепочку, она помечается битом Heap Hot Updated.
Если при сканировании индекса сервер попадает в табличную страницу и обнаруживает версию с флагом Heap Hot Updated, он понимает, что не надо останавливаться, и проходит дальше по всей цепочке обновлений. Разумеется, для всех прочитанных таким образом версий строк проверяется видимость, прежде чем клиент получит результат.
Чтобы посмотреть на работу hot-обновления, удалим один индекс и опустошим таблицу.
Для удобства пересоздадим функцию heap_page, дополнив вывод тремя полями — ctid и двумя битами, относящимися к hot-обновлению:
Повторяем вставку и обновление строки:
В странице появилась hot-цепочка изменений:
флаг Heap Hot Updated (
hhu) говорит о том, что надо идти дальше по цепочкеctid;флаг Heap Only Tuple (
hot) показывает, что на данную версию строки нет ссылок из индексов.
При дальнейших изменениях цепочка будет расти — но только в пределах страницы:
При этом в индексе обнаруживаем одну-единственную ссылку на «голову» цепочки:
Цепочка hot-обновлений не выходит за рамки страницы, поэтому обход всей цепочки никогда не требует обращения к другим страницам и не ухудшает производительность.
Тип обновлений отслеживается как часть статистики использования таблицы. В данном случае из семи операций UPDATE три были hot-обновлениями:
5.3. Самоочистка при hot-обновлениях
Частный, но важный случай представляет собой самоочистка при hot-обновлениях.
В начатом примере уже превышен порог fillfactor, так что следующее обновление должно привести к самоочистке. Но на этот раз в странице находится цепочка обновлений. «Голова» этой hot-цепочки всегда должна оставаться на своем месте, поскольку на нее ссылается индекс, а остальные указатели могут быть освобождены, ведь на них точно нет ссылок извне.
Чтобы не трогать «голову», применяется двойная адресация: указатель, на который ссылается индекс, — в данном случае (0,1) — получает статус redirect и ведет на версию строки, с которой в данный момент начинается цепочка:
Здесь версии (0,1), (0,2) и (0,3) были вычищены, «головной» указатель 1 остался перенаправлять доступ, а указатели 2 и 3 были освобождены (получили статус unused), поскольку на эти версии гарантированно не было ссылок из индексов. Новая версия строки была записана на освободившееся место под номером (0,2).
Выполним обновление еще несколько раз:
Hot-цепочка выросла:
Следующее обновление снова вызывает самоочистку:
Часть версий вычищена, а указатель на «голову» соответствующим образом сдвинут. Если продолжать обновления, в этом (удачно подобранном) примере все версии строк так и будут «крутиться» в пределах одной страницы.
5.4. Разрыв hot-цепочки
Если на странице не хватит свободного места, чтобы разместить новую версию строки, цепочка прервется. На версию строки, размещенную на другой странице, придется сделать отдельную ссылку из индекса.
Чтобы получить такую ситуацию, начнем параллельную транзакцию и построим в ней снимок данных, который не даст вычищать версии строк:
Теперь выполним обновления в первом сеансе:
Пока все версии помещаются на одной странице:
При следующем обновлении места на странице уже не хватит, а самоочистка не сможет ничего освободить:
В версии (0,5) видим ссылку (1,1), ведущую на страницу 1:
Впрочем, эта ссылка не используется, поскольку в версии (0,5) не установлен бит Heap Hot Updated. А на версию (1,1) можно попасть из индекса, в котором теперь две ссылки. Каждая из них ведет на начало своей hot-цепочки:
Разрывы hot-цепочек отслеживаются в статистике отдельно: (v. 16)
Здесь из пятнадцати операций UPDATE одиннадцать были hot-обновлениями: десяти хватило места на странице, а одной нет, что привело к разрыву цепочки.
При частых обновлениях столбцов, не входящих в индексы, может иметь смысл уменьшить параметр fillfactor, чтобы зарезервировать на странице достаточно места для обновлений. Конечно, надо учитывать, что чем ниже fillfactor, тем больше остается незанятого места на странице, и, соответственно, физический размер таблицы увеличивается.
5.5. Самоочистка индексов
Я говорил о том, что самоочистка работает с одной табличной страницей и не трогает индексы. Но и для индексов есть собственная самоочистка4, которая тоже работает с одной (но уже индексной) страницей.
Такая очистка происходит, когда при вставке строки в B-дерево на индексной странице не оказывается места и ее требуется расщепить (split), перераспределив данные между двумя новыми страницами. Проблема в том, что при удалении записей две индексные страницы уже не «склеиваются» в одну. Это приводит к раздуванию индекса, а однажды выросший размер может не уменьшиться даже при удалении существенной части данных. Но если самоочистке удается убрать часть индексных записей, момент расщепления может быть отложен.
Есть два вида записей, которые самоочистка может удалить из индекса.
В первую очередь удаляются записи, помеченные ранее как мертвые5. Как я уже говорил, такая пометка ставится при индексном доступе, если оказывается, что индексная запись ссылается на версию строки, которая не видна ни в одном снимке или уже не существует.
Если заведомо мертвых записей не нашлось, проверяются строки, ссылающиеся на разные версии одной и той же табличной строки 6. Из-за многоверсионности при обновлениях может возникать большое количество неактуальных версий строк, которые, скорее всего, достаточно быстро уйдут за горизонт базы данных. Hot-обновления смягчают этот эффект, но работают не всегда: если обновляемый столбец входит в какой-нибудь индекс, ссылки на все версии строк появляются и во всех остальных индексах. Перед расщеплением страницы имеет смысл поискать в ней записи, еще не помеченные как мертвые, но уже допускающие очистку. Для этого надо проверить видимость соответствующих версий строк, что требует обращения к табличной странице. Поэтому рассматриваются не все, а только «перспективные» индексные записи, появившиеся как копия существующих для нужд многоверсионности. Заплатить цену проверки видимости таких записей оказывается выгоднее, чем допустить лишнее расщепление индексной страницы. (v. 14)
backend/access/heap/pruneheap.c, функция heap_page_prune_opt. ↩︎
backend/access/index/indexam.c, функция index_fetch_heap. ↩︎
backend/access/nbtree/README, раздел Simple deletion. ↩︎
backend/access/nbtree/README, раздел Bottom-Up deletion; include/access/tableam.h. ↩︎