Как разбить таблицу на секции в PostgreSQL и не сломать себе жизнь

Партиционирование в PostgreSQL — один из тех инструментов, к которому приходят поздно. Обычно тогда, когда таблица уже весит сотни гигабайт, запросы тормозят, а DELETE на миллионах строк превращается в часовой ритуал. Разбираем, как работает PARTITION BY, когда секционирование реально помогает, а когда лучше не трогать.

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

Когда партиционирование реально помогает

Прежде чем лезть в настройки, стоит ответить на один вопрос: есть ли в таблице естественный «срез», по которому идут запросы? Если нет — партиционирование не поможет.

Ощутимый эффект оно даёт в трёх случаях. Первый — таблица логов или транзакций, где большинство запросов смотрят на последние N дней: PostgreSQL читает только нужную секцию, а не пробегает всю таблицу. Второй — нужно регулярно выбрасывать старые данные: DROP PARTITION занимает секунды там, где массовый DELETE идёт часами. Третий — данные сами по себе делятся по чёткому признаку: региону, типу события, статусу заказа — и такое деление устойчиво.

Если таблица небольшая или запросы равномерно распределены по всему объёму — партиционирование только добавит сложности без выигрыша в скорости.

Три стратегии: RANGE, LIST, HASH

Выбор стратегии определяет, как PostgreSQL физически разделяет строки. Их три, и каждая под свой сценарий:

  • RANGE — делит по диапазону значений. Классика для дат: каждый месяц или год уходит в отдельную секцию. Чаще всего встречается в проектах с логами и транзакциями.
  • LIST — каждой секции явно назначается набор конкретных значений ключа. Удобно при делении по коду страны, типу документа или статусу заказа.
  • HASH — никакого смыслового деления нет. PostgreSQL сам вычисляет, в какую секцию попадёт строка, просто чтобы равномерно распределить объём.

HASH выбирают, когда всё остальное не подходит. Но partition pruning с ним работает только при точном совпадении значения, так что основное его преимущество — параллельная запись, а не ускорение чтения.

Минимальный рабочий пример

Покажу на таблице событий. Синтаксис декларативного партиционирования выглядит так: сначала объявляем корневую таблицу с PARTITION BY, потом дочерние — с указанием границ. Корневая таблица сама строк не хранит, она только маршрутизирует.

CREATE TABLE events (
    id          BIGSERIAL,
    user_id     BIGINT NOT NULL,
    event_type  TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL,
    payload     JSONB
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2025_01
    PARTITION OF events
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE events_2025_02
    PARTITION OF events
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

Индекс объявляется один раз на корневой — PostgreSQL сам разложит его по дочерним:

CREATE INDEX ON events (created_at);

Почему pruning может не сработать — и как это поймать

Partition pruning — это когда планировщик заранее выбрасывает из плана секции, куда запрос точно не попадёт. Звучит просто, но ломается неожиданно. Проверить легко:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2025-01-01'
  AND created_at < '2025-02-01';

Если в плане видна только одна секция — всё работает. Если PostgreSQL честно обошёл все партиции подряд — pruning отключился. Самая частая причина, с которой я сталкивался: тип параметра в условии не совпадает с типом ключа. Передаёшь строку там, где ждут timestamptz — и база на всякий случай проверяет каждую секцию. Лечится явным кастом в запросе или на уровне ORM.

Вывод EXPLAIN ANALYZE в PostgreSQL — partition pruning сканирует одну секцию вместо всей таблицы

pg_partman: чтобы не создавать секции руками

Делать CREATE TABLE ... PARTITION OF вручную каждый месяц — плохая идея. Забудешь один раз, и вставки начнут падать с ошибкой про отсутствующую секцию. Это неприятно обнаруживать в три часа ночи.

pg_partman решает проблему: регистрируешь таблицу один раз, указываешь интервал и горизонт — дальше расширение само держит нужный запас секций вперёд.

CREATE EXTENSION pg_partman;

SELECT partman.create_parent(
    p_parent_table  => 'public.events',
    p_control       => 'created_at',
    p_interval      => '1 month',
    p_premake       => 3
);

p_premake = 3 — это три секции «в запасе» от сегодняшней даты. Важный нюанс: расширение само по себе ничего не делает по расписанию. Его нужно дёргать через pg_cron или внешний cron. Без этого шага оно просто будет ждать.

Три вещи, которые удивляют при первом знакомстве

PRIMARY KEY должен содержать ключ партиционирования — иначе ошибка при создании. Звучит странно, но логика понятна: уникальность PostgreSQL может проверить только внутри одной секции, глобальную гарантию дать не может.

С внешними ключами на партиционированную таблицу долго было плохо — вплоть до 15-й версии они попросту не работали. В 16-й добавили частичную поддержку, но «частичную» — ключевое слово. Если FK важен для логики, лучше проверить на своей версии руками, а не верить что «в новых версиях всё хорошо».

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

Партиционирование — не таблетка, а скальпель

Секционирование — не универсальный ускоритель, а инструмент с чётко очерченной областью применения. Оно хорошо работает там, где запросы предсказуемо «приземляются» в конкретный диапазон, а данные за пределами этого диапазона либо не нужны, либо периодически выбрасываются. Если запросы случайны по всей таблице — partition pruning не сработает, и вы получите лишь накладные расходы на маршрутизацию строк. Разумная точка входа: сначала индексы и профилирование через EXPLAIN ANALYZE, и только когда они перестают справляться — партиционирование как следующий уровень.