Таблица логов растёт незаметно, пока в один прекрасный день не превращается в чугунную плиту, которую база ворочает с трудом. Сто миллионов новых записей каждый месяц это около трёх с лишним миллионов строк в сутки, и обычная неразбитая таблица под такой поток начинает буксовать на каждой операции обслуживания. Удаление старых данных блокирует всё подряд, очистка тянется часами, а индексы пухнут. Партиционирование по дате решает проблему в корне, а расширение pg_partman снимает с инженера ручную возню по созданию и удалению секций. Разберём настройку так, как её делают в живом проекте, а не в стерильном примере из документации.
Зачем вообще резать таблицу логов на секции по дате
Идея партиционирования проста до банальности: одна гигантская таблица разрезается на множество мелких по значению ключа, в нашем случае по дате записи. PostgreSQL после этого направляет запрос только в те секции, где реально могут лежать нужные строки, и отсекает остальные ещё на этапе планирования. Для запроса вида "покажи логи за вчера" это означает обращение к одной дневной секции вместо сканирования сотен миллионов строк.
Но выигрыш в скорости чтения это лишь часть истории. Настоящая магия проявляется в обслуживании. Без партиционирования операция над таблицей в миллиард строк блокирует весь набор данных, а удаление старых записей через DELETE превращается в многочасовую пытку, которая ещё и оставляет после себя горы мёртвых строк для последующей очистки. С секциями всё иначе: чтобы избавиться от данных месячной давности, достаточно отцепить и удалить целую секцию одной быстрой операцией. Очистка тоже идёт веселее, потому что работает на маленьких порциях, а не на едином монолите. Параллельные запросы получают возможность сканировать несколько секций одновременно.
У встроенного декларативного партиционирования PostgreSQL есть один минус: новые секции под будущие даты кто-то должен создавать заранее, а старые вовремя убирать. Делать это руками на потоке в три миллиона строк в день немыслимо. Здесь и выходит на сцену pg_partman.
Что именно автоматизирует расширение и как оно изменилось
Расширение берёт на себя ровно ту рутину, которой никто не хочет заниматься вручную. Оно создаёт будущие секции по расписанию наперёд, удаляет старые по заданной политике хранения и умеет превращать уже существующую обычную таблицу в партиционированную. Важная деталь для тех, кто читал старые руководства: начиная с версии 5.0.1 расширение поддерживает только встроенное декларативное партиционирование, а устаревшие методы на основе триггеров выброшены. Это значит, что синтаксис вызова заметно изменился, и старый параметр с указанием типа native в современных версиях просто отсутствует и вызовет ошибку.
Установка начинается с подгрузки расширения. На управляемых платформах вроде облачных баз оно обычно доступно из списка расширений, на своём сервере ставится через пакетный менеджер. Хороший тон создать для него отдельную схему.
CREATE SCHEMA partman;
CREATE EXTENSION pg_partman WITH SCHEMA partman;
После этого можно проверить, что схема расширения появилась, и переходить к самой таблице.
Как создать родительскую таблицу и настроить её под суточные секции
Родительская таблица под логи должна быть сразу объявлена как партиционированная по диапазону значений колонки с датой. Это обязательное условие: pg_partman работает только с диапазонным партиционированием и только по типам, которые являются целыми числами или датой со временем. Ещё одно правило, о которое спотыкаются многие: любое ограничение уникальности на партиционированной таблице обязано включать колонку партиционирования.
CREATE TABLE public.app_logs (
id bigserial,
created_at timestamptz NOT NULL,
level text,
service text,
message text
) PARTITION BY RANGE (created_at);
Обратите внимание на ключевой момент. Первичный ключ здесь не объявлен по полю id в одиночку, потому что он обязан содержать колонку created_at. Для таблицы логов это редко проблема, ведь логи обычно не требуют сквозной уникальности по идентификатору.
Дальше передаём таблицу под управление расширения одной функцией. Для потока в сто миллионов строк в месяц суточные секции это разумный выбор: месячные оказались бы слишком крупными и теряли бы смысл, а часовые расплодили бы тысячи объектов.
SELECT partman.create_parent(
p_parent_table := 'public.app_logs',
p_control := 'created_at',
p_interval := '1 day',
p_premake := 10
);
Параметр контроля указывает на колонку с датой, интервал задаёт суточную нарезку, а количество заранее создаваемых секций определяет, на сколько дней вперёд расширение наготовит пустых секций. Десять дней запаса дают комфортную подушку на случай, если фоновое обслуживание по какой-то причине задержится. Функция создаёт записи в служебной таблице конфигурации, через которую дальше настраивается всё поведение набора секций.
Почему политику хранения нужно настраивать осознанно
Политика хранения это то, ради чего всё затевалось. Она задаётся в служебной таблице конфигурации через интервальное значение, и любая секция, содержащая только данные старше этого интервала, будет удалена при очередном прогоне обслуживания.
UPDATE partman.part_config
SET retention = '30 days',
retention_keep_table = false,
infinite_time_partitions = true
WHERE parent_table = 'public.app_logs';
Здесь спрятаны два важных решения. Флаг сохранения таблицы по умолчанию настроен консервативно: расширение исходит из того, что случайная потеря данных это худший из исходов, поэтому по умолчанию старая секция не удаляется физически, а лишь отцепляется от родителя и остаётся лежать отдельной таблицей, которую при желании можно прицепить обратно. Для боевого журнала, где тридцать дней это потолок хранения, такое поведение оборачивается тем, что диск продолжает заполняться отцепленными таблицами. Если данные старше срока действительно не нужны, флаг сохранения таблицы выставляется в ложь, и тогда секция удаляется по-настоящему, освобождая место.
Второй флаг, разрешающий бесконечное создание будущих секций, на практике почти всегда нужно включать. Без него расширение в ряде случаев перестаёт корректно создавать секции наперёд, и об этом упоминают практически все, кто настраивал хранение, хотя внятного объяснения причине обычно не дают. Проще говоря, это флаг из разряда "включи, чтобы работало как ожидаешь".
Как запускать обслуживание и почему выбор между двумя планировщиками не пустяк
Создание новых секций и удаление старых происходит не само собой, а при вызове функции обслуживания. Есть два способа сделать так, чтобы она вызывалась регулярно, и выбор между ними не формальность.
Первый способ это встроенный фоновый рабочий процесс, который расширение поставляет в комплекте. Он включается в конфигурации сервера и сам дёргает обслуживание через заданный интервал, по умолчанию раз в час.
# postgresql.conf
shared_preload_libraries = 'pg_partman_bgw'
pg_partman_bgw.interval = 3600
pg_partman_bgw.dbname = 'mydb'
pg_partman_bgw.role = 'partman_user'
Второй способ это планировщик pg_cron, который вызывает ту же процедуру обслуживания по расписанию.
SELECT cron.schedule(
'partman-maintenance',
'*/30 * * * *',
$$CALL partman.run_maintenance_proc()$$
);
Зачем второй способ, если встроенный процесс уже есть? Причина в гибкости. Встроенный рабочий процесс гоняет обслуживание строго через свой единый интервал и не умеет разной частоты для разных задач. Нельзя сказать ему "каждые пять минут в рабочие часы и раз в час в остальное время". А pg_cron принимает полноценные cron-выражения с настройкой для каждой задачи отдельно. На крупном потоке логов это позволяет, например, чаще проверять наличие свежих секций в пиковые часы. Важно при этом следить за параметрами фонового процесса, особенно за именем базы и ролью: если они указаны неверно, обслуживание молча не выполняется, а секции перестают создаваться.
Какие грабли поджидают на потоке в три миллиона строк в сутки
Самая коварная мина в этой схеме это таблица по умолчанию. Так называется специальная секция, куда попадают строки, не вписавшиеся ни в одну существующую секцию по дате. На первый взгляд страховка полезная, но на деле она превращается в бомбу замедленного действия. Если в эту таблицу по умолчанию попадёт строка с датой из будущего, то когда расширение позже попытается создать обычную секцию под этот день, операция провалится, потому что таблица по умолчанию уже содержит строку, претендующую на этот диапазон. Перенести строки и создать секцию одним махом база не может, и приходится вручную разруливать застрявшие записи.
Вывод практиков однозначен: если приложение никогда не должно писать логи с датами из далёкого прошлого или будущего, лучше вообще обойтись без таблицы по умолчанию. Тогда некорректная вставка громко падает с ошибкой сразу, а не отравляет жизнь скрытым конфликтом через неделю. Громкий сбой в момент ошибки почти всегда лучше тихой проблемы, всплывающей позже.
Отдельно стоит учесть нюанс свежих версий PostgreSQL. В восемнадцатой версии появилось ограничение, запрещающее нежурналируемые родительские партиционированные таблицы, так что трюк с отключением журналирования ради скорости вставки на родителе больше не пройдёт. И при превращении уже существующей заполненной таблицы в партиционированную данные переносятся не разом, а пакетами через специальную процедуру, которая коммитит каждую порцию отдельно. Для таблицы, уже накопившей сотни миллионов строк, перенос настраивают небольшими дневными интервалами, чтобы не положить базу одной гигантской транзакцией.
Что в итоге получает проект от такой настройки
Связка из партиционированной по дате таблицы, автоматического создания секций наперёд, осознанной политики хранения и регулярного обслуживания превращает неуправляемый журнал в предсказуемую систему. Запросы за конкретный период читают одну-две секции вместо всей истории. Удаление устаревших логов это мгновенное удаление секции вместо многочасового DELETE с последующей очисткой. Очистка работает на маленьких порциях и не блокирует ничего лишнего. А инженеру больше не нужно помнить про создание секций под новый месяц.
Цена за всё это умеренная: один раз вдумчиво настроить расширение, выбрать правильную гранулярность секций под свой поток, осознанно решить вопрос с физическим удалением данных и проверить, что обслуживание реально запускается. Сто миллионов строк в месяц перестают быть проблемой и становятся просто числом в отчёте, а таблица логов из чугунной плиты превращается в аккуратный набор сменных кассет, каждую из которых легко достать, прочитать или выбросить.