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

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

Партиционирование (partitioning) — деление таблицы на части (партиции) по значению ключа. Снаружи это одна таблица, внутри — набор отдельных таблиц, между которыми PostgreSQL сам раскладывает строки и направляет запросы.

Когда таблица разрастается до сотен миллионов строк, с ней начинаются проблемы: индексы перестают помещаться в память, DELETE старых данных идёт часами и раздувает таблицу, а планировщик вынужден перебирать гигантское дерево даже ради нескольких строк. Партиционирование решает это, разрезая таблицу по понятному критерию — обычно по дате. Логически у вас по-прежнему одна events, физически — десятки помесячных кусков, и запрос за вчерашний день читает только вчерашнюю партицию.

В PostgreSQL начиная с 10-й версии есть декларативное партиционирование: вы объявляете родительскую таблицу с ключом разбиения, а партиции добавляете отдельными командами. Этим уроком открываем раздел про эксплуатацию: дальше будут репликация, пулы соединений и бэкапы, а начинаем с того, как приручить по-настоящему большие таблицы.

Зачем это на практике

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

  • Быстрое удаление старых данных. Сбросить партицию за прошлый год — это мгновенный DROP TABLE или DETACH, а не многочасовой DELETE, который пишет в журнал каждую строку и оставляет за собой раздувание.
  • Меньше данных на запрос. Планировщик отсекает ненужные партиции (partition pruning) и читает только релевантные — индекс по одной помесячной партиции куда компактнее общего.
  • Дешёвое обслуживание. VACUUM, ANALYZE и перестройка индексов идут по одной партиции, а не по всему монолиту сразу.

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

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

PostgreSQL поддерживает три способа определить, в какую партицию попадёт строка.

МетодКлюч распределенияКогда применять
RANGEпопадание в диапазон значенийдаты, числовые интервалы (самый частый случай)
LISTпринадлежность к перечню значенийрегион, страна, тип события — небольшой набор категорий
HASHхеш ключа по модулюравномерное дробление, когда естественного диапазона нет

Партиционирование по диапазону дат — типовой выбор для логов и событий. Создаём родительскую таблицу и помесячные партиции:

CREATE TABLE events (
    id      bigint,
    user_id bigint,
    created_at timestamptz NOT NULL,
    payload jsonb
) PARTITION BY RANGE (created_at);

-- партиция на январь 2026: верхняя граница НЕ включается
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');

-- запасная партиция для всего, что не попало в явные диапазоны
CREATE TABLE events_default PARTITION OF events DEFAULT;

Вставка идёт в родительскую таблицу как обычно — PostgreSQL сам выберет нужную партицию по created_at:

INSERT INTO events (id, user_id, created_at, payload)
VALUES (1, 42, '2026-02-14 10:00+00', '{"type":"login"}');
-- строка физически легла в events_2026_02

Для LIST ключом служит категория (например, регион), для HASH — остаток от деления хеша:

-- LIST: партиции по регионам
CREATE TABLE orders (id bigint, region text, total numeric)
    PARTITION BY LIST (region);
CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('DE','FR','ES');
CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('US','CA');

-- HASH: равномерно на 4 партиции по user_id
CREATE TABLE sessions (id bigint, user_id bigint)
    PARTITION BY HASH (user_id);
CREATE TABLE sessions_p0 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_p2 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_p3 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 3);

Partition pruning: главный выигрыш

Partition pruning (отсечение партиций) — это способность планировщика по условию WHERE понять, какие партиции точно не содержат нужных строк, и вовсе их не читать. Именно ради этого всё затевается.

EXPLAIN
SELECT count(*) FROM events
WHERE created_at >= '2026-02-01' AND created_at < '2026-02-15';

-- В плане будет видно, что просканирована только events_2026_02,
-- а events_2026_01 и остальные партиции отсечены (pruned).

Чтобы отсечение сработало, условие WHERE должно опираться на ключ партиционирования. Запрос по created_at отсечёт лишние месяцы; запрос только по user_id (который не входит в ключ) будет вынужден просканировать все партиции. Поэтому ключ выбирают так, чтобы он совпадал с самым частым измерением в фильтрах: для логов и событий это почти всегда время, для мультиарендных систем — идентификатор клиента. Удачный ключ превращает запрос за день из чтения миллиардов строк в чтение одной небольшой партиции; неудачный — лишает партиционирование смысла.

Как это работает под капотом

Партиционированная таблица — это, по сути, «пустой» каталоговый объект без собственных данных; реальные строки живут в дочерних таблицах. PostgreSQL хранит для каждой партиции границы (partition bound) и при планировании запроса сравнивает условие WHERE с этими границами. Если диапазон условия не пересекается с границами партиции, она исключается из плана ещё до выполнения — это и есть pruning на этапе планирования. Есть и runtime pruning: когда значение известно только во время выполнения (например, параметр запроса или результат подзапроса), отсечение происходит уже при исполнении.

Индексы и ограничения в декларативном партиционировании задаются на родителе и «спускаются» на партиции: создав индекс на events, вы получаете локальный индекс на каждой партиции. Уникальность поддерживается только если ключ партиционирования входит в уникальный индекс — иначе PostgreSQL не сможет гарантировать глобальную уникальность, не заглядывая во все партиции сразу.

Частые ошибки

  • Верхняя граница RANGE включается. Нет: FROM ('2026-01-01') TO ('2026-02-01') — это [01-01, 02-01), то есть 2026-02-01 уже относится к следующей партиции. Из-за путаницы тут возникают «дыры» и дубли границ.
  • Забыть DEFAULT-партицию. Если строка не попадает ни в один диапазон, а партиции по умолчанию нет, INSERT упадёт с ошибкой «no partition of relation found for row».
  • Уникальный ключ без колонки партиционирования. Попытка сделать PRIMARY KEY (id) на партиционированной по created_at таблице не пройдёт: в уникальный индекс обязан входить ключ партиционирования.
  • Запросы мимо ключа. Если фильтровать не по ключу партиционирования, pruning не сработает и читаются все партиции — становится только медленнее, чем у обычной таблицы.
  • Партиционировать маленькое. Для таблицы в пару миллионов строк накладные расходы на планирование и обслуживание десятков партиций перевешивают любую выгоду.

Итоги

  • Партиционирование разбивает большую таблицу на физические части по ключу; снаружи это по-прежнему одна таблица.
  • Декларативное партиционирование (PostgreSQL 10+): PARTITION BY RANGE/LIST/HASH на родителе плюс отдельные CREATE TABLE ... PARTITION OF.
  • RANGE — для дат и интервалов, LIST — для перечня категорий, HASH — для равномерного дробления без естественного диапазона.
  • Partition pruning отсекает ненужные партиции по WHERE — но только если условие опирается на ключ партиционирования.
  • Главные грабли: невключаемая верхняя граница RANGE, отсутствие DEFAULT-партиции и уникальный ключ без колонки партиционирования.
Проверьте себя
1. Какой метод партиционирования лучше всего подходит для таблицы логов, которую регулярно фильтруют и чистят по дате?
APARTITION BY RANGE по колонке с датой
BPARTITION BY HASH по первичному ключу
CPARTITION BY LIST по тексту payload
DПартиционирование тут вообще не нужно — хватит индекса
2. Запрос фильтрует партиционированную по created_at таблицу только по user_id, который в ключ партиционирования не входит. Что произойдёт?
APostgreSQL автоматически перепартиционирует таблицу по user_id
BPartition pruning не сработает и будут просканированы все партиции
CЗапрос завершится ошибкой, так как фильтр обязан идти по ключу
DБудет прочитана только DEFAULT-партиция
3. Что задаёт объявление FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')?
AДиапазон, включающий обе границы: и 1 января, и 1 февраля
BДиапазон [2026-01-01, 2026-02-01): нижняя граница включается, верхняя — нет
CТолько одну дату — 1 января 2026 года
DДиапазон, исключающий нижнюю границу, но включающий верхнюю