pg_ilm — гибрид кладовщика с градусником для ваших данных (Information Lifeсycle Management в Tantor Postgres 18)

от автора

Забегая вперед

Это первая из трех статей о расширении, которое появилось в 18 версии СУБД Tantor Postgres и которое может значительно упростить жизнь всем причастным к организации размещения данных и оптимизации хранилищ баз данных.

Во-первых, оно предоставляет дополнительные инструменты и функции, которые упрощают контроль за состоянием данных с точки зрения их жизненного цикла (Горячие данные —> Остывающие данные —> Холодные данные). Во-вторых, расширение частично автоматизирует перенос таблиц и партиций, в которых хранятся данные, в колоночное хранилище и/или на более дешевый носитель — и не как повезет, а согласно заданным правилам.

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

Рад знакомству

Всем привет, меня зовут Артем Бугаенко, я разработчик СУБД в Тантор Лабс. Мы можем быть знакомы по PG BootCamp в Казани или Екатеринбурге. В этом году с выступлением на PG BootCamp не сложилось, и коллеги предложили поделиться новостями разработки через статью на Хабр. Это моя первая статья в сообществе, но сам я, будучи читателем, ощутимо устал от обилия дегенеративного контента, так что эта статья написана с использованием только натурального интеллекта (да-да, натуральный интеллект тоже умеет в длинные тире — проще нажать Alt+0151, чем потом откачивать редактора, любящего типографские правила))).

Вступление

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

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

  • на быстром, но ограниченном в объёме дорогом хранилище (in-memory, NVME/PCIE SSD);

  • на среднем по скорости, большем в объёме, более дешевом хранилище (SATA SSD);

  • на наиболее медленном, кратно большем в объёме, наиболее дешевом хранилище (HDD).

Также в зависимости от своего жизненного пути данные могут переходить от OLTP- к OLAP-профилю нагрузки.

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

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

Различные профили нагрузки и конкретные клиенты имеют разные профили распределения горячих, тёплых и холодных данных. Да, они разнятся в деталях в зависимости от области применения данных и бизнес-процессов, однако в целом картина такова:

  • доля горячих данных не превышает 20%;

  • доля тёплых данных не превышает 40%;

  • всё остальное место занимают холодные (по сути, архивные) данные.

Разделение этих данных по разным уровням хранения приносит экономию до 80% затрат на носители или их аренду.

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

Тип хранилища

Рублей за 1 ГБ

DDR5

1300

NVME M.2 SSD

15

HDD

4

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

ILM-alpha (сентябрь-декабрь 2025)

От концепта в продакшен

Среди сонма полезных, но весьма рутинных задач, возникла одна необычная: «А что, если мы сможем дать пользователю удобный инструмент, который облегчит анализ и работы по поддержанию жизненного цикла остывания данных?» Внутри этой задачи была диаграмма с наброском этапов реализации этой задумки и небольшое обсуждение по первому этапу реализации…

Ну и, как говорится, глаза боятся, а руки делают. Как раз отгремела пора отпусков, и по всем направлениям возобновилась движуха. В цехе кипит работа по 18 релизу, дезинсекторы ловят всякую нечисть по запросам поддержки, а инженеры, ловко орудуя напильниками, дотачивают 17 релиз до идеальной аэродинамической формы, выигрывая вожделенные проценты производительности под нагрузками. В стороне не осталась и наша команда. Первым препятствием на пути к ILM было пройти путь от концепции до создания минимальной реализации продукта в ограниченный срок. Мы собрали архитектурный комитет, достали ножи и стали отсекать от проекта всё, что было на наш взгляд избыточно и необязательно для включения в первую версию.

Какие данные нам необходимы для статистики? Как мы будем её собирать? Как определить, к какой категории относится та или иная таблица? Какие типы таблиц будем поддерживать? Что можем использовать из функционала уже имеющихся расширений? Каким будет сценарий работы с расширением? Каковы затраты на его работу? Как мы будем охлаждать данные — через перенос в другой tablespace или перевод в колоночное хранилище? Можем ли мы избежать блокировок?

Вопросов было гораздо больше, чем ответов. К концу совещания сошлись на мнении, что стоит реализовать первые два этапа с диаграммы. По итогу там остались:

  1. Сбор метрик для построения профиля нагрузки таблиц за определённый период.

  2. Функции для наглядного представления активности использования таблиц.

  3. Функции для переноса таблиц или партиций в колоночное хранилище.

Всё, таким образом ILM-alpha превратился в information lifecycle monitor с дополнительным функционалом. Но это был MVP для проверки гипотез и дальнейших исследований того, с какими проблемами мы столкнемся на пути к полноценному решению.

Сбор метрик, или сколько вешать граммов

Этот этап определил весь ход разработки. Вариантов развития событий было два:

  1. Можно построить этот процесс по уже известной схеме: реализация хитмап на хуках через запись времени последней выполненной операции каждого типа (Insert, Delete, Update и так далее) для определенного пула пользователей.

  2. Либо можно поискать подход с использованием того, что уже было реализовано в Postgres, а именно — представление pg_statio_all_tables.

Сперва мы реализовали и опробовали первый вариант, но тут во время очередного совещания прозвучало предложение от R&D реализовать сценарий номер два. Посовещавщись, мы выбрали его: он манил новизной, эффективным подходом и элегантностью решения. Например, есть большая старая таблица, раз в неделю в нее прилетает одна запись или модифицируется одна строка. В первом подходе мы видим, что с таблицей активно работают. Во втором подходе мы можем оценить ещё и объём активности таблицы. Это даёт возможность признать таблицу остывшей, и не держать гигабайты в горячем хранилище ради редких операций. Нужен был счётчик модификаций страниц для конкретного RelId, а из хука его не забрать. Да, для наших целей ванильный pg_statio_all_tables подходил не идеально, так что его нужно было немного прокачать…

Для этого мы добавили статистику по модифицированным блокам heap_blks_modified при помощи модификации функций MarkBufferDirty и MarkBufferDirtyHint. А так как в функциях нет указателя на Relation, но есть RelFileNumber, мы добавили хэш-таблицу, ключом которой является сам RelFileNumber, а значением — указатель на счетчик PgStat_TableCounts (&rel -> pgstat_info -> counts) для инкрементирования нового поля — blocks_modified. Вставку значения в хэш-таблицу реализовали в pgstat_assoc_relation, а удаление — через pgstat_unlink_relation.
Пройдя путь через пачку сегфолтов, несколько код ревью и пару рефакторингов мы получили необходимый нам инструмент. Но этого мало: надо нашу статистику сложить для дальнейшей нарезки и анализа.

Здесь нам идеально зашли pg_partman и pg_cron. При помощи первого мы создали партиционированную таблицу ilm_stats_history, для которой мы можем настраивать шаг разбиения и срок хранения нашей статистики. А использование pg_cron дало возможность вызывать функцию сбора статистики для дальнейшего анализа с необходимым интервалом.

Вот пример того, как выглядит ilm_stats_history на тестовом сценарии:

Пламенные графы, или как наглядно представить температуру

После того, как сбор статистики был завершен, встал следующий вопрос: как сделать наше представление наглядным? Мы решили использовать Flamegraph по срезам ilm_stats_history. В целом было ясно, что для срезов можно предоставить следующие варианты:

  • день;

  • месяц;

  • квартал;

  • полугодие;

  • год;

  • произвольный интервал в днях.

Так как реализация срезов данных была реализована в процессе, половина задачи была сделана.

Пример срезов ilm.stats_history:

select * from ilm.stats_history;         snapshot_time         | reloid | schemaname |  relname   | heap_blks_read | blocks_dirtied |      last_blocks_dirtied      |       last_blocks_read        | stats_reset -------------------------------+--------+------------+------------+----------------+----------------+-------------------------------+-------------------------------+------------- 2025-10-15 14:48:00.032645+03 |  17543 | public     | test_table |              0 |              0 |                               |                               |  2025-10-15 14:49:00.025756+03 |  17543 | public     | test_table |              0 |           4429 | 2025-10-15 14:48:27.051258+03 | 2025-10-15 14:48:27.051258+03 |  2025-10-15 14:50:00.018106+03 |  17543 | public     | test_table |              0 |           4429 | 2025-10-15 14:48:27.051258+03 | 2025-10-15 14:48:27.051258+03 | (3 rows)postgres=# select * from ilm.get_stats_for_period('1 minute'); reloid | schemaname |  relname   | total_heap_blks_read | total_blocks_dirtied | snapshot_count |         period_start          |          period_end           | resets_detected --------+------------+------------+----------------------+----------------------+----------------+-------------------------------+-------------------------------+-----------------  17543 | public     | test_table |                    0 |                    0 |              1 | 2025-10-15 14:50:00.018106+03 | 2025-10-15 14:50:00.018106+03 |               1(1 row)postgres=# select * from ilm.get_stats_for_period('2 minute'); reloid | schemaname |  relname   | total_heap_blks_read | total_blocks_dirtied | snapshot_count |         period_start          |          period_end           | resets_detected --------+------------+------------+----------------------+----------------------+----------------+-------------------------------+-------------------------------+-----------------  17543 | public     | test_table |                    0 |                 4429 |              2 | 2025-10-15 14:49:00.025756+03 | 2025-10-15 14:50:00.018106+03 |               2(1 row)postgres=# select * from ilm.get_stats_for_period('3 minute'); reloid | schemaname |  relname   | total_heap_blks_read | total_blocks_dirtied | snapshot_count |         period_start          |          period_end           | resets_detected --------+------------+------------+----------------------+----------------------+----------------+-------------------------------+-------------------------------+-----------------  17543 | public     | test_table |                    0 |                 4429 |              3 | 2025-10-15 14:48:00.032645+03 | 2025-10-15 14:50:00.018106+03 |               3(1 row)postgres=# select * from ilm.get_stats_for_period('3 minute'); reloid | schemaname |  relname   | total_heap_blks_read | total_blocks_dirtied | snapshot_count |         period_start          |          period_end           | resets_detected --------+------------+------------+----------------------+----------------------+----------------+-------------------------------+-------------------------------+-----------------  17543 | public     | test_table |                    0 |                    0 |              3 | 2025-10-15 14:53:00.020784+03 | 2025-10-15 14:55:00.020426+03 |               3(1 row)postgres=# select * from ilm.get_stats_for_period('10 minute'); reloid | schemaname |  relname   | total_heap_blks_read | total_blocks_dirtied | snapshot_count |         period_start          |          period_end           | resets_detected --------+------------+------------+----------------------+----------------------+----------------+-------------------------------+-------------------------------+-----------------  17543 | public     | test_table |                    0 |                 4429 |              8 | 2025-10-15 14:48:00.032645+03 | 2025-10-15 14:55:00.020426+03 |               8

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

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

Но такое решение меня не устроило, потому что оно не учитывало размеры таблиц. Одна большая таблица могла изменением 10% своего объёма сделать так, что небольшая таблица, которую активно используют, показывала нулевую активность. Ещё мне не нравилось, что шкала заполняется линейно, хотелось что-то с этим сделать, потому что тестовые данные в таком представлении были сильно прижаты к экстремальным значениям. Это сильно снижало наглядность.

Так что размеры таблиц явно надо было учитывать. Поэтому я ввёл dirty_ratio :

WHEN sz.relation_size_bytes > 0THEN s.total_blocks_dirtied::numeric    / GREATEST(1, sz.relation_size_bytes / current_setting('block_size')::int)::numericELSE 0::numeric

и пришел к такому виду:

  WHEN max_dirty_ratio <= 0 THEN 0::numeric      ELSE log(10, 1 + dirty_ratio) / log(10, 1 + max_dirty_ratio)  AS activity_norm...GREATEST(0, LEAST(20, floor(activity_norm * 20)::int)) AS filled...repeat('█', filled) || repeat(' ', 20 - filled) AS flame

И вот здесь предлагаю написать ваше мнение о таком решении в комментариях — будет интересно обсудить и найти более элегантный подход. Но на тот момент результат меня полностью устроил.

Ниже представлен пример flamegraph для тестового сценария:

Flamegraph даёт наглядное и числовое представление о активности использования таблиц и партиций. Можно увидеть их размер, количество вошедших снапшотов, а также начало и конец периода последнего сбора статистики, на которую опирался flamegraph. По сценарию планировалось, что администратор вызывает flamegraph для выбора дальнейших кандидатов, и при этом не только видит текущую активность, но и согласно размеру таблицы получает некоторое представление о том, какую экономию даст перенос и сколько времени потребуется на этот процесс.

Колумнаризируй это!

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

К слову, что вы знаете о созависимых отношениях?… Забавный факт — в тот момент ILM стал самым «зависимым» расширением: хранение статистики — pg_partman, расписания — pg_cron, операции переноса — pg_archive, а для того, куда будем переносить —columnar. Однако здесь тоже всё было не так гладко: pg_archive умел работать только для партиций, но не для обычных таблиц. Также были ограничения по внешним ключам. Но что больше всего зацепило наш взгляд — это эксклюзивная блокировка на партицию, так как для переноса данных в columnar надо было пересоздавать партицию в формате columnar, переносить данные и переподцеплять новую колоночную партицию к родителю.

Поэтому были созданы два трека:

  1. Альтернативные способы переноса в columnar для обхода эксклюзивной блокировки.

  2. Расширение функционала pg_archive для работы с регулярными таблицами.

Пока за первый трек взялся R&D отдел, мы приступили к расширению функционала, и здесь всё прошло как по нотам. Мы не только реализовали перенос регулярных таблиц в колоночное хранилище, но и реализовали обратную «деколумнаризацию» и сбор статистики по результатам переноса: сколько таблиц было перенесено и каков оказался выигрыш по пространству после переноса.

Поэтому за неделю до нового года мы написали тесты, документацию, и сделали подарок внутренним тестировщикам, положив под ёлочку наш ILM-alpha. А меня тем временем обрадовали, что дальнейшая разработка ILM закрепляется за мной.

Впереди маячил ILM-beta и грандиозные планы по расширению функционала. В атмосфере витал запах хвои и мандаринов, но я ещё не знал, что ждет меня в январе…

Pg_ILM-beta (январь-март 2026)

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

Первым делом собрал обратную связь от коллег, взял свой список идей для дальнейшего развития ILM и вприпрыжку помчался на архитектурный комитет. Совещались долго. Хотелось всё и сразу, но надо было сохранять реалистичный подход. По итогам совещания было принято решение выдать нашему «кладовщику» нормативные документы, карту и автопогрузчик. В дополнение к имеющемуся функционалу требовалось:

  1. Осуществить перенос не только в другую форму хранения, но и между табличными пространствами — для реализации ключевого переноса на «холодные» хранилища.

  2. Сформировать систему правил и политик для автоматического разделения и маркировки данных по их текущему состоянию жизненного цикла.

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

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

  5. Спроектировать журнал для ведения учёта того, какие рекомендации были выполнены.

  6. Реализовать предоставление дополнительной информации, если перенос нельзя произвести в автоматическом режиме.

  7. Обеспечить защиту от ошибок и режим Dry-run для проверки функционала без реального переноса.

Отлично, подумал я и сел за реализацию требований, точнее, за планирование, как же это реализовывать. Моё лицо в тот момент:

Решения, которые были хороши для MVP альфа-версии, совершенно не подходили для ее дальнейшего масштабирования. Требовалось как минимум переписать архитектуру расширения. А еще — добавить модульность и спланировать функционал так, чтобы не пришлось всё переделывать на этапе, когда будет пора реализовывать требования ILM-gamma.

Но сперва меня ждал перенос с 17 версии, на которой велась разработка альфы, на 18 версию Postgres…

Конец первой части.

Послесловие

Эта статья получилась обзорной, а вот следующую часть я планирую сделать более технической: больше диаграмм, схем, примеров с результатами и кода. Мы рассмотрим практическое применение ILM-beta на тестовом сценарии в том виде, в котором оно войдёт в 18 релиз, с комментариями от разработчика.

Спасибо за внимание!

Буду рад обратной связи, до встречи в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1044180/