Таблица на сотни миллионов строк рано или поздно начинает мешать: autovacuum не успевает, индексы распухают, а удаление старых данных растягивается на часы. Партиционирование не ускоряет всё подряд, но делает работу предсказуемой: старые данные уходят куском, новые пишутся в свежую партицию, а запросы с фильтром по дате читают только нужные файлы. Разберём декларативный синтаксис PostgreSQL, обслуживание и подводные камни.
Когда партиционирование помогает, а когда нет
Партиции имеют смысл при больших объёмах и естественном ключе деления: время, регион, тип события. Типичный пример — журнал событий, где 95 процентов запросов смотрят последние сутки, а старые данные хранятся по требованиям аудита. Второй пример — таблица заказов, которую удобно архивировать по месяцам.
Если таблица занимает пару гигабайт и запросы идут по индексу, партиционирование добавит сложности без выигрыша. Помните и об ограничениях: уникальный индекс должен включать ключ партиционирования, а глобальных индексов через все партиции в PostgreSQL нет. Запрос без фильтра по ключу обойдёт все партиции и может работать медленнее исходной таблицы.
Базовая схема с диапазонами дат
Декларативное партиционирование доступно с PostgreSQL 10, hash-партиции и внешние ключи появились позже, а identity-колонки в партициях поддерживаются с PostgreSQL 17. Синтаксис един: родительская таблица объявляет метод и ключ, дочерние описывают границы.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL,
source text NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
Границы задаются в формате «от включая, до не включая». Это удобно: конец января и начало февраля не пересекаются, а дата 1 февраля попадёт ровно в одну партицию. Ключ timestamptz храните в UTC, иначе переход на летнее время создаст сюрпризы на границах месяцев.
Первичный ключ обязан включать ключ партиционирования, поэтому в примере он составной: id и created_at. Уникальный индекс по колонке вне ключа создать нельзя.
Создание партиций заранее
Главная эксплуатационная обязанность — заранее создавать следующую партицию. Если её нет, вставка завершится ошибкой «no partition of relation found for row». Автоматизируйте процесс через pg_partman или собственный скрипт с блоком DO, который вызывается по cron или systemd-таймеру.
DO $$
DECLARE
start_date date := date_trunc('month', now() + interval '1 month')::date;
end_date date := (start_date + interval '1 month')::date;
part_name text := 'events_' || to_char(start_date, 'YYYY_MM');
BEGIN
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF events FOR VALUES FROM (%L) TO (%L)',
part_name, start_date, end_date);
END $$;
Скрипт идемпотентен благодаря IF NOT EXISTS. Запускайте его за несколько дней до границы, а не в последнюю минуту: создание партиции берёт короткую блокировку на родителя, и в час пик это может стать заметно. Держите запас на два-три месяца вперёд, чтобы отпуск или сбой таймера не остановил запись.
Проверка отсечения партиций
Отсечение, или pruning, — главный источник выигрыша. Планировщик должен видеть в запросе ограничение по ключу. Сравните планы с фильтром и без него: в первом случае в строке Append будет указано число выбранных партиций, во втором — все.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE created_at >= now() - interval '1 day';
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE payload ->> 'type' = 'login';
Во втором запросе ключа партиционирования нет, поэтому PostgreSQL честно просканирует всё. Если такие запросы частые, подумайте о другом ключе или о денормализации: вынесите тип в отдельную колонку и добавьте её в ключ. Отсечение работает и для prepared statements: при выполнении значения подставляются и план уточняется, хотя при неудачном выборе generic plan возможны сюрпризы.
Индексы и autovacuum на партициях
Индекс, созданный на родительской таблице, автоматически создаётся на каждой партиции. Это удобно, но помните, что у каждой партиции свой физический индекс и своё состояние bloat. Для больших таблиц полезно настроить autovacuum агрессивнее на уровне партиции: параметры хранения не наследуются автоматически на уже созданные дочерние таблицы.
ALTER TABLE events_2026_01 SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.01
);
CREATE INDEX events_source_idx ON events (source, created_at);
Индекс на родителе раскатывается на все партиции; на огромной схеме это долгая операция. Планируйте её на окно обслуживания или создавайте индекс с CONCURRENTLY на каждой партиции отдельно, а затем прикрепляйте к родителю через CREATE INDEX ON ONLY и ALTER INDEX ATTACH PARTITION.
Удаление и архивирование старых данных
Вместо DELETE с миллионами строк отключайте старую партицию. С PostgreSQL 14 это можно делать конкурентно, не блокируя запись в родительскую таблицу. Отсоединённая партиция становится обычной таблицей: её можно выгрузить, сжать или удалить.
ALTER TABLE events DETACH PARTITION events_2026_01 CONCURRENTLY;
pg_dump -Fc -t events_2026_01 appdb > events_2026_01.dump
DROP TABLE events_2026_01;
DETACH CONCURRENTLY нельзя выполнять внутри транзакции и не стоит совмещать с другими DDL в одном скрипте. Перед удалением убедитесь, что резервная копия действительно восстанавливается: проверка бэкапа важнее самого факта выгрузки. Для холодного архива подойдёт отдельная схема: партиция прикрепляется к таблице-архиву через ATTACH.
Типичные ошибки
- Партиция по дате, а запросы фильтруют только по идентификатору пользователя: pruning не работает.
- Отсутствие default-партиции и падение вставок на границе месяца; либо, наоборот, default-партиция, которая незаметно собирает мусор.
- Уникальный индекс без ключа партиционирования и ошибка при создании схемы.
- Слишком мелкие партиции: по дням за десять лет дадут тысячи файлов и медленное планирование.
- Забытый autovacuum на партициях и рост bloat в индексах.
Default-партиция коварна тем, что при создании новой обычной партиции PostgreSQL должен проверить default на пересечение границ, а это полное сканирование. Если default нужен как страховка, держите его пустым и настройте алерт на появление строк.
Итог
Партиционирование PostgreSQL — это дисциплина эксплуатации, а не волшебная кнопка. Выберите естественный ключ, создавайте партиции заранее, проверяйте планы через EXPLAIN, настройте autovacuum и отключайте старые куски вместо массового DELETE. Тогда большая таблица остаётся управляемой, а удаление истории превращается в одну быструю операцию.