Данные тоже стареют. Только в отличие от людей им не всегда нужен вечный покой — чаще достаточно переехать туда, где они будут занимать меньше места, стоить дешевле в хранении и не мешать тем, кто продолжает работать. В Tantor Postgres эта задача решается не набором разрозненных скриптов, а полноценной системой управления жизненным циклом данных: с регулярным сбором статистики, анализом реальной активности, объяснимыми рекомендациями, аудитом, dry-run и безопасным поэтапным переносом объектов между уровнями хранения. Главное — понять, когда действительно пришло их время. В этой статье мы разберем полный операторский сценарий работы с ILM — от появления первых рекомендаций до их безопасного исполнения — чтобы вы могли не просто познакомиться с возможностями расширения, а уверенно использовать его в эксплуатации.

В первой статье мы познакомились с pg_ilm, увидели, как из идеи «давайте как-нибудь охлаждать данные» получился вполне конкретный инструмент для анализа жизненного цикла таблиц и партиций. Также тему pg_ilm со своей стороны раскрыл мой коллега Дмитрий — опытный администратор баз данных, который работает с расширением еще со времен альфа-версий. Его статья посвящена настройке и практическому использованию ILM глазами DBA, поэтому наши материалы скорее дополняют друг друга.

Для полноты, пожалуй, стоит еще привести ссылку на полную документацию, но сегодня я хочу вместе с вами пройти функциональный сценарий использования pg_ilm. Я как разработчик покажу, на что стоит обратить внимание, почему инструменты работают так, а не иначе, дам комментарии и пояснения о том, как пользоваться функционалом pg_ilm, и покажу результаты небольшого бенчмарка – так временны́е затраты на перенос данных станут нагляднее. Прочитав статью, вы сможете, в частности:

  • освоить мониторинг с параллельной эмуляцией нагрузки;

  • разобраться со сбором и работой с рекомендациями;

  • понять, зачем нужен Dry-run execute;

  • узнать, как выполнять сразу несколько рекомендаций за раз;

  • проверить результаты переносов и преобразований;

  • ознакомиться с аудитом рекомендаций.

Почему это важно? Потому что в реальной эксплуатации почти никогда не бывает так, что, дескать, вот старая таблица, она, очевидно, холодная, давайте ее куда-нибудь унесем.

В конце демонстрационного сценария мы увидим несколько разных исходов:

  • часть объектов будет рекомендована к переносу;

  • часть останется горячей;

  • часть будет пропущена из-за размера;

  • а часть заблокируется из-за ограничений.

Для ILM это нормальный результат: задача инструмента не в том, чтобы «перенести все», а в том, чтобы дать оператору объяснимую картину и инструменты, чтобы ее реализовать.

Сравним на пальцах

Давайте сперва сравним сценарий работы администратора для проведения работы по оптимизации хранения на примере одной таблицы на ванильном PostgreSQL и с использованием pg_ilm.

Vanilla PostgreSQL: что сделал DBA руками

Для одной таблицы администратор вручную:

  1. Собрал политики для этой таблицы.

  2. Проверил размер таблицы.

  3. Проверил размер индексов.

  4. Создал свою таблицу snapshot-ов.

  5. Снял первый snapshot.

  6. Подождал окно наблюдения.

  7. Снял второй snapshot.

  8. Написал запрос сравнения snapshot-ов.

  9. Сам интерпретировал дельты относительно политик.

  10. Проверил долгие транзакции.

  11. Проверил блокировки.

  12. Сгенерировал команды переноса таблицы.

  13. Сгенерировал команды переноса индексов.

  14. Выполнил перенос.

  15. Проверил физическое состояние таблицы.

  16. Проверил физическое состояние индексов.

  17. Сам зафиксировал результат где-то в change ticket/runbook.

pg_ilm: что сделал DBA руками

Для одной таблицы вручную:

  1. Собрал политики.

  2. Задал правило.

  3. Спустя окно наблюдения посмотрел активность через get_table_activity/flame.

  4. Получил recommend/skip/blocked.

  5. Посмотрел actionable queue в ilm.list_actionable_recommendations().

  6. Выполнил рекомендацию.

  7. Проверил физическое состояние.

  8. Посмотрел ilm.archive_recommendation_history.

Таким образом, даже для сценария с одной таблицей ILM дает возможность сэкономить множество усилий, сократив 17 пунктов до 8. Если рассмотреть сценарий для множества таблиц, эффективность усилий администратора повысится еще более, так как в первую очередь расширение разрабатывалось как «пакетный» инструмент.

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

Задача

Без ILM

с ILM

Собрать историю активности

DBA собирает snapshot-ы сам

Регулярный сбор по расписанию и ilm.collect_stats_snapshot()

Решить, остыла ли таблица

Ручное сравнение счетчиков

ilm.get_table_activity() / ilm.flame()

Принять решение возможности переноса

Интерпретация DBA по runbook-у, согласно принятым политикам

Статусы списка рекомендаций recommend / skip / blocked

Получить исполнимые действия

Ручной DDL или самописный скрипт

ilm.list_actionable_recommendations()

Перенос таблиц

Потабличный перенос или самописный скрипт пакетного переноса

Список для проверки таблиц, выбранных для переноса ilm.execute_recommendations(..., p_dry_run => TRUE) Выполнение переноса ilm.execute_recommendations(..., p_dry_run => FALSE)

Ведение истории

Создание и ведение ticket/wiki/runbook

ilm.archive_recommendation_history

Подчеркну: pg_ilm не отменяет ответственность DBA. Он убирает большую часть ручной механики вокруг наблюдения, интерпретации и подготовки действий.

На этой ноте переходим к основной части. Впереди нас ждет разбор того, как pg_ilm помогает оператору принять решение, почему рекомендация — это не приказ, зачем она такая, какая есть, и какой смысл в нее заложен.

Стенд и условия эксперимента

Функционально сценарий также проверялся на совместимых системах, включая Astra Linux 1.7 и Astra Linux 1.8, но приведенные ниже логи относятся к одному конкретному демостенду.

Для демонстрации я использовал Tantor Postgres 18.3 на Ubuntu 22.04 и набор расширений, необходимых для работы сценария:

SELECT current_user AS run_as, current_database() AS database_name, version() AS server_version;
SHOW shared_preload_libraries;
CREATE EXTENSION IF NOT EXISTS pg_cron CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman;
CREATE EXTENSION IF NOT EXISTS pg_archive SCHEMA archive CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_ilm CASCADE;

В логе запуска это выглядит так:

run_as   | database_name | server_version
---------+---------------+---------------------------------------------------------------
postgres | postgres      | PostgreSQL 18.3 on x86_64-pc-linux-gnu, compiled by gcc ...

shared_preload_libraries
---------------------------------------
pg_cron,pg_partman_bgw,pg_archive_bgw

extname     | extversion
------------+------------
pg_archive  | 1.0.0
pg_columnar | 11.1-12
pg_cron     | 1.6
pg_ilm      | 1.0
pg_partman  | 5.4.1

Для чистоты эксперимента создаем два табличных пространства:

\set demo_hot_dir `bash -lc 'mkdir -p /var/lib/postgresql/ilm_demo_runs && chmod 700 /var/lib/postgresql/ilm_demo_runs && mktemp -d /var/lib/postgresql/ilm_demo_runs/hot_XXXXXXXX'` -- Не забудьте заготовить директорию и права на нее для tablespace

CREATE TABLESPACE ilm_demo_hot_ts LOCATION :'demo_hot_dir';
CREATE TABLESPACE ilm_demo_cold_ts LOCATION 'path/to/cold_directory'; -- Либо можете использовать заранее созданную директорию, указав путь

Смысл здесь прост:

  • ilm_demo_hot_ts — быстрый слой, где данные живут изначально;

  • ilm_demo_cold_ts — холодный слой, куда мы будем переносить данные после того, как они действительно остынут.

В боевой системе на месте этих tablespace могут быть разные диски, разные классы storage, разные политики резервного копирования, разные компромиссы по цене и скорости. В демо это просто две директории, но логика та же. Имена и количество tablespace, используемых в работе с ILM, не ограничено: главное, не запутайтесь, они пригодятся в правилах.

Ну и, конечно, проведем инициализацию самого ILM:

SELECT ilm.init(  
    p_partman_interval             => '1 hour',
       -- партиции собраной статистики нарезаются с интервалом 1 час
    p_partman_retention            => '2 hours',
       -- окно хранения статистики 2 часа
    p_stats_schedule               => '* * * * *',
       -- pg_ilm снимает snapshot активности каждую минуту
    p_cleanup_schedule             => '*/15 * * * *',
       -- раз в пятнадцать минут pg_ilm запускает служебную очистку устаревших рекомендаций, правил и т.д. 
    p_partman_maintenance_schedule => '*/5 * * * *',
       -- раз в пять минут partman запускает обслуживание (создание новых партиций, обслуживание родительских таблиц и т.д.)
    p_archive_rules_schedule       => '*/5 * * * *'
       -- раз в пять минут пересчитываются/обрабатываются правила жизненного цикла.
       -- кто стал кандидатом на перенос, кто остается горячим, кто блокируется. 
);

В демо я намеренно использую плотные расписания: статистика собирается каждую минуту, правила архивации и обслуживание pg_partman запускаются каждые пять минут, очистка — раз в пятнадцать минут. Это не рекомендация для production-настройки, а ускоренный демонстрационный режим, чтобы жизненный цикл данных можно было увидеть за минуты, а не за сутки или неделю.

Моделируем маленький зоопарк данных

Чтобы не сводить все к скучному примеру «таблица старая — таблицу перенесли», создадим несколько объектов с разным поведением.

orders_cold
    только небольшая запись в начале эксперимента без дальнейшей активности

orders_cooling
    заметная активность продолжительное время в начале. никакой активности после 3 фазы

orders_hot
    разнообразная ощутимая запись на всей протяженности эксперимента

orders_small
    таблица, которую мы пишем в начале, но которая слишком мала,
    чтобы удовлетворить требованиям по минимальному размеру для переноса

fk_blocked_orders / identity_blocked
    таблицы, на которых будут продемонстрированы текущие ограничения `pg_archive`

events
    партиционированная таблица, у которой нагрузке подвергнется одна из архивных партиций

Если перевести это с демонстрационного на человеческий:

  • orders_hot — текущий живой поток данных. Его продолжают активно менять;

  • orders_cooling — данные, которые недавно еще были активными, но потом начали остывать;

  • orders_cold — исторический набор, который записали в начале и больше не трогали;

  • orders_small — таблица холодная, но слишком маленькая, чтобы ради нее стоило начинать переезд;

  • fk_blocked_orders — таблица с внешним ключом. Холодная, но с ограничением;

  • identity_blocked — таблица с identity column. Тоже не все так просто;

  • events_archive_202401 — старая партиция событий, для которой правило задано не напрямую, а через родительскую таблицу demo.events.

Я считаю, что хороший ILM должен уметь не только соглашаться — «да, переносим», но и уверенно говорить:

Нет, вот это пока не трогаем.
Вот это вообще автоматически трогать нельзя.
А вот это вроде холодное, но экономического смысла мало.

Как любит говорить мой друг, «Порой пас — не ошибка». Да, иногда лучшее действие системы — это отказ от действия. Звучит скучно, зато после таких скучных решений у администратора обычно меньше поводов пить валерьянку из системной кружки. Ибо, как говорится, нет для команды большей проблемы, чем излишне инициативный самоуверенный профан.

Мой ILM — мои правила

Для каждой таблицы задаем lifecycle policy через ilm.archive_rule_upsert.

Например, для orders_cold:

SELECT ilm.archive_rule_upsert(
    p_target_table           => 'demo.orders_cold'::regclass,
    p_target_kind            => 'regular_table',
    p_target_state           => 'cold_columnar', -- состояние, которое будет считаться финальным
    p_idle_threshold         => '120 seconds',   -- для демосценария значение уменьшено в меньшую сторону,
                                                 -- если бы тут стоял месяц, вторая часть статьи вышла бы сильно поздже))
    p_min_table_size_bytes   => 0,        -- порог по размеру
    p_warm_tablespace        => 'ilm_demo_hot_ts',  -- здесь указывается, какой tablespace будет считаться горячим.
                                                    -- по умолчанию любой исходный tablespace, кроме заданного
                                                    -- как p_cold_tablespace, считается горячим
    p_cold_access_method     => 'columnar',
    p_cold_tablespace        => 'ilm_demo_cold_ts', -- собственно «холодный» tablespace
    p_keep_indexes           => true,
    p_keep_publication       => false
);

Для orders_hot специально задаем такое же правило:

SELECT ilm.archive_rule_upsert(
    p_target_table           => 'demo.orders_hot'::regclass,
    p_target_kind            => 'regular_table',
    p_target_state           => 'cold_columnar',
    p_idle_threshold         => '2 minutes',     -- в этот раз мы использовали минуты
    p_min_table_size_bytes   => 0,
    p_warm_tablespace        => 'ilm_demo_hot_ts',
    p_cold_access_method     => 'columnar',
    p_cold_tablespace        => 'ilm_demo_cold_ts',
    p_keep_indexes           => true
);

За счет этого можно будет наглядно увидеть, как отрабатывают правила. Пока активность будет сохраняться, рекомендации не появятся.

Для маленькой таблицы задаем большой p_min_table_size_bytes:

SELECT ilm.archive_rule_upsert(
    p_target_table           => 'demo.orders_small'::regclass,
    p_target_kind            => 'regular_table',
    p_target_state           => 'cold_columnar',
    p_idle_threshold         => '120 seconds',
    p_min_table_size_bytes   => 1073741824,
    p_cold_access_method     => 'columnar',
    p_cold_tablespace        => 'ilm_demo_cold_ts'
);

Один гигабайт для таблицы из пары десятков строк — это сильно, но и мы не будем в примере размениваться по мелочам. ILM не будет предлагать рекомендации до тех пор, пока размер таблицы не превысит требуемый размер. Однако если шаг партиционирования таблицы у вас частый, то с данным параметром нужно обходиться аккуратнее.

Для партиционированной таблицы задаем правило на родителя:

SELECT ilm.archive_rule_upsert(
    p_target_table           => 'demo.events'::regclass, -- правило накладывается на родительскую таблицу
    p_target_kind            => 'partitioned_parent',
    p_target_state           => 'cold_columnar',
    p_control_column         => 'event_ts',              -- этот параметр используется для реализации переноса,
    p_max_age                => '1 month',               -- если нужно задать верхний порог возраста партиции
    p_cold_after             => '120 seconds',
    p_min_age                => '1 day',                 -- и собственно нижний порог возраста
    p_warm_tablespace        => 'ilm_demo_hot_ts',
    p_cold_access_method     => 'columnar',
    p_cold_tablespace        => 'ilm_demo_cold_ts',
    p_keep_indexes           => true
);

Хоть мы и назначаем правило на demo.events, но действия будут выполняться не над родителем, а над конкретной leaf-партицией. В нашем случае это demo.events_archive_202401.

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

SELECT * FROM ilm.archive_rules ORDER BY rule_id;
 rule_id |      target_table      |    target_kind     | target_state  | pg_archive_schema | control_column | max_age | cold_after |
---------+------------------------+--------------------+---------------+-------------------+----------------+---------+------------+
       1 | demo.orders_cold       | regular_table      | cold_columnar | archive           | ∅              | ∅       | 30 days    | -- обратите внимание, что
       2 | demo.orders_cooling    | regular_table      | cold_columnar | archive           | ∅              | ∅       | 30 days    | -- для регулярных таблиц
       3 | demo.orders_hot        | regular_table      | cold_columnar | archive           | ∅              | ∅       | 30 days    | -- выставлено дефолтное
       4 | demo.orders_small      | regular_table      | cold_columnar | archive           | ∅              | ∅       | 30 days    | -- значение `cold_after`, 
       5 | demo.fk_blocked_orders | regular_table      | cold_columnar | archive           | ∅              | ∅       | 30 days    | -- которое не участвует в расчетах
       6 | demo.identity_blocked  | regular_table      | cold_columnar | archive           | ∅              | ∅       | 30 days    | -- периода остывания. для них задается `idle_threshold`
       7 | demo.events            | partitioned_parent | cold_columnar | archive           | event_ts       | 1 mon   | 00:02:00   |

 rule_id |      target_table      | min_age  | idle_threshold | min_table_size_bytes | enabled |          created_at           |          updated_at           |
---------+------------------------+----------+----------------+----------------------+---------+-------------------------------+-------------------------------+
       1 | demo.orders_cold       | 00:00:00 | 00:02:00       |                    0 | t       | 2026-06-16 20:03:53.257837+03 | 2026-06-16 20:03:53.257837+03 |
       2 | demo.orders_cooling    | 00:00:00 | 00:02:00       |                    0 | t       | 2026-06-16 20:03:53.25877+03  | 2026-06-16 20:03:53.25877+03  |
       3 | demo.orders_hot        | 00:00:00 | 00:02:00       |                    0 | t       | 2026-06-16 20:03:53.258979+03 | 2026-06-16 20:03:53.258979+03 |
       4 | demo.orders_small      | 00:00:00 | 00:02:00       |           1073741824 | t       | 2026-06-16 20:03:53.259125+03 | 2026-06-16 20:03:53.259125+03 |
       5 | demo.fk_blocked_orders | 00:00:00 | 00:02:00       |                    0 | t       | 2026-06-16 20:03:53.259277+03 | 2026-06-16 20:03:53.259277+03 |
       6 | demo.identity_blocked  | 00:00:00 | 00:02:00       |                    0 | t       | 2026-06-16 20:03:53.259433+03 | 2026-06-16 20:03:53.259433+03 |
       7 | demo.events            | 1 day    | 30 days        |                    0 | t       | 2026-06-16 20:03:53.259574+03 | 2026-06-16 20:03:53.259574+03 | -- для партиций важен cold_after

 rule_id |      target_table      | target_schema | warm_tablespace | cold_access_method | cold_tablespace  | keep_indexes | keep_publication 
---------+------------------------+---------------+-----------------+--------------------+------------------+--------------+------------------
       1 | demo.orders_cold       | ∅             | ilm_demo_hot_ts | columnar           | ilm_demo_cold_ts | t            | f
       2 | demo.orders_cooling    | ∅             | ilm_demo_hot_ts | columnar           | ilm_demo_cold_ts | t            | f
       3 | demo.orders_hot        | ∅             | ilm_demo_hot_ts | columnar           | ilm_demo_cold_ts | t            | f
       4 | demo.orders_small      | ∅             | ∅               | columnar           | ilm_demo_cold_ts | t            | f
       5 | demo.fk_blocked_orders | ∅             | ∅               | columnar           | ilm_demo_cold_ts | t            | f
       6 | demo.identity_blocked  | ∅             | ∅               | columnar           | ilm_demo_cold_ts | t            | f
       7 | demo.events            | ∅             | ilm_demo_hot_ts | columnar           | ilm_demo_cold_ts | t            | f

(7 строк)

Время: 0,090 мс

Здесь можно проверить правила на соответствие политикам, а заодно посмотреть, включены ли сами правила. ILM дает возможность не удалять правила, а временно их выключать, используя значение поле enabled. Отдельно стоит обратить внимание на два разных механизма остывания. Для обычных таблиц в этом сценарии используется idle_threshold: сколько времени объект должен не проявлять активности, чтобы стать кандидатом. Для партиционированного родителя важен cold_after: правило задано на родителя, а кандидатом становится конкретная leaf-партиция, если она подходит по возрасту и активности. Поэтому в выводе для regular table можно видеть дефолтный cold_after, но в расчете периода остывания для них используется idle_threshold. Так уж исторически сложилось для разделения подходов работы pg_archive с партиционированными и регулярными таблицами. В дальнейших патчах переработаем и дополним.

Температура — это не дата в строке

Для лучшей наглядности того, как данные остывают, мы построим таймлайн из пяти фаз. Так мы увидим не только конечный результат, но и отследим, как активность влияет на флеймграф.

Сразу после bulk load почти все выглядит горячим — не потому что это живые данные, а потому что мы только что залили их в базу. Это важный момент: дата в бизнес-колонке и фактическая активность объекта в базе — разные вещи.

Дата события отвечает на вопрос:

Когда это случилось в предметной области?

Температура данных отвечает на другой вопрос:

Что с этим объектом происходило в базе за наблюдаемый период?

И вот это различие в эксплуатации для нас гораздо важнее.

Важный момент для понимания работы ILM. Это инструмент для наблюдения и анализа, а не построения гипотез. Если создать таблицы, загрузить данные и потом дождаться первого «регулярного» снапшота, то флеймграф не сможет вычислить дельту и посчитает, что таблица была старая, уже с активностью, и на этом диапазоне с ней просто не работали.
Дельта высчитывается либо относительно первого не вошедшего в диапазон значения dirty_blocks, либо относительно последнего вошедшего, если интервал покрывает все снапшоты.
Например, мы строим flame по двум минутам.

Ниже упрощенная схема - не полный вывод stats_history:

snapshot | table | dirty_blocks | относительное время снапшота |
    1    | demo  |        10    |            1 минута          |

Дельта будет равна нулю.

Если 2 снапшота:

snapshot | table | dirty_blocks | относительное время снапшота 
    2    |  demo |     3        |             1 минута         
    1    |  demo |    10        |             2 минуты         | -- снапшот взят как опорный

то дельта будет равна 3.

Если мы после создания таблицы, но до заполнения данными, при помощи команды SELECT ilm.collect_stats_snapshot(); собрали снапшот, то:

snapshot | table | dirty_blocks | относительное время снапшота 
   3     |  demo |      3       |             1 минута         | -- входят в интервал
   2     |  demo |     10       |             2 минуты         | -- входят в интервал
   1     |  demo |      0       |             3 минуты         | -- используется как опорный

И дельта будет равна 13.

Phase 01. Начальный след загрузки

Я заранее сделал «нулевой» снапшот. Теперь заливаем данные во все крупные таблицы, сразу же обновляем небольшую часть строк и собираем «первый» snapshot:

UPDATE demo.orders_cold SET status = 'settled' WHERE id <= 300;
UPDATE demo.orders_cooling SET status = 'warming' WHERE id <= 300;
UPDATE demo.orders_hot SET amount = amount + 1 WHERE id <= 4000;
UPDATE demo.orders_small SET payload = payload || '-changed' WHERE id <= 1;
UPDATE demo.fk_blocked_orders SET payload = payload || '-changed' WHERE id <= 50;
UPDATE demo.identity_blocked SET payload = payload || '-changed' WHERE id <= 50;
UPDATE demo.events_archive_202401 SET payload = payload || '-changed' WHERE id <= 500;
  -- если после нагрузки сделать небольшую паузу перед snapshot,
  -- свежая активность с большей вероятностью попадет в собираемую статистику
SELECT ilm.collect_stats_snapshot();
 -- здесь пауза намеренно не добавлена,
 -- чтобы показать возможное запаздывание обновления статистики
SELECT
    'phase01 initial footprint' AS phase,
    *
FROM ilm.flame('2 minutes'::interval)
WHERE schemaname = 'demo'
  AND relation_size_bytes > 0
ORDER BY relname;

В первый раз я покажу весь вывод, ниже буду сокращать для компактности (и чтобы не размывать внимание):

           phase           | reloid | schemaname |        relname        | access_method |        flame         | relation_size_bytes | total_blocks_dirtied |      dirty_ratio       | snapshot_count |         period_start          |          period_end           
---------------------------+--------+------------+-----------------------+---------------+----------------------+---------------------+----------------------+------------------------+----------------+-------------------------------+-------------------------------
 phase01 initial footprint |  22514 | demo       | customer_dim          | heap          | ████████████████████ |                8192 |                    1 | 1.00000000000000000000 |              2 | 2026-06-16 20:03:53.261873+03 | 2026-06-16 20:03:55.121648+03
 phase01 initial footprint |  22600 | demo       | events_archive_202401 | heap          | ███████████████████  |             3637248 |                  430 | 0.96846846846846846847 |              2 | 2026-06-16 20:03:53.261873+03 | 2026-06-16 20:03:55.121648+03
 phase01 initial footprint |  22524 | demo       | fk_blocked_orders     | heap          | ████████████████████ |              802816 |                   98 | 1.00000000000000000000 |              2 | 2026-06-16 20:03:53.261873+03 | 2026-06-16 20:03:55.121648+03
 phase01 initial footprint |  22541 | demo       | identity_blocked      | heap          | ████████████████████ |              925696 |                  113 | 1.00000000000000000000 |              2 | 2026-06-16 20:03:53.261873+03 | 2026-06-16 20:03:55.121648+03
 phase01 initial footprint |  22455 | demo       | orders_cold           | heap          | ███████████████████  |             6529024 |                  786 | 0.98619824341279799247 |              2 | 2026-06-16 20:03:53.261873+03 | 2026-06-16 20:03:55.121648+03
 phase01 initial footprint |  22472 | demo       | orders_cooling        | heap          | ███████████████████  |             4775936 |                  573 | 0.98284734133790737564 |              2 | 2026-06-16 20:03:53.261873+03 | 2026-06-16 20:03:55.121648+03
 phase01 initial footprint |  22488 | demo       | orders_hot            | heap          | ████████████████     |             3366912 |                  309 | 0.75182481751824817518 |              2 | 2026-06-16 20:03:53.261873+03 | 2026-06-16 20:03:55.121648+03
 phase01 initial footprint |  22504 | demo       | orders_small          | heap          | ████████████████████ |                8192 |                    1 | 1.00000000000000000000 |              2 | 2026-06-16 20:03:53.261873+03 | 2026-06-16 20:03:55.121648+03
(8 строк)
Время: 4,244 мс

На этом этапе почти все крупные объекты выглядят активными. И это нормально: мы только что их создали, залили и потрогали. Если прямо сейчас сказать «о, таблица горячая», мы будем правы технически, но промахнемся эксплуатационно. Увы, это не жизненный цикл данных — это след первичной загрузки. Хотя в какой-то мере это их яркий старт.

Я буду использовать ручной сбор снапшотов, чтобы гарантировать сбор данных между фазами из-за маленьких промежутков между командами и сжатых интервалов; вы же при работе с ИЛМ не обязаны использовать принудительный сбор статистики.

После этого этапа подождем, пока технический всплеск слегка уляжется. Сделай паузу — скушай твикс:

SELECT pg_sleep(120);
SELECT ilm.collect_stats_snapshot();

Две минуты ожидания нужны, чтобы отделить начальный след загрузки от дальнейшей активности. В проде, разумеется, интервалы будут не в секундах, а в часах, днях или неделях. Поэтому не думаю, что вам это будет необходимо.

Phase 02. Горячие данные и последний всплеск остывающих

Дальше начинаем имитировать рабочую нагрузку.

orders_hot активно обновляем несколькими пачками:

SELECT * FROM demo_meta.touch_orders_hot('phase02 hot burst A: 9800 rows', 1, 9800);
SELECT * FROM demo_meta.touch_orders_hot('phase02 hot burst B: 10000 rows', 1201, 11200);
SELECT * FROM demo_meta.touch_orders_hot('phase02 hot burst C: 8750 rows', 1, 8750);

orders_cooling тоже получает заметный всплеск:

UPDATE demo.orders_cooling
   SET amount = amount + 1, status = 'cooling-burst-a'
 WHERE id BETWEEN 301 AND 4300;

UPDATE demo.orders_cooling
   SET amount = amount + 1, status = 'cooling-burst-b'
 WHERE id BETWEEN 4301 AND 8300;

UPDATE demo.orders_cooling
   SET amount = amount + 1, status = 'cooling-burst-c'
 WHERE id BETWEEN 8301 AND 11300;

Сокращенный flame на 1 фазе (стартовая загрузка):

  phase  |        relname        |        flame         | relation_size_bytes | total_blocks_dirtied | snapshot_count |period_start|period_end 
---------+-----------------------+----------------------+---------------------+----------------------+----------------+------------+-----------
 phase01 | customer_dim          | ████████████████████ |                8192 |                    1 |              2 |   20:03:53 |  20:03:55
 phase01 | events_archive_202401 | ███████████████████  |             3637248 |                  430 |              2 |   20:03:53 |  20:03:55
 phase01 | fk_blocked_orders     | ████████████████████ |              802816 |                   98 |              2 |   20:03:53 |  20:03:55
 phase01 | identity_blocked      | ████████████████████ |              925696 |                  113 |              2 |   20:03:53 |  20:03:55
 phase01 | orders_cold           | ███████████████████  |             6529024 |                  786 |              2 |   20:03:53 |  20:03:55
 phase01 | orders_cooling        | ███████████████████  |             4775936 |                  573 |              2 |   20:03:53 |  20:03:55
 phase01 | orders_hot            | ████████████████     |             3366912 |                  309 |              2 |   20:03:53 |  20:03:55
 phase01 | orders_small          | ████████████████████ |                8192 |                    1 |              2 |   20:03:53 |  20:03:55

После сбора snapshot смотрим flame:

   phase |        relname        |        flame         | relation_size_bytes | total_blocks_dirtied | snapshot_count |period_start|period_end 
---------+-----------------------+----------------------+---------------------+----------------------+----------------+------------+----------
 phase02 | customer_dim          |                      |                8192 |                    0 |              4 | 20:05:00   | 20:06:25
 phase02 | events_archive_202401 | ████████             |             3637248 |                   18 |              4 | 20:05:00   | 20:06:25
 phase02 | fk_blocked_orders     | █████████            |              802816 |                    4 |              4 | 20:05:00   | 20:06:25
 phase02 | identity_blocked      | ███████              |              925696 |                    4 |              4 | 20:05:00   | 20:06:25
 phase02 | orders_cold           | ████                 |             6529024 |                   15 |              4 | 20:05:00   | 20:06:25
 phase02 | orders_cooling        | ███                  |             8011776 |                   14 |              4 | 20:05:00   | 20:06:25
 phase02 | orders_hot            | ████████████████████ |             9379840 |                  106 |              4 | 20:05:00   | 20:06:25
 phase02 | orders_small          |                      |                8192 |                    0 |              4 | 20:05:00   | 20:06:25

Рассмотрим флеймграф подробно. Согласно настройке демо-сценария автоматические снапшоты собираются раз в минуту; также мы собираем снапшоты на каждом этапе вручную. Если бы мы посмотрели таблицу ilm.stats_history для orders_cooling, то увидели бы такую картину:

snapshot_time|    relname     | heap_blks_read | blocks_dirtied |last_blocks_dirtied  
-------------+----------------+----------------+----------------+--------------------  
20:03:53 руч | orders_cooling |              0 |              0 |           -- опорный на 1 фазе
20:03:55 руч | orders_cooling |              0 |            573 |  20:03:54 -- использовался на 1 фазе для расчета дельты (загрузка данных)
-- здесь был flame первой фазы
20:04:00 авто| orders_cooling |              0 |            573 |  20:03:54 -- опорный на 2 фазе
20:05:00 авто| orders_cooling |              0 |            576 |  20:04:09 -- использован на 2 фазе (активность с 1 фазы 1 часть)
20:05:55 руч | orders_cooling |              0 |            587 |  20:05:53 -- использован на 2 фазе (активность с 1 фазы 2 часть)
20:06:00 авто| orders_cooling |              0 |            587 |  20:05:53 -- использован на 2 фазе
20:06:25 руч | orders_cooling |              0 |            587 |  20:05:53 -- использован на 2 фазе
--здесь был flame второй фазы

Я считаю, что такой артефакт является довольно интересным и показательным — видно, что размер таблиц изменился. Мы запустили сбор снапшотов практически одновременно с новой нагрузкой , но количество dirty-блоков запаздывает. Таким образом получается, что хоть мы и вызывали SELECT ilm.collect_stats_snapshot(); на прошлой фазе, ИЛМ все равно нужно время, чтобы получить новые данные. Да, можно было бы поставить паузы после записи, но тогда бы мы не обнаружили этот артефакт гонки. Думаю, понимание и учет механик работы БД стоят того, чтобы обратить на него внимание.

Мы видим дельту относительно автосбора конца первой фазы начала нового «рабочего» всплеска. В этом окне orders_hot доминирует по total_blocks_dirtied и flame: у нее 106 измененных блоков против 14 у orders_cooling и 15 у orders_cold. При этом остальные таблицы еще нельзя считать полностью остывшими — в окне наблюдения у них все еще есть следы активности. Значит, можно сделать важный вывод:

Таблица не становится холодной в тот момент, когда бизнес-процесс сказал «кажется, мы с ней закончили». Сначала она должна пережить период тишины, и только потом можно начинать говорить о переносе.

Phase 03. Cooling перестает использоваться

На следующей фазе мы перестаем писать в orders_cooling, но продолжаем активно менять orders_hot:

SELECT * FROM demo_meta.touch_orders_hot('phase03 hot burst A: 11200 rows', 801, 12000);
SELECT * FROM demo_meta.touch_orders_hot('phase03 hot burst B: 9200 rows', 1, 9200);
SELECT * FROM demo_meta.touch_orders_hot('phase03 hot burst C: 10000 rows', 1801, 11800);

После snapshot видим:

           phase      |        relname        | a_m  |        flame         | size_bytes | total_blocks_dirtied |  dirty_ratio       
phase03 cooling fades | events_archive_202401 | heap | ██                   | 3637248    | 18                   | 0.040540...
phase03 cooling fades | orders_cooling        | heap | ████████████████████ | 8011776    | 409                  | 0.418200...
phase03 cooling fades | orders_hot            | heap | ███████████████████  | 16646144   | 840                  | 0.413385...

На первый взгляд кажется странным: мы же уже не трогаем orders_cooling, почему она еще такая заметная? Ответ прост: окно наблюдения все еще помнит ее недавнюю активность. И это правильно. Между фазами прошло 30 секунд, orders_cooling был активен минуту назад, продолжаем наблюдение.

Phase 04. Разделение тепла

Сделаем паузу в половину диапазона остывания, снова трогаем только orders_hot:

SELECT pg_sleep(65);

SELECT * FROM demo_meta.touch_orders_hot('phase04 hot burst A: 8700 rows', 1501, 10200);
SELECT * FROM demo_meta.touch_orders_hot('phase04 hot burst B: 11800 rows', 1, 11800);

SELECT ilm.collect_stats_snapshot();

И вот теперь картина начинает расходиться:

phase04 heat separation | events_archive_202401 | heap |                      | 3637248  | 0    | 0.000000...
phase04 heat separation | orders_cooling        | heap | ███████████          | 8011776  | 395  | 0.403885...
phase04 heat separation | orders_hot            | heap | ████████████████████ | 16646144 | 1621 | 0.797736...

events_archive_202401 уже совсем тихая.

orders_cooling еще хранит следы недавней активности, но уже начинает проигрывать orders_hot. Видно, что 14 dirty_blocks, которые активно использовались на 1 фазе, вышли из двухминутного окна flame. Это как раз тот промежуточный случай, ради которого и нужен timeline, а не один разовый запрос.

orders_hot продолжает полыхать, как новогодняя гирлянда в серверной.

Phase 05. Время решительных действий

Финальный этап: еще одна пауза, снова пишем только в orders_hot, собираем snapshot и смотрим двухминутное окно активности через ilm.flame('2 minutes'::interval):

SELECT pg_sleep(65);

SELECT * FROM demo_meta.touch_orders_hot('phase05 hot burst A: 11950 rows', 1, 11950);
SELECT * FROM demo_meta.touch_orders_hot('phase05 hot burst B: 10000 rows', 701, 10700);
SELECT * FROM demo_meta.touch_orders_hot('phase05 hot burst C: 10000 rows', 2001, 12000);

SELECT ilm.collect_stats_snapshot();

SELECT
    'phase05 actionable queue' AS phase,
    *
FROM ilm.flame('2 minutes'::interval)
WHERE schemaname = 'demo'
  AND relation_size_bytes > 0
ORDER BY relname;

Теперь результат уже гораздо интереснее:

phase05 actionable queue | events_archive_202401 | heap |                      | 3637248  | 0   | 0.000000...
phase05 actionable queue | orders_cold           | heap |                      | 6529024  | 0   | 0.000000...
phase05 actionable queue | orders_cooling        | heap |                      | 8011776  | 0   | 0.000000...
phase05 actionable queue | orders_hot            | heap | ████████████████████ | 16646144 | 887 | 0.436515...

Вот теперь видим то, ради чего все затевалось:

  • orders_hot живет и активно меняется. Да, часть нагрузки выходит из окна, но новая нагрузка оперативно поддерживает температуру;

  • orders_cold молчит;

  • orders_cooling наконец-то остыла;

  • events_archive_202401 тоже не проявляет активности;

  • остальные объекты тоже тихие, но это еще не значит, что с ними можно или нужно что-то делать.

И это хороший момент, чтобы повторить главную мысль:

ILM смотрит не на нашу надежду или гипотезу, что данные уже остыли, а на наблюдаемую активность объекта за заданный период.

Считаем рекомендации

На этом приключения с анализом flamegraph и мониторингом температуры таблиц подошли к концу. Теперь мы готовы поработать с рекомендациями, самим переносом и журналом рекомендаций. Сначала вызываем функцию ручного сбора рекомендаций:

SELECT * 
FROM ilm.recommend_archive_actions(NULL, now(), FALSE) --False собирает рекомендации без занесения в журнал аудита
WHERE target_table LIKE 'demo.%'
ORDER BY target_table;

Получаем семь рекомендаций, как раз по количеству правил:

 rule_id |target_table              | current_state | recommended_next_state | status    | reason
---------+--------------------------+---------------+------------------------+-----------+----------------------------------------------------------
       7 |demo.events_archive_202401| hot_row       | warm_row               | recommend | cold tablespace move is recommended before columnar conversion
       5 |demo.fk_blocked_orders    | warm_row      | cold_columnar          | blocked   | table cannot transition to cold_columnar automatically...
       6 |demo.identity_blocked     | warm_row      | cold_columnar          | blocked   | table cannot transition to cold_columnar automatically...
       1 |demo.orders_cold          | hot_row       | warm_row               | recommend | row-oriented cold placement is recommended
       2 |demo.orders_cooling       | hot_row       | warm_row               | recommend | row-oriented cold placement is recommended
       3 |demo.orders_hot           | hot_row       | ∅                      | skip      | table does not satisfy idle/size thresholds yet
       4 |demo.orders_small         | hot_row       | ∅                      | skip      | table does not satisfy idle/size thresholds yet

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

В полном выводе есть еще важные поля, которые могут быть полезны для исполнения и понимания рекомендаций:

|                                                    prepared_call_sql                                                     | blocking_constraints | query_compatibility_risk | access_risk | restore_risk 
+--------------------------------------------------------------------------------------------------------------------------+----------------------+--------------------------+-------------+--------------
| SELECT * FROM ilm.execute_recommendations(p_target_table := 'demo.events_archive_202401'::regclass, p_dry_run := FALSE); | ∅                    | low                      | low         | medium      
| ∅                                                                                                                        | foreign_key          | low                      | low         | high
| ∅                                                                                                                        | identity_column      | low                      | low         | high
| SELECT * FROM ilm.execute_recommendations(p_target_table := 'demo.orders_cold'::regclass, p_dry_run := FALSE);           | ∅                    | low                      | low         | low
| SELECT * FROM ilm.execute_recommendations(p_target_table := 'demo.orders_cooling'::regclass, p_dry_run := FALSE);        | ∅                    | low                      | low         | low
| ∅                                                                                                                        | ∅                    | low                      | low         | low
| ∅                                                                                                                        | ∅                    | low                      | low         | low
(7 строк)

И вот здесь начинается "взрослая" эксплуатация ILM.

Recommend

Начнем со статусов. orders_coldorders_cooling и events_archive_202401 получают статус recommend. Это значит, что таблица/партиция удовлетворяет требованиям политик, не имеет ограничений и имеет место для маневра.

В поле prepared_call_sql находится команда для выполнения точечного переноса. Это может сэкономить время, если после нашего анализа данных мы приняли решение переносить только определенную таблицу.

Но обратите внимание: рекомендуемое состояние пока warm_row, а не сразу cold_columnar.

demo.orders_cold           | hot_row | warm_row | recommend
demo.orders_cooling        | hot_row | warm_row | recommend
demo.events_archive_202401 | hot_row | warm_row | recommend

ПОЧЕМУ В КАРТИНКЕ BETA?

Давайте разберемся, почему так. При переносе регулярной таблицы в другой tablespace достаточно провести только перенос, это наименее инвазивная операция. В случае переноса в columnar формат или уже columnar таблицы нет возможности просто сменить tablespace командой pg_archive — необходимо пересоздавать таблицу заново и учитывать ограничения. Таким образом, перенести в холодное хранилище мы можем почти всегда, и было бы расточительством не использовать такую возможность. Поэтому путь hot_row -> warm_row является приоритетным.

Это важная деталь: pg_ilm не обязан прыгать сразу в финальное состояние. Сначала можно переместить объект в холодный tablespace, сохранив row-oriented storage, а уже потом перейти к cold_columnar, если ничто не препятствует этому переходу.

Хотя от прямого перехода в текущей версии отказались из соображений безопасности и прозрачности, возможно, в следующей версии переход hot_row -> cold_columnar будет поддержан в случае, если у таблицы нет никаких блокеров. Иначе будет предлагаться путь hot_row -> warm_rowwarm_row -> cold_columnar, как это реализовано сейчас.

orders_hot получает skip:

target_table      | current_state | recommended_next_state | status    | reason
demo.orders_hot   | hot_row       | ∅                      | skip      | table does not satisfy idle/size thresholds yet
demo.orders_small | hot_row       | ∅                      | skip      | table does not satisfy idle/size thresholds yet

Таблица активна, flamegraph это показывает, правило для нее есть, но в очередь на перенос она не попадает. pg_ilm сообщает, что на текущий момент условия не выполняются, но таблица под наблюдением. orders_small тоже получает skip, но по другой причине: она не проходит порог размера. И это тоже нормальное поведение. Холодность сама по себе еще не повод что-то переносить. Если экономии нет, иногда лучше оставить таблицу в покое.

Blocked

А теперь самое вкусное:

demo.fk_blocked_orders | warm_row | cold_columnar | blocked | foreign_key
demo.identity_blocked  | warm_row | cold_columnar | blocked | identity_column

На первый взгляд может показаться, мол, не сработало. Но с точки зрения эксплуатации это как раз хороший результат. Система нашла объекты, которые по температуре и правилам выглядят подходящими кандидатами, но автоматически выполнять переход небезопасно:

  • у fk_blocked_orders есть внешний ключ.

  • у identity_blocked есть identity column.

Обе таблицы получают restore_risk = high.

То есть pg_ilm не пытается быть героем, который «сам все порешал», а честно отдает оператору состояние:

Вот кандидаты.
Вот исполнимые действия.
Вот объекты, где автоматический переход заблокирован.
Вот причина.

На мой взгляд, это одна из самых важных частей: уметь не только переносить, но и объяснять, почему перенос не выполняется.

Actionable recommendations: то, что можно выполнить

Отдельно посмотрим очередь исполнимых рекомендаций:

SELECT *
FROM ilm.list_actionable_recommendations()
WHERE target_table LIKE 'demo.%'
ORDER BY target_table;

Вывод:

target_table              | resolved_target_kind | recommended_next_state | prepared_call_sql
--------------------------+----------------------+------------------------+---------------------------------------------------------
demo.events_archive_202401| partition_leaf       | warm_row               | SELECT * FROM ilm.execute_recommendations(...)
demo.orders_cold          | regular_table        | warm_row               | SELECT * FROM ilm.execute_recommendations(...)
demo.orders_cooling       | regular_table        | warm_row               | SELECT * FROM ilm.execute_recommendations(...)

По своей сути это не весь список рекомендаций, а именно исполнимая очередь. В нее попадают объекты со статусом recommend, для которых сформирован prepared_call_sql. Объекты со статусом skip или blocked остаются видимыми в recommend_archive_actions, но не попадают в actionable queue.

Parent-to-leaf: правило на родителя, действие на партицию

Если необходимо посмотреть отдельно остывшие партиции, готовые к переносу, можно воспользоваться внутренней функцией поиска холодных партиций:

SELECT *
FROM ilm.find_cold_partitions('demo.events'::regclass, now())
ORDER BY partition_table::text;

Результат:

parent_table | partition_table          | last_write
-------------+--------------------------+-------------------------------
demo.events  | demo.events_archive_202401 | 2026-06-16 20:05:55.149571+03

Политика задается на родителя. Решение принимается по leaf-партиции. Действие выполняется над leaf-партицией. Казалось бы, мелочь, но в большой базе такие «мелочи» обычно и отделяют инструмент от набора ручных скриптов.

Dry-run: сначала смотрим, потом трогаем

Зачем же появился режим Dry-run. По идеологии ILM представляет из себя полуавтоматический инструмент: админ настроил политики и режим работы. Проходит время, настала пора оптимизировать дисковое пространство, админ суровым справедливым взглядом смотрит в ilm.list_actionable_recommendations() рекомендации, которые ему подготовил ILM. Дальше он по-хозяйски проверяет их по flamegraph, выбирает те, что его устраивают и подходят для текущего окна обслуживания. Теперь у него есть варианты, как выполнять рекомендации:

  • таргетно для каждой таблицы/партиции;

  • фильтром выбрать некоторые из них;

  • выполнить все доступные разом.

Именно для самопроверки таких пакетных переносов dry-run и создавался — чтобы сперва посмотреть, те ли таблицы были выбраны для пакетной обработки.

Есть важный нюанс: в момент вызова ilm.execute_recommendations() внутри пересчитываются свежие рекомендации. Поэтому dry-run показывает не «сохраненный вчера список», а актуальную очередь на момент вызова. Это осознанный компромисс: оператор получает наиболее свежую картину, но между dry-run и реальным execute состояние может измениться, если был вызван ilm.collect_stats_snapshot() вручную или по расписанию. Поэтому для пакетного выполнения все равно важно смотреть на состав очереди, окно обслуживания и доменную природу таблиц.

Я считаю, что даже в сокращенном варианте для Оператора надо предоставлять наиболее актуальную информацию. В следующей версии рассматривается вариант: пересчитывать актуальную очередь при dry-run, а для «боевого» вызова использовать последние подтвержденные рекомендации.

SELECT *
FROM ilm.execute_recommendations(
    p_target_table => NULL::regclass,
    p_dry_run      => TRUE,
    p_execute_all  => TRUE
);

Результат:

target_table              | executed | execution_error
--------------------------+----------+----------------
demo.events_archive_202401| f        | ∅
demo.orders_cold          | f        | ∅
demo.orders_cooling       | f        | ∅

executed = f — именно то, что мы хотим увидеть в dry-run. Система прошла по текущей actionable queue, проверила, что может быть выполнено, но ничего физически не изменила.

Если список нас устраивает, запускаем реальное выполнение:

SELECT *
FROM ilm.execute_recommendations(
    p_target_table => NULL::regclass,
    p_dry_run      => FALSE,
    p_execute_all  => TRUE
);

Результат:

target_table              | executed | execution_error
--------------------------+----------+----------------
demo.events_archive_202401| t        | ∅
demo.orders_cold          | t        | ∅
demo.orders_cooling       | t        | ∅

Вот теперь действия действительно выполнены. Ошибок не обнаружено, перенос проведен в штатном режиме.

Здесь стоит обратить внимание на режим:

p_target_table => NULL::regclass,
p_execute_all  => TRUE

Это batch-сценарий. Мы не вызываем перенос руками для каждой таблицы, а говорим: «выполни всю текущую исполнимую очередь».

Это удобно для оператора, но именно поэтому перед выполнением особенно важны:

  • рекомендации;

  • actionable queue;

  • dry-run;

  • понимание blocked/skip;

  • физическая проверка результата.

Проверяем физическое состояние

После первого перехода смотрим \d+ demo.orders_cold:

Табличное пространство: "ilm_demo_cold_ts"
Метод доступа: heap

И индексы:

orders_cold_pkey            | btree | 464 kB
orders_cold_created_at_idx  | btree | 464 kB
orders_cold_customer_id_idx | btree | 184 kB

Для orders_cooling аналогично:

"orders_cooling_pkey" PRIMARY KEY, btree (id), табл. пространство "ilm_demo_cold_ts"
"orders_cooling_created_at_idx" btree (created_at), табл. пространство "ilm_demo_cold_ts"

Табличное пространство: "ilm_demo_cold_ts"
Метод доступа: heap

То есть первый переход сделал ровно то, что и должен был: объекты переехали в cold tablespace, но остались heap. На этом шаге меняется физическое размещение объекта, но метод хранения еще не меняется.

Первый шаг в cold_columnar сделан, остался еще один шаг:

hot_row -> warm_row -> cold_columnar
           мы здесь

Сначала меняем физическое размещение, потом уже принимаем решение о преобразовании в columnar.

Это более осторожная модель. А осторожность в операциях с большими таблицами — это не недостаток, а способ не испортить себе и другим выходные)).

Второй переход: из warm_row в cold_columnar

После первого перехода даем объектам еще один период тишины:

SELECT pg_sleep(65);
SELECT pg_sleep(65);
SELECT ilm.collect_stats_snapshot();

-- В демо мы продолжаем поддерживать активность orders_hot,
-- чтобы она не стала кандидатом на перенос из-за искусственной паузы.
-- Это не часть ILM, а только эмуляция живой нагрузки.

Теперь снова считаем рекомендации:

SELECT *
FROM ilm.recommend_archive_actions(NULL, now(), FALSE)
WHERE target_table LIKE 'demo.%'
ORDER BY target_table;

Получаем:

target_table              | current_state | recommended_next_state | status    | reason
--------------------------+---------------+------------------------+-----------+----------------------------------------------------------
demo.events_archive_202401| warm_row      | cold_columnar          | recommend | columnar conversion of an already cold leaf is recommended
demo.fk_blocked_orders    | warm_row      | cold_columnar          | blocked   | table cannot transition to cold_columnar automatically...
demo.identity_blocked     | warm_row      | cold_columnar          | blocked   | table cannot transition to cold_columnar automatically...
demo.orders_cold          | warm_row      | cold_columnar          | recommend | columnar cold placement is recommended...
demo.orders_cooling       | warm_row      | cold_columnar          | recommend | columnar cold placement is recommended...
demo.orders_hot           | hot_row       | ∅                      | skip      | table does not satisfy idle/size thresholds yet
demo.orders_small         | hot_row       | ∅                      | skip      | table does not satisfy idle/size thresholds yet

Теперь actionable queue выглядит так:

target_table              | resolved_target_kind | recommended_next_state
--------------------------+----------------------+------------------------
demo.events_archive_202401| partition_leaf       | cold_columnar
demo.orders_cold          | regular_table        | cold_columnar
demo.orders_cooling       | regular_table        | cold_columnar

Опять сначала dry-run:

SELECT *
FROM ilm.execute_recommendations(
    p_target_table => NULL::regclass,
    p_dry_run      => TRUE,
    p_execute_all  => TRUE
);
target_table              | executed | execution_error
--------------------------+----------+----------------
demo.events_archive_202401| f        | ∅
demo.orders_cold          | f        | ∅
demo.orders_cooling       | f        | ∅
-- отлично, сюда не попала внезапно остывшая orders_hot

Потом выполнение:

SELECT *
FROM ilm.execute_recommendations(
    p_target_table => NULL::regclass,
    p_dry_run      => FALSE,
    p_execute_all  => TRUE
);
target_table              | executed | execution_error
--------------------------+----------+----------------
demo.events_archive_202401| t        | ∅
demo.orders_cold          | t        | ∅
demo.orders_cooling       | t        | ∅

На этом этапе объекты прошли второй переход.

Проверим, что они изменили свой метод хранения на columnar, а индексы не потерялись:

Таблица "demo.orders_cold"
Индексы:
    "orders_cold_pkey" PRIMARY KEY, btree (id), табл. пространство "ilm_demo_cold_ts"
    "orders_cold_created_at_idx" btree (created_at), табл. пространство "ilm_demo_cold_ts"
    "orders_cold_customer_id_idx" btree (customer_id), табл. пространство "ilm_demo_cold_ts"
Табличное пространство: "ilm_demo_cold_ts"
Метод доступа: columnar
Список индексов
 Схема |             Имя             |  Тип   | Владелец |   Таблица   |  Хранение  | Метод доступа | Размер | Описание 
-------+-----------------------------+--------+----------+-------------+------------+---------------+--------+----------
 demo  | orders_cold_created_at_idx  | индекс | postgres | orders_cold | постоянное | btree         | 456 kB | 
 demo  | orders_cold_customer_id_idx | индекс | postgres | orders_cold | постоянное | btree         | 160 kB | 
 demo  | orders_cold_pkey            | индекс | postgres | orders_cold | постоянное | btree         | 456 kB | 

Таблица "demo.orders_cooling"
Индексы:
    "orders_cooling_pkey" PRIMARY KEY, btree (id), табл. пространство "ilm_demo_cold_ts"
    "orders_cooling_created_at_idx" btree (created_at), табл. пространство "ilm_demo_cold_ts"
Табличное пространство: "ilm_demo_cold_ts"
Метод доступа: columnar
Список индексов
 Схема |              Имя              |  Тип   | Владелец |    Таблица     |  Хранение  | Метод доступа | Размер | Описание 
-------+-------------------------------+--------+----------+----------------+------------+---------------+--------+----------
 demo  | orders_cooling_created_at_idx | индекс | postgres | orders_cooling | постоянное | btree         | 368 kB | 
 demo  | orders_cooling_pkey           | индекс | postgres | orders_cooling | постоянное | btree         | 368 kB | 


Таблица "demo.events_archive_202401"
Секция: demo.events FOR VALUES FROM ('2024-01-01 03:00:00+03') TO ('2024-02-01 03:00:00+03')
Ограничение секции: ((event_ts IS NOT NULL) AND (event_ts >= '2024-01-01 03:00:00+03'::timestamp with time zone) AND (event_ts < '2024-02-01 03:00:00+03'::timestamp with time zone))
Индексы:
    "events_archive_202401_ts_idx" btree (event_ts)
Табличное пространство: "ilm_demo_cold_ts"
Метод доступа: columnar
Список индексов
 Схема |             Имя              |  Тип   | Владелец |        Таблица        |  Хранение  | Метод доступа | Размер | Описание 
-------+------------------------------+--------+----------+-----------------------+------------+---------------+--------+----------
 demo  | events_archive_202401_ts_idx | индекс | postgres | events_archive_202401 | постоянное | btree         | 344 kB | 

Как можно видеть, все замечательно перенеслось. Метод хранения изменился на columnar, ни один из индексов не отвалился, а партиционированная таблица сохранила своего родителя.

Финальная очередь: стакан наполовину пуст

Теперь самое интересное — финальные рекомендации.

SELECT *
FROM ilm.recommend_archive_actions(NULL, now(), FALSE)
WHERE target_table LIKE 'demo.%'
ORDER BY target_table;

Результат:

target_table             | current_state | recommended_next_state | status  | blocking_constraints | restore_risk
-------------------------+---------------+------------------------+---------+----------------------+-------------
demo.fk_blocked_orders   | warm_row      | cold_columnar          | blocked | foreign_key          | high
demo.identity_blocked    | warm_row      | cold_columnar          | blocked | identity_column      | high
demo.orders_hot          | hot_row       | ∅                      | skip    | ∅                    | low
demo.orders_small        | hot_row       | ∅                      | skip    | ∅                    | low

И вот тут легко попасть в ловушку: «Почему очередь не пустая? Мы же все выполнили!»

Потому что «все» — это только исполнимые рекомендации.

Blocked и skip — это тоже результат анализа, но не действие.

fk_blocked_orders и identity_blocked остались заблокированными.
orders_hot осталась горячей.
orders_small осталась слишком маленькой для переноса.

Это нормальное финальное состояние.

Если хочется увидеть пустую очередь, то стоит выполнить ilm.list_actionable_recommendations().

Ожидаемый результат после выполнения всех доступных действий:

(0 строк)

В промышленной системе список рекомендаций не обязан исчезать полностью. Это нормально: recommend_archive_actions продолжит показывать skip и blocked, а list_actionable_recommendations будет пустой.

Я считаю, что ILM должен оставлять после себя объяснимую картину:

  • вот что выполнено;

  • вот что пока рано трогать;

  • вот что нельзя выполнять автоматически;

  • вот почему.

Audit: что система думала во времени

В конце смотрим историю рекомендаций:

SELECT
    evaluated_at,
    count(*) AS rows_total,
    count(*) FILTER (WHERE recommendation_status = 'recommend') AS recommend_count,
    count(*) FILTER (WHERE recommendation_status = 'blocked') AS blocked_count,
    count(*) FILTER (WHERE recommendation_status = 'skip') AS skip_count
FROM ilm.archive_recommendation_history
WHERE target_table LIKE 'demo.%'
GROUP BY evaluated_at
ORDER BY evaluated_at;

Результат:

evaluated_at              | rows_total | recommend_count | blocked_count | skip_count
--------------------------+------------+-----------------+---------------+-----------
2026-06-16 20:05:00.0135  | 6          | 0               | 0             | 6
2026-06-16 20:10:00.0095  | 5          | 3               | 2             | 0

Первая точка — все еще skip. Вторая — три рекомендации и два blocked. А почему так мало, не сходится, - скажете вы и будете абсолютно правы. Это не ошибка вывода, а следствие того, что история рекомендаций хранит именно те оценки, которые были записаны в archive_recommendation_history, а не каждый ручной просмотр рекомендаций. Разворачиваем подробнее:

SELECT
    evaluated_at,
    target_table,
    resolved_target_kind,
    current_state,
    recommended_next_state,
    recommendation_status,
    blocking_constraints,
    restore_risk
FROM ilm.archive_recommendation_history
WHERE target_table LIKE 'demo.%'
ORDER BY evaluated_at, target_table;

Фрагмент результата:

        evaluated_at  |        target_table        | resolved_target_kind | current_state | recommended_next_state | recommendation_status | blocking_constraints | restore_risk 
-------------------- -+----------------------------+----------------------+---------------+------------------------+-----------------------+----------------------+--------------
 2026-06-16 20:05:00  | demo.fk_blocked_orders     | regular_table        | warm_row      | ∅                      | skip                  | ∅                    | low
 2026-06-16 20:05:00  | demo.identity_blocked      | regular_table        | warm_row      | ∅                      | skip                  | ∅                    | low
 2026-06-16 20:05:00  | demo.orders_cold           | regular_table        | hot_row       | ∅                      | skip                  | ∅                    | low
 2026-06-16 20:05:00  | demo.orders_cooling        | regular_table        | hot_row       | ∅                      | skip                  | ∅                    | low
 2026-06-16 20:05:00  | demo.orders_hot            | regular_table        | hot_row       | ∅                      | skip                  | ∅                    | low
 2026-06-16 20:05:00  | demo.orders_small          | regular_table        | hot_row       | ∅                      | skip                  | ∅                    | low
 -- первый автоматический сбор во время 2 фазы нагрузки
 2026-06-16 20:10:00  | demo.events_archive_202401 | partition_leaf       | warm_row      | cold_columnar          | recommend             | ∅                    | medium
 2026-06-16 20:10:00  | demo.fk_blocked_orders     | regular_table        | warm_row      | cold_columnar          | blocked               | foreign_key          | high
 2026-06-16 20:10:00  | demo.identity_blocked      | regular_table        | warm_row      | cold_columnar          | blocked               | identity_column      | high
 2026-06-16 20:10:00  | demo.orders_cold           | regular_table        | warm_row      | cold_columnar          | recommend             | ∅                    | high
 2026-06-16 20:10:00  | demo.orders_cooling        | regular_table        | warm_row      | cold_columnar          | recommend             | ∅                    | high
 -- второй автоматический сбор после выполнения переноса в холодное хранилище

Так-с, и что мы видим? Давайте разбираться. Первые 6 записей — это автоматическое срабатывание сбора рекомендаций, как раз когда мы были на 2 фазе и активно шевелили все данные. Изначально там было 7 записей, но между первым и вторым переносом произошел второй автоматический сбор рекомендаций, и demo.events_archive_202401 обновил свое значение на recommend. Но все равно кто-то мог пропустить — почему нет тех рекомендаций, на которые мы опирались на первом переносе? Разгадка проста: в основном сценарии ручной просмотр рекомендаций выполнялся так:

SELECT *
FROM ilm.recommend_archive_actions(NULL, now(), FALSE);
-- здесь нас интересует последняя переменная

Последний параметр здесь отвечает за запись результата в историю. При FALSE мы получаем рекомендации на экран, но не сохраняем этот ручной просмотр в ilm.archive_recommendation_history.

Это удобно для быстрых проверок, но плохо подходит, если потом хочется восстановить всю цепочку решений по журналу.

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

SELECT *
FROM ilm.recommend_archive_actions(NULL, now(), TRUE);

Таким образом я показал контрпример, когда злоупотребление одной из функций инструмента может привести к снижению эффективности другой части расширения. Но контрпример полезен, когда его можно сравнить с хорошим. Поэтому пример, где рекомендации были собраны в историю, ниже:

 evaluated_at|        target_table        | resolved_target_kind | current_state | recommended_next_state | recommendation_status | blocking_constraints | restore_risk 
-------------+----------------------------+----------------------+---------------+------------------------+-----------------------+----------------------+--------------
 20:15:00    | demo.fk_blocked_orders     | regular_table        | warm_row      | ∅                      | skip                  | ∅                    | low
 20:15:00    | demo.identity_blocked      | regular_table        | warm_row      | ∅                      | skip                  | ∅                    | low
 20:15:00    | demo.orders_cold           | regular_table        | hot_row       | ∅                      | skip                  | ∅                    | low
 20:15:00    | demo.orders_cooling        | regular_table        | hot_row       | ∅                      | skip                  | ∅                    | low
 20:15:00    | demo.orders_hot            | regular_table        | hot_row       | ∅                      | skip                  | ∅                    | low
 20:15:00    | demo.orders_small          | regular_table        | hot_row       | ∅                      | skip                  | ∅                    | low
 -- первый автоматический сбор рекомендаций во время нагрузки
 20:20:00    | demo.events_archive_202401 | partition_leaf       | hot_row       | warm_row               | recommend             | ∅                    | medium
 20:20:00    | demo.fk_blocked_orders     | regular_table        | warm_row      | cold_columnar          | blocked               | foreign_key          | high
 20:20:00    | demo.identity_blocked      | regular_table        | warm_row      | cold_columnar          | blocked               | identity_column      | high
 20:20:00    | demo.orders_cold           | regular_table        | hot_row       | warm_row               | recommend             | ∅                    | low
 20:20:00    | demo.orders_cooling        | regular_table        | hot_row       | warm_row               | recommend             | ∅                    | low
 -- второй автоматический сбор рекомендаций на последней фазе нагрузок
 20:21:45    | demo.orders_hot            | regular_table        | hot_row       | warm_row               | recommend             | ∅                    | low
 -- первый ручной сбор перед первым переносом в холодный tablespace
 20:21:51    | demo.events_archive_202401 | partition_leaf       | warm_row      | cold_columnar          | recommend             | ∅                    | medium
 20:21:51    | demo.orders_cold           | regular_table        | warm_row      | cold_columnar          | recommend             | ∅                    | high
 20:21:51    | demo.orders_cooling        | regular_table        | warm_row      | cold_columnar          | recommend             | ∅                    | high
 20:21:51    | demo.orders_hot            | regular_table        | warm_row      | ∅                      | skip                  | ∅                    | low
--второй ручной сбор перед переносом в columnar

Это отдельный прогон того же сценария, выполненный с записью ручных рекомендаций в историю. Время отличается, но последовательность состояний та же: сначала объекты находятся под нагрузкой, затем часть из них становится кандидатами, а после первого переноса появляются рекомендации на переход в cold_columnar.

Так же, как и в первом примере, первые 6 рекомендаций собираются во время нагрузки. А вот второй блок отличается. Теперь в него попала последняя фаза нагрузки перед первым ручным сбором рекомендаций. Это видно по полю текущего и целевого состояния.

В третьем блоке есть интересный артефакт. Так как на втором прогоне я не эмулировал, дополнительную нагрузку после пятой фазы, во время ручного сбора рекомендаций мы попали в момент, когда orders_hot не записывалась 2 минуты и временно попала в журнал рекомендаций. Она была перенесена в холодное хранилище. Но потом нагрузка восстановилась, и дальнейший переход в колумнар не был рекомендован. Все сработало согласно политикам, поэтому важно и участие администратора, который понимает, что, возможно, для этой таблицы это всего лишь затишье перед бурей сводного отчета за квартал.

Практический вывод простой: для таких таблиц порог остывания нужно задавать с запасом, а пакетные действия выполнять в понятном окне обслуживания. Как раз для защиты от подобных ситуаций в ILM gamma планируется рассмотреть путь разогрева данных. Он пригодится, если таблица была перенесена, но вы передумали или ошиблись. Согласитесь, наличие у инструмента функции ctrl+Z отлично снижает психологический накал при миграции данных.

Место для шутки про «Галя, отмена!»

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

  • когда объект стал кандидатом;

  • почему раньше он им не был;

  • какие объекты были заблокированы;

  • какие риски были зафиксированы;

  • как менялась картина рекомендаций во времени.

Для оператора это почти как черный ящик самолета, только, к счастью, смотреть его можно до падения, а не после.

Бенчмарк: бонус за ваше внимание

Для тех, кто дошел до конца статьи, я подготовил лог небольшого синтетического бенчмарка. Его цель — не дать универсальные цифры производительности для любого железа, а показать порядок временны́х затрат на перенос, объем WAL и разницу в размере после перехода в columnar. Это важно, потому что при переходе в columnar таблица пересоздается, и на время пересоздания берется EXCLUSIVE LOCK. Значит, DBA должен понимать хотя бы примерный масштаб операции. В бенчмарке создано по пять таблиц с шагом размера примерно в два раза: от 63 MB до 1000 MB.

Для данных использованы два профиля:

  • compressible — хорошо сжимаемые повторяющиеся данные, синтетический лучший случай;

  • pseudo_random — псевдослучайные данные, которые сжимаются заметно хуже.

Ниже приведен один прогон на демостенде. Эти цифры стоит воспринимать как иллюстрацию поведения, а не как универсальный performance benchmark: на результат будут влиять диск, файловая система, настройки PostgreSQL, параллельная нагрузка и состояние кэша.

transition_stage                     |    target_table   | executed | execution_error | elapsed_ms | wal_generated |         before_state     |         after_state         | before_relation_size | after_relation_size | before_total_size | after_total_size | row_count 
-------------------------------------+-------------------+----------+-----------------+------------+---------------+--------------------------+-----------------------------+----------------------+---------------------+-------------------+------------------+-----------
 h2c_step1_hot_row_to_warm_row       | bench.h2c_cmp_s1  | t        | ∅               |     81.943 | 62 MB         | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 63 MB                | 63 MB               | 63 MB             | 63 MB            |     40000
 h2c_step1_hot_row_to_warm_row       | bench.h2c_cmp_s2  | t        | ∅               |    150.135 | 130 MB        | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 125 MB               | 125 MB              | 125 MB            | 125 MB           |     80000
 h2c_step1_hot_row_to_warm_row       | bench.h2c_cmp_s3  | t        | ∅               |    322.091 | 257 MB        | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 250 MB               | 250 MB              | 250 MB            | 250 MB           |    160000
 h2c_step1_hot_row_to_warm_row       | bench.h2c_cmp_s4  | t        | ∅               |   1419.571 | 532 MB        | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 500 MB               | 500 MB              | 500 MB            | 500 MB           |    320000
 h2c_step1_hot_row_to_warm_row       | bench.h2c_cmp_s5  | t        | ∅               |   4593.762 | 1134 MB       | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 1000 MB              | 1000 MB             | 1000 MB           | 1000 MB          |    640000

 h2c_step1_hot_row_to_warm_row       | bench.h2c_rnd_s1  | t        | ∅               |     97.799 | 63 MB         | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 63 MB                | 63 MB               | 63 MB             | 63 MB            |     40000
 h2c_step1_hot_row_to_warm_row       | bench.h2c_rnd_s2  | t        | ∅               |   1179.222 | 158 MB        | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 125 MB               | 125 MB              | 125 MB            | 125 MB           |     80000
 h2c_step1_hot_row_to_warm_row       | bench.h2c_rnd_s3  | t        | ∅               |   1972.599 | 297 MB        | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 250 MB               | 250 MB              | 250 MB            | 250 MB           |    160000
 h2c_step1_hot_row_to_warm_row       | bench.h2c_rnd_s4  | t        | ∅               |   3188.061 | 564 MB        | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 500 MB               | 500 MB              | 500 MB            | 500 MB           |    320000
 h2c_step1_hot_row_to_warm_row       | bench.h2c_rnd_s5  | t        | ∅               |   4754.354 | 1021 MB       | heap @ ilm_bench_hot_ts  | heap @ ilm_bench_cold_ts    | 1000 MB              | 1000 MB             | 1000 MB           | 1000 MB          |    640000

transition_stage                     |   target_table   | executed | execution_error | elapsed_ms | wal_generated |       before_state       |         after_state          | before_relation_size | after_relation_size | before_total_size | after_total_size | row_count 
-------------------------------------+------------------+----------+-----------------+------------+---------------+--------------------------+------------------------------+----------------------+---------------------+-------------------+------------------+-----------
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_cmp_s1 | t        | ∅               |     74.411 | 1826 kB       | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 63 MB                | 408 kB              | 63 MB             | 408 kB           |     40000
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_cmp_s2 | t        | ∅               |    101.115 | 2807 kB       | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 125 MB               | 784 kB              | 125 MB            | 784 kB           |     80000
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_cmp_s3 | t        | ∅               |    178.110 | 4774 kB       | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 250 MB               | 1544 kB             | 250 MB            | 1544 kB          |    160000
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_cmp_s4 | t        | ∅               |    381.064 | 16 MB         | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 500 MB               | 3072 kB             | 500 MB            | 3072 kB          |    320000
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_cmp_s5 | t        | ∅               |   1777.281 | 58 MB         | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 1000 MB              | 6112 kB             | 1000 MB           | 6112 kB          |    640000

 h2c_step2_warm_row_to_cold_columnar | bench.h2c_rnd_s1 | t        | ∅               |    416.935 | 30 MB         | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 63 MB                | 31 MB               | 63 MB             | 31 MB            |     40000
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_rnd_s2 | t        | ∅               |    867.998 | 88 MB         | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 125 MB               | 63 MB               | 125 MB            | 63 MB            |     80000
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_rnd_s3 | t        | ∅               |   2657.521 | 200 MB        | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 250 MB               | 126 MB              | 250 MB            | 126 MB           |    160000
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_rnd_s4 | t        | ∅               |   4308.781 | 310 MB        | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 500 MB               | 251 MB              | 500 MB            | 251 MB           |    320000
 h2c_step2_warm_row_to_cold_columnar | bench.h2c_rnd_s5 | t        | ∅               |   8219.980 | 537 MB        | heap @ ilm_bench_cold_ts | columnar @ ilm_bench_cold_ts | 1000 MB              | 502 MB              | 1000 MB           | 502 MB           |    640000

  path  | compression_profile |    target_table     |                                 transitions                                  | wal_total | elapsed_ms_total | max_before_relation_size | min_after_relation_size | best_relation_savings_pct |  any_error                  
--------+---------------------+---------------------+------------------------------------------------------------------------------+-----------+------------------+--------------------------+-------------------------+---------------------------+------------
 h2c    | compressible        | bench.h2c_cmp_s1    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 64 MB     |          156.354 | 63 MB                    | 408 kB                  |                     99.36 | ∅
 h2c    | compressible        | bench.h2c_cmp_s2    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 133 MB    |          251.250 | 125 MB                   | 784 kB                  |                     99.39 | ∅
 h2c    | compressible        | bench.h2c_cmp_s3    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 262 MB    |          500.201 | 250 MB                   | 1544 kB                 |                     99.40 | ∅
 h2c    | compressible        | bench.h2c_cmp_s4    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 548 MB    |         1800.635 | 500 MB                   | 3072 kB                 |                     99.40 | ∅
 h2c    | compressible        | bench.h2c_cmp_s5    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 1192 MB   |         6371.043 | 1000 MB                  | 6112 kB                 |                     99.40 | ∅
 
 h2c    | pseudo_random       | bench.h2c_rnd_s1    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 93 MB     |          514.734 | 63 MB                    | 31 MB                   |                     49.76 | ∅
 h2c    | pseudo_random       | bench.h2c_rnd_s2    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 246 MB    |         2047.220 | 125 MB                   | 63 MB                   |                     49.78 | ∅
 h2c    | pseudo_random       | bench.h2c_rnd_s3    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 497 MB    |         4630.120 | 250 MB                   | 126 MB                  |                     49.79 | ∅
 h2c    | pseudo_random       | bench.h2c_rnd_s4    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 873 MB    |         7496.842 | 500 MB                   | 251 MB                  |                     49.80 | ∅
 h2c    | pseudo_random       | bench.h2c_rnd_s5    | h2c_step1_hot_row_to_warm_row:true, h2c_step2_warm_row_to_cold_columnar:true | 1558 MB   |        12974.334 | 1000 MB                  | 502 MB                  |                     49.80 | ∅

По результатам этого прогона видно:

  • время переноса растет вместе с размером таблицы, но по одному прогону нельзя делать строгие выводы о линейности;

  • на время влияют не только размер таблицы, но и профиль данных, WAL, диск, кэш и параллельные процессы;

  • хорошо сжимаемые синтетические данные в columnar занимают на порядки меньше места;

  • псевдослучайные данные сжались примерно на 50%, но переносились и преобразовывались дольше. Главный практический вывод: перед массовым применением ILM на больших таблицах полезно прогнать похожий тест на своем стенде и своих данных.

Дополнительные материалы

Пока писал статью, заодно собирал материал для отдела обучения и подготовил некоторую инфографику по спецификации.

Так можно нагляднее продемонстрировать архитектуру, которая была принята для pg_ilm beta, дать понимание, что от чего зависит (и использовать как своего рода шпаргалку, если уж совсем лень сразу идти в документацию.)

struct
struct

Заключение

В этом демосценарии я хотел показать, что pg_ilm — это не какое-то магическое средство от всех проблем старых таблиц.

Это инструмент, который помогает администратору пройти цепочку:

       посмотреть активность
    -> понять жизненный цикл
    -> получить рекомендации
    -> отделить recommend от skip и blocked
    -> сделать dry-run
    -> выполнить безопасную очередь
    -> проверить физическое состояние
    -> посмотреть audit

В этом демо есть несколько важных мыслей.

Cooling-данные интереснее cold-данных

orders_cold скучная: записали и забыли.
orders_cooling полезнее для демонстрации: она сначала активна, потом перестает получать записи, потом попадает в очередь.

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

Старость по бизнес-дате — не то же самое, что холодность

events_archive_202401 старая по event_ts, но для решения важна не только дата в партиции. Важна еще фактическая активность, последнее изменение и соответствие политике.

Рекомендация — не приказ

recommend_archive_actions показывает возможное действие.
list_actionable_recommendations показывает исполнимую очередь.
execute_recommendations(..., p_dry_run => TRUE) дает безопасную проверку.

И только потом оператор решает, выполнять или нет.

Blocked — это не ошибка

foreign_key и identity_column в демо специально оставлены, чтобы показать: система умеет не только предлагать действие, но и объяснять, почему автоматический переход невозможен.

Если бы эти таблицы начинали в горячем tablespace, их можно было бы перенести в холодный слой как warm_row.

Ограничения foreign_key и identity_column в этом сценарии блокируют именно автоматический переход в columnar, а не сам факт наблюдения или первичного переноса в холодное табличное пространство.

Финальная очередь не обязана быть пустой

После выполнения исполнимых рекомендаций остаются skip и blocked. Это нормально. ILM не должен делать вид, что любой объект можно привести к идеальному состоянию одной кнопкой.

----------------------------

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

В первой статье мы познакомились с pg_ilm alpha как с кладовщиком с градусником: он смотрит на температуру данных и помогает понять, кому пора сменить полку. В pg_ilm beta мы дали ему автопогрузчик, ведомости, логистические накладные и журнал. В этой статье мы дали ему несколько коробок разной степени подозрительности и подробно посмотрели на его рабочий день: разобрали, как и почему он принимает решения.

Что получилось:

  • горячие данные остались горячими;

  • остывающие данные дождались своего окна;

  • старая leaf-партиция была найдена через правило на родителя;

  • исполнимые рекомендации прошли через dry-run и batch execution;

  • физическое состояние было проверено через \d+;

  • заблокированные объекты остались заблокированными с понятной причиной;

  • история рекомендаций сохранила то, что система думала во времени.

На этом месте обычно хочется сказать, что теперь можно запускать ILM на все подряд и спокойно пить чай. Но нет. В промышленной эксплуатации к этому сценарию добавятся длинные транзакции, ночные batch-задачи, late events, особенности backup/restore, расписания обслуживания, SLA, размеры индексов, ограничения железа и человеческий фактор, который, как известно, умеет делать невозможное реальным. Поэтому главный вывод осторожнее:

pg_ilm не заменяет администратора. Он дает администратору наблюдаемую, объяснимую и воспроизводимую основу для решения поставленных задач.

В третьей части мы рассмотрим roadmap для pg_ilm gamma, который сейчас находится в разработке, обсудим ваши комментарии и предложения и подумаем, как сделать работу нашего кладовщика еще эффективнее, и что понадобится, чтобы он стал для вас приятным коллегой, а не кадром, ради которого создается отдельный чат без него)).


Другие статьи по теме реализации ILM в Tantor Postgres:

Интересно? Подписывайтесь на наш блог.