Перейти к содержимому

5 Самоочистка и hot-обновления

PostgreSQL 18 · Издание 2026 года · Китайский перевод доступен для PostgreSQL 18

5.1. Самоочистка

При обращении к табличной странице — как при обновлении, так и при обычном чтении — может выполняться быстрая внутристраничная самоочистка1. Это происходит в одном из трех случаев:

  • Ранее выполненное обновление (UPDATE) не обнаружило достаточно места, чтобы разместить новую версию строки на этой же странице. Такая ситуация запоминается в заголовке страницы.

  • Табличная страница заполнена больше, чем на 90 %.

  • Табличная страница заполнена больше, чем на значение параметра хранения fillfactor.

PostgreSQL вставляет (INSERT) новую строку на страницу, только если эта страница заполнена менее, чем на fillfactor процентов. Остальное место приберегается для новых версий строк, которые получаются в результате обновлений (UPDATE). Со значением по умолчанию место в таблицах не резервируется. (по умолчанию: 100)

Самоочистка убирает со страницы версии строк, не видимые ни в одном снимке (находящиеся за горизонтом базы данных). Она никогда не выходит за одну табличную страницу, зато выполняется очень быстро. Указатели на вычищенные версии строк не освобождаются, так как на них могут вести ссылки из индексов, а индекс — это уже другая страница. Из этих же соображений не обновляются ни карта видимости, ни карта свободного пространства (получается, что освобожденное место используется для обновлений, а не для вставок). (с. 106)

Тот факт, что страница может очищаться при чтении, означает, что оператор SELECT может вызвать изменение страниц. Это еще один такой случай в дополнение к отложенному изменению битов-подсказок. (с. 86)

Посмотрим на примере, как работает самоочистка. Создадим таблицу с двумя столбцами и индексы по каждому из них:

=> CREATE TABLE hot(id integer, s char(2000)) WITH (fillfactor = 75);
=> CREATE INDEX hot_id ON hot(id);
=> CREATE INDEX hot_s ON hot(s);

Если в столбце s хранить только латинские буквы, каждая версия строки будет иметь фиксированный размер 2004 байта, не считая 24 байт заголовка. Параметр хранения fillfactor установлен в 75 %. Значит, места на странице будет хватать на четыре версии строки, но вставить получится только три.

Теперь вставим одну строку и несколько раз изменим ее:

=> INSERT INTO hot VALUES (1, 'A');
=> UPDATE hot SET s = 'B';
=> UPDATE hot SET s = 'C';
=> UPDATE hot SET s = 'D';

В странице сейчас четыре версии строки:

=> SELECT * FROM heap_page('hot',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+-------
 (0,1) | normal | 820 c | 821 c
 (0,2) | normal | 821 c | 822 c
 (0,3) | normal | 822 c | 823
 (0,4) | normal | 823   | 0 a
(4 rows)

Как и ожидалось, мы только что вышли за fillfactor. На это указывает разница между значениями pagesize и upper — она превышает порог в 75 % от размера страницы, составляющий 6144 байта: (с. 75)

=> SELECT upper, pagesize FROM page_header(get_raw_page('hot',0));
 upper | pagesize
-------+----------
    64 |     8192
(1 row)

При следующем обращении к странице срабатывает самоочистка, которая вычищает все неактуальные версии. Теперь освободившегося места хватает на новую версию (0,5):

=> UPDATE hot SET s = 'E';
=> SELECT * FROM heap_page('hot',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+------
 (0,1) | dead   |       |
 (0,2) | dead   |       |
 (0,3) | dead   |       |
 (0,4) | normal | 823 c | 824
 (0,5) | normal | 824   | 0 a
(5 rows)

Версии строк, оставшиеся после очистки, физически сдвигаются в сторону старших адресов страницы так, чтобы все свободное место было представлено одним непрерывным фрагментом. Соответствующим образом изменяются и указатели. Благодаря этому не возникает проблем с фрагментацией свободного места в странице.

Указатели на удаленные версии строк пока нельзя освободить, поскольку на них ведут ссылки c индексной страницы; статус таких указателей меняется с normal на dead («мертвая» версия). Заглянем в первую страницу индекса hot_s (нулевая занята метаинформацией): (с. 529)

=> SELECT * FROM index_page('hot_s',1);
 itemoffset | htid
------------+-------
          1 | (0,1)
          2 | (0,2)
          3 | (0,3)
          4 | (0,4)
          5 | (0,5)
(5 rows)

Ту же картину мы увидим и в другом индексе:

=> SELECT * FROM index_page('hot_id',1);
 itemoffset | htid
------------+-------
          1 | (0,1)
          2 | (0,2)
          3 | (0,3)
          4 | (0,4)
          5 | (0,5)
(5 rows)

При индексном доступе сервер может получить (0,1), (0,2) или (0,3) в качестве идентификатора версии строки. Тогда он попробует прочитать соответствующую версию строки из табличной страницы, но обнаружит, что указатель имеет статус dead, то есть версия уже не существует и должна игнорироваться. А заодно изменит статус указателя и в индексной странице, чтобы повторно не обращаться к таблице2.

Расширим функцию для просмотра индексной страницы, добавив признак «мертвого» указателя: (v. 13)

=> DROP FUNCTION index_page(text, integer);
=> CREATE FUNCTION index_page(relname text, pageno integer)
RETURNS TABLE(itemoffset smallint, htid tid, dead boolean)
AS $$
SELECT itemoffset,
       htid,
       dead -- начиная с v.13
FROM bt_page_items(relname,pageno);
$$ LANGUAGE sql;
=> SELECT * FROM index_page('hot_id',1);
 itemoffset | htid  | dead
------------+-------+------
          1 | (0,1) | f
          2 | (0,2) | f
          3 | (0,3) | f
          4 | (0,4) | f
          5 | (0,5) | f
(5 rows)

Пока все указатели на индексной странице активны. Но при первом же обращении к таблице по индексу статус указателей меняется:

=> EXPLAIN (analyze, costs off, timing off, summary off, buffers off)
SELECT * FROM hot WHERE id = 1;
                        QUERY PLAN
-----------------------------------------------------------
 Index Scan using hot_id on hot (actual rows=1.00 loops=1)
   Index Cond: (id = 1)
   Index Searches: 1
(3 rows)
=> SELECT * FROM index_page('hot_id',1);
 itemoffset | htid  | dead
------------+-------+------
          1 | (0,1) | t
          2 | (0,2) | t
          3 | (0,3) | t
          4 | (0,4) | t
          5 | (0,5) | f
(5 rows)

Хотя табличная строка, на которую ссылается четвертый указатель, еще не вычищена и имеет статус 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-обновления, удалим один индекс и опустошим таблицу.

=> DROP INDEX hot_s;
=> TRUNCATE TABLE hot;

Для удобства пересоздадим функцию heap_page, дополнив вывод тремя полями — ctid и двумя битами, относящимися к hot-обновлению:

=> DROP FUNCTION heap_page(text,integer);
=> CREATE FUNCTION heap_page(
  relname text,
  pageno integer
)
RETURNS TABLE(
  ctid tid,
  state text,
  xmin text,
  xmax text,
  hhu text,
  hot text,
  t_ctid tid
)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to '||lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin || CASE
         -- f пригодится позже
         WHEN (t_infomask & 256+512) = 256+512 THEN ' f'
         WHEN (t_infomask & 256) > 0 THEN ' c'
         WHEN (t_infomask & 512) > 0 THEN ' a'
         ELSE ''
       END AS xmin,
       t_xmax || CASE
         WHEN (t_infomask & 1024) > 0 THEN ' c'
         WHEN (t_infomask & 2048) > 0 THEN ' a'
         ELSE ''
       END AS xmax,
       CASE WHEN (t_infomask2 & 16384) > 0 THEN 't' END AS hhu,
       CASE WHEN (t_infomask2 & 32768) > 0 THEN 't' END AS hot,
       t_ctid
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE sql;

Повторяем вставку и обновление строки:

=> INSERT INTO hot VALUES (1, 'A');
=> UPDATE hot SET s = 'B';

В странице появилась hot-цепочка изменений:

  • флаг Heap Hot Updated (hhu) говорит о том, что надо идти дальше по цепочке ctid;

  • флаг Heap Only Tuple (hot) показывает, что на данную версию строки нет ссылок из индексов.

=> SELECT * FROM heap_page('hot',0);
 ctid  | state  | xmin  | xmax | hhu | hot | t_ctid
-------+--------+-------+------+-----+-----+--------
 (0,1) | normal | 831 c | 832  | t   |     | (0,2)
 (0,2) | normal | 832   | 0 a  |     | t   | (0,2)
(2 rows)

При дальнейших изменениях цепочка будет расти — но только в пределах страницы:

=> UPDATE hot SET s = 'C';
=> UPDATE hot SET s = 'D';
=> SELECT * FROM heap_page('hot',0);
 ctid  | state  | xmin  | xmax  | hhu | hot | t_ctid
-------+--------+-------+-------+-----+-----+--------
 (0,1) | normal | 831 c | 832 c | t   |     | (0,2)
 (0,2) | normal | 832 c | 833 c | t   | t   | (0,3)
 (0,3) | normal | 833 c | 834   | t   | t   | (0,4)
 (0,4) | normal | 834   | 0 a   |     | t   | (0,4)
(4 rows)

При этом в индексе обнаруживаем одну-единственную ссылку на «голову» цепочки:

=> SELECT * FROM index_page('hot_id',1);
 itemoffset | htid  | dead
------------+-------+------
          1 | (0,1) | f
(1 row)

Цепочка hot-обновлений не выходит за рамки страницы, поэтому обход всей цепочки никогда не требует обращения к другим страницам и не ухудшает производительность.

Тип обновлений отслеживается как часть статистики использования таблицы. В данном случае из семи операций UPDATE три были hot-обновлениями:

=> SELECT n_tup_upd, n_tup_hot_upd
FROM pg_stat_all_tables
WHERE relid = 'hot'::regclass;
 n_tup_upd | n_tup_hot_upd
-----------+---------------
         7 |             3
(1 row)

5.3. Самоочистка при hot-обновлениях

Частный, но важный случай представляет собой самоочистка при hot-обновлениях.

В начатом примере уже превышен порог fillfactor, так что следующее обновление должно привести к самоочистке. Но на этот раз в странице находится цепочка обновлений. «Голова» этой hot-цепочки всегда должна оставаться на своем месте, поскольку на нее ссылается индекс, а остальные указатели могут быть освобождены, ведь на них точно нет ссылок извне.

Чтобы не трогать «голову», применяется двойная адресация: указатель, на который ссылается индекс, — в данном случае (0,1) — получает статус redirect и ведет на версию строки, с которой в данный момент начинается цепочка:

=> UPDATE hot SET s = 'E';
=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     | xmin  | xmax | hhu | hot | t_ctid
-------+---------------+-------+------+-----+-----+--------
 (0,1) | redirect to 4 |       |      |     |     |
 (0,2) | normal        | 835   | 0 a  |     | t   | (0,2)
 (0,3) | unused        |       |      |     |     |
 (0,4) | normal        | 834 c | 835  | t   | t   | (0,2)
(4 rows)

Здесь версии (0,1), (0,2) и (0,3) были вычищены, «головной» указатель 1 остался перенаправлять доступ, а указатели 2 и 3 были освобождены (получили статус unused), поскольку на эти версии гарантированно не было ссылок из индексов. Новая версия строки была записана на освободившееся место под номером (0,2).

Выполним обновление еще несколько раз:

=> UPDATE hot SET s = 'F';
=> UPDATE hot SET s = 'G';

Hot-цепочка выросла:

=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     | xmin  | xmax  | hhu | hot | t_ctid
-------+---------------+-------+-------+-----+-----+--------
 (0,1) | redirect to 4 |       |       |     |     |
 (0,2) | normal        | 835 c | 836 c | t   | t   | (0,3)
 (0,3) | normal        | 836 c | 837   | t   | t   | (0,5)
 (0,4) | normal        | 834 c | 835 c | t   | t   | (0,2)
 (0,5) | normal        | 837   | 0 a   |     | t   | (0,5)
(5 rows)

Следующее обновление снова вызывает самоочистку:

=> UPDATE hot SET s = 'H';
=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     | xmin  | xmax | hhu | hot | t_ctid
-------+---------------+-------+------+-----+-----+--------
 (0,1) | redirect to 5 |       |      |     |     |
 (0,2) | normal        | 838   | 0 a  |     | t   | (0,2)
 (0,3) | unused        |       |      |     |     |
 (0,4) | unused        |       |      |     |     |
 (0,5) | normal        | 837 c | 838  | t   | t   | (0,2)
(5 rows)

Часть версий вычищена, а указатель на «голову» соответствующим образом сдвинут. Если продолжать обновления, в этом (удачно подобранном) примере все версии строк так и будут «крутиться» в пределах одной страницы.

5.4. Разрыв hot-цепочки

Если на странице не хватит свободного места, чтобы разместить новую версию строки, цепочка прервется. На версию строки, размещенную на другой странице, придется сделать отдельную ссылку из индекса.

Чтобы получить такую ситуацию, начнем параллельную транзакцию и построим в ней снимок данных, который не даст вычищать версии строк:

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT 1;

Теперь выполним обновления в первом сеансе:

=> UPDATE hot SET s = 'I';
=> UPDATE hot SET s = 'J';
=> UPDATE hot SET s = 'K';

Пока все версии помещаются на одной странице:

=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     | xmin  | xmax  | hhu | hot | t_ctid
-------+---------------+-------+-------+-----+-----+--------
 (0,1) | redirect to 2 |       |       |     |     |
 (0,2) | normal        | 838 c | 839 c | t   | t   | (0,3)
 (0,3) | normal        | 839 c | 840 c | t   | t   | (0,4)
 (0,4) | normal        | 840 c | 841   | t   | t   | (0,5)
 (0,5) | normal        | 841   | 0 a   |     | t   | (0,5)
(5 rows)

При следующем обновлении места на странице уже не хватит, а самоочистка не сможет ничего освободить:

=> UPDATE hot SET s = 'L';

    => COMMIT; -- снимок больше не нужен
=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     | xmin  | xmax  | hhu | hot | t_ctid
-------+---------------+-------+-------+-----+-----+--------
 (0,1) | redirect to 2 |       |       |     |     |
 (0,2) | normal        | 838 c | 839 c | t   | t   | (0,3)
 (0,3) | normal        | 839 c | 840 c | t   | t   | (0,4)
 (0,4) | normal        | 840 c | 841 c | t   | t   | (0,5)
 (0,5) | normal        | 841 c | 842   |     | t   | (1,1)
(5 rows)

В версии (0,5) видим ссылку (1,1), ведущую на страницу 1:

=> SELECT * FROM heap_page('hot',1);
 ctid  | state  | xmin | xmax | hhu | hot | t_ctid
-------+--------+------+------+-----+-----+--------
 (1,1) | normal | 842  | 0 a  |     |     | (1,1)
(1 row)

Впрочем, эта ссылка не используется, поскольку в версии (0,5) не установлен бит Heap Hot Updated. А на версию (1,1) можно попасть из индекса, в котором теперь две ссылки. Каждая из них ведет на начало своей hot-цепочки:

=> SELECT * FROM index_page('hot_id',1);
 itemoffset | htid  | dead
------------+-------+------
          1 | (0,1) | f
          2 | (1,1) | f
(2 rows)

Разрывы hot-цепочек отслеживаются в статистике отдельно: (v. 16)

=> SELECT n_tup_upd, n_tup_hot_upd, n_tup_newpage_upd
FROM pg_stat_all_tables
WHERE relid = 'hot'::regclass;
 n_tup_upd | n_tup_hot_upd | n_tup_newpage_upd
-----------+---------------+-------------------
        15 |            10 |                 1
(1 row)

Здесь из пятнадцати операций UPDATE одиннадцать были hot-обновлениями: десяти хватило места на странице, а одной нет, что привело к разрыву цепочки.

При частых обновлениях столбцов, не входящих в индексы, может иметь смысл уменьшить параметр fillfactor, чтобы зарезервировать на странице достаточно места для обновлений. Конечно, надо учитывать, что чем ниже fillfactor, тем больше остается незанятого места на странице, и, соответственно, физический размер таблицы увеличивается.

5.5. Самоочистка индексов

Я говорил о том, что самоочистка работает с одной табличной страницей и не трогает индексы. Но и для индексов есть собственная самоочистка4, которая тоже работает с одной (но уже индексной) страницей.

Такая очистка происходит, когда при вставке строки в B-дерево на индексной странице не оказывается места и ее требуется расщепить (split), перераспределив данные между двумя новыми страницами. Проблема в том, что при удалении записей две индексные страницы уже не «склеиваются» в одну. Это приводит к раздуванию индекса, а однажды выросший размер может не уменьшиться даже при удалении существенной части данных. Но если самоочистке удается убрать часть индексных записей, момент расщепления может быть отложен.

Есть два вида записей, которые самоочистка может удалить из индекса.

В первую очередь удаляются записи, помеченные ранее как мертвые5. Как я уже говорил, такая пометка ставится при индексном доступе, если оказывается, что индексная запись ссылается на версию строки, которая не видна ни в одном снимке или уже не существует.

Если заведомо мертвых записей не нашлось, проверяются строки, ссылающиеся на разные версии одной и той же табличной строки 6. Из-за многоверсионности при обновлениях может возникать большое количество неактуальных версий строк, которые, скорее всего, достаточно быстро уйдут за горизонт базы данных. Hot-обновления смягчают этот эффект, но работают не всегда: если обновляемый столбец входит в какой-нибудь индекс, ссылки на все версии строк появляются и во всех остальных индексах. Перед расщеплением страницы имеет смысл поискать в ней записи, еще не помеченные как мертвые, но уже допускающие очистку. Для этого надо проверить видимость соответствующих версий строк, что требует обращения к табличной странице. Поэтому рассматриваются не все, а только «перспективные» индексные записи, появившиеся как копия существующих для нужд многоверсионности. Заплатить цену проверки видимости таких записей оказывается выгоднее, чем допустить лишнее расщепление индексной страницы. (v. 14)