Партиционирование — это разделение одной логической таблицы на несколько физических по правилу (диапазон дат, список значений, хэш). С точки зрения SQL это всё ещё одна таблица: SELECT FROM orders работает как раньше. Внутри PG читает только те партиции, которые подходят под WHERE.

Главный выигрыш — не «производительность сама по себе», а управляемость на больших объёмах: дроп старых данных мгновенный (DROP TABLE партиции), индексы строятся на одной партиции, vacuum работает на каждой отдельно, autovacuum не задыхается на 500-гигабайтной таблице.

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

1. Когда оправдано

PG-P-001 Партиционируй, если выполнено хотя бы одно:

  • Таблица > 50 GB (или > 100M строк) и продолжает расти.
  • Это time-series (события, метрики, логи) — естественное партиционирование по дате.
  • Старые данные регулярно удаляются по сроку (DELETE WHERE created_at < now() - interval '90 days' — мучительный seq scan, DROP TABLE партиции — мгновенный).
  • Multi-tenant с большими тенантами и желанием изолировать их per-tenant (хотя это спорный подход — см. §10).
  • Autovacuum не успевает на текущей таблице (видно по росту bloat и старому last_autovacuum).

PG-P-002 Партиционирование избыточно, если:

  • Таблица < 10 GB. Накладные расходы планировщика и управление партициями обойдутся дороже выигрыша.
  • Запросы редко включают потенциальный partition key в WHERE. Без partition pruning партиции только мешают.
  • Все партиции одного размера и читаются равномерно — это не партиции, это шардирование, и PG-партиции тут не помогут.

2. Жизненный цикл декларативной партиционированной таблицы

С PG10 партиционирование декларативное (раньше было через наследование — устарело).

-- родительская таблица (без данных)
CREATE TABLE event_log (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    occurred_at timestamptz NOT NULL,
    payload     jsonb NOT NULL,
    PRIMARY KEY (id, occurred_at)              -- PK должен включать ключ партиционирования
) PARTITION BY RANGE (occurred_at);

-- партиции
CREATE TABLE event_log_2026_05 PARTITION OF event_log
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

CREATE TABLE event_log_2026_06 PARTITION OF event_log
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

-- INSERT идёт в родительскую, PG раскладывает по партициям
INSERT INTO event_log (occurred_at, payload) VALUES (now(), '{"k":"v"}');

-- SELECT с фильтром по ключу — partition pruning
SELECT * FROM event_log
WHERE occurred_at >= '2026-05-15' AND occurred_at < '2026-05-20';
-- читается ТОЛЬКО event_log_2026_05

PG-P-003 PK партиционированной таблицы обязан включать ключ партиционирования. Это ограничение PG (нельзя PRIMARY KEY (id) в partitioned table). Поэтому идиоматично: PRIMARY KEY (id, occurred_at) или PRIMARY KEY (tenant_id, id) для tenant-партиционирования.

3. Три типа партиционирования

RANGE (по диапазону)

PG-P-010 Самый частый и самый понятный. Подходит для:

  • Time-series по occurred_at/created_at.
  • Архивы по году/месяцу.
  • Числовые шкалы (например, id в диапазонах).
PARTITION BY RANGE (occurred_at);
PARTITION BY RANGE (created_at);

LIST (по списку значений)

PG-P-011 Когда есть фиксированный набор значений-категорий.

CREATE TABLE order_doc (...) PARTITION BY LIST (region);

CREATE TABLE order_doc_eu  PARTITION OF order_doc FOR VALUES IN ('EU', 'UK');
CREATE TABLE order_doc_usa PARTITION OF order_doc FOR VALUES IN ('US', 'CA');
CREATE TABLE order_doc_apac PARTITION OF order_doc FOR VALUES IN ('JP', 'CN', 'AU');
CREATE TABLE order_doc_other PARTITION OF order_doc DEFAULT;

Удобно, когда регионы нужно физически разделить (compliance) или когда часть регионов горячая, а часть холодная.

HASH (по хэшу)

PG-P-012 Когда нужно равномерно распределить нагрузку без естественного ключа разбиения.

CREATE TABLE user_event (...) PARTITION BY HASH (user_id);

CREATE TABLE user_event_p0 PARTITION OF user_event FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_event_p1 PARTITION OF user_event FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_event_p2 PARTITION OF user_event FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_event_p3 PARTITION OF user_event FOR VALUES WITH (MODULUS 4, REMAINDER 3);

HASH не даёт partition pruning по диапазону (WHERE user_id BETWEEN 100 AND 200 — все партиции). Помогает только при WHERE user_id = ? (одно значение → одна партиция).

PG-P-013 HASH-партиционирование оправдано редко. Чаще всего «нужно равномерно распределить» решается одной таблицей с правильными индексами. Real use case: очень большая таблица событий, где старые данные не дропаются, а PK-поиск идёт точечно.

4. Выбор ключа партиционирования

Самое важное решение — определяет, будет ли partition pruning работать.

PG-P-020 Ключ партиционирования должен быть в WHERE почти всех запросов.

Правильно:

  • Time-series: ключ — occurred_at. Запросы почти всегда WHERE occurred_at > ? или WHERE occurred_at BETWEEN ? AND ?.
  • Multi-tenant: ключ — tenant_id. Каждый запрос работает в контексте одного тенанта.
  • Архив заказов: ключ — created_at.

Неправильно:

  • Партиционировать orders по status, если запросы ходят по customer_id. PG будет читать все партиции — overhead больше, чем выигрыш.
  • Партиционировать customer по country, если 90% работы внутри country = 'RU'. Все партиции, кроме одной — пустые. Это просто отдельная таблица для RU и неудобство.

PG-P-021 Если запросы делятся на две группы (по customer_id И по created_at) — выбирай тот ключ, который чаще в самых тяжёлых запросах. Композитный partition key возможен (PARTITION BY RANGE (tenant_id, created_at)), но обычно overkill.

PG-P-022 Распределение нагрузки. Партиции должны быть сравнимого размера. Если 99% данных — в одной партиции, остальные — почти пустые, это не партиционирование. Проверь распределение перед выбором:

SELECT date_trunc('month', occurred_at) AS m, count(*)
FROM event_log GROUP BY m ORDER BY m;

5. Размер партиции

PG-P-030 Целевой размер одной партиции — 1–50 GB. Больше — autovacuum снова тормозит. Меньше — overhead планировщика на множество партиций.

Для time-series обычно:

  • Помесячно — для умеренной нагрузки (миллионы строк в месяц, 1–20 GB).
  • Понедельно — для средней нагрузки (десятки миллионов в неделю).
  • Подневно — для высокой (сотни миллионов в день).

PG-P-031 Не делай сотни мелких партиций. > 1000 партиций — планировщик начинает тормозить. > 10000 — кластер еле дышит.

6. Управление партициями

PG-P-040 Создание новых партиций — заранее, не по факту. Если первая запись месяца попадёт без партиции — INSERT упадёт. Заводи партиции на месяц-два вперёд.

Вручную:

CREATE TABLE event_log_2026_07 PARTITION OF event_log
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

Автоматически — расширение pg_partman:

CREATE EXTENSION pg_partman;

SELECT partman.create_parent(
    p_parent_table => 'public.event_log',
    p_control      => 'occurred_at',
    p_type         => 'native',
    p_interval     => '1 month',
    p_premake      => 4    -- сколько партиций вперёд держать
);

-- регулярно (cron):
SELECT partman.run_maintenance('public.event_log');

pg_partman создаёт новые партиции по графику и при желании дропает старые.

PG-P-041 Удаление старых данных — DROP TABLE партиции, не DELETE.

DROP TABLE event_log_2025_05;   -- мгновенно, без MVCC-следов

Это главный практический выигрыш партиционирования. DELETE FROM event_log WHERE occurred_at < ... — сначала seq scan миллионов строк, потом MVCC-маркеры, потом vacuum для освобождения места. На time-series это десятки минут с активным WAL и replication lag. DROP TABLE — миллисекунды и одна WAL-запись.

PG-P-042 DETACH PARTITION отделяет партицию, не удаляя данные. Полезно для переноса данных в холодное хранилище:

ALTER TABLE event_log DETACH PARTITION event_log_2025_05;
-- теперь event_log_2025_05 — обычная таблица, можно дропнуть, экспортировать, перенести

С DETACH ... CONCURRENTLY (PG14+) — без блокировки на родительской таблице.

7. Индексы на партиционированной таблице

PG-P-050 CREATE INDEX на родительской таблице автоматически создаёт индексы на каждой партиции.

CREATE INDEX ON event_log (payload->>'event_type');
-- создаст этот индекс на event_log_2026_05, event_log_2026_06, ...

Удобно, но можно завести partial index per-партиция, если нагрузка отличается.

PG-P-051 Уникальные индексы должны включать ключ партиционирования. То же ограничение, что для PK. Уникальность гарантируется только в пределах партиции — если бизнес требует глобальной уникальности по UUID, единственный путь — отдельная таблица справочника или приложение.

8. Foreign Keys и партиции

PG-P-060 Partitioned table может ссылаться на обычную таблицу — FK работает.

CREATE TABLE event_log (
    customer_id bigint NOT NULL REFERENCES customer(id),
    ...
) PARTITION BY RANGE (occurred_at);

PG-P-061 Обратная ссылка (обычная таблица ссылается на partitioned) — была ограничением до PG12. С PG12+ работает.

CREATE TABLE order_event (
    order_id bigint NOT NULL REFERENCES order_doc(id, created_at),
    ...
);

Но FK на partitioned требует, чтобы партиция-источник имела PK с ключом партиционирования (см. PG-P-003).

9. Миграция существующей таблицы в партиции

PG-P-070 Прямого ALTER TABLE ... PARTITION BY ... нет. Миграция — через теневую таблицу.

Шаги:

  1. Создаёшь новую partitioned-таблицу с правильным ключом.
  2. Копируешь данные (INSERT INTO new SELECT FROM old).
  3. Меняешь имена через RENAME в одной транзакции.
-- 1. новая партиционированная
CREATE TABLE event_log_new (LIKE event_log INCLUDING ALL)
    PARTITION BY RANGE (occurred_at);

-- партиции на покрытие диапазона
CREATE TABLE event_log_new_2026_05 PARTITION OF event_log_new
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
-- ... остальные

-- 2. копирование (можно батчами для большой таблицы)
INSERT INTO event_log_new SELECT * FROM event_log;

-- 3. swap
BEGIN;
ALTER TABLE event_log RENAME TO event_log_old;
ALTER TABLE event_log_new RENAME TO event_log;
COMMIT;

-- проверить, потом DROP TABLE event_log_old;

Для живых таблиц — добавь логику dual-write (или используй pg_partman partition_data_proc).

PG-P-071 Это масштабная миграция. На таблице 500 GB — несколько часов копирования + место под обе версии. Планируй заранее.

10. Антипаттерны

PG-P-080 Партиционировать малые таблицы. < 10 GB — overhead больше выигрыша. Сначала выжми из обычных индексов и vacuum.

PG-P-081 Партиционировать по полю, которого нет в большинстве WHERE. Без partition pruning партиции — это просто куча таблиц с одинаковой схемой.

PG-P-082 Multi-tenant per-tenant партиции. Соблазнительно («каждому тенанту своя партиция»), но если тенантов сотни — это сотни партиций, autovacuum и pg_class распухают. Multi-tenant решается partial index'ами или row-level security, не партициями. Партиции по тенанту — только если есть несколько очень крупных тенантов и compliance требует физической изоляции.

PG-P-083 Множество мелких партиций (по часам, по минутам). > 1000 партиций — планировщик SQL начинает заметно тормозить. Помесячно/понедельно — обычный максимум.

PG-P-084 Забыть создать партицию заранее. INSERT без подходящей партиции упадёт с no partition of relation found for row. Должен быть мониторинг или pg_partman.

PG-P-085 UPDATE поля, по которому идёт партиционирование. Если меняется occurred_at (с одной партиции на другую), PG должен переместить строку — это DELETE + INSERT в WAL. На больших объёмах больно. Проектируй так, чтобы partition key был immutable.


Чек-лист «нужно ли партиционировать»

  • [ ] Таблица > 50 GB / 100M строк ИЛИ time-series с дропом старых данных?
  • [ ] Есть колонка, которая в WHERE почти всех запросов?
  • [ ] Распределение по этой колонке — равномерное (нет 99% в одном значении)?
  • [ ] Целевой размер партиции — 1–50 GB?
  • [ ] Партиций будет < 1000?
  • [ ] Есть план автоматического создания партиций (pg_partman или cron)?

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


Чек-лист на ревью DDL партиций

  • [ ] Ключ партиционирования совпадает с фильтром в большинстве запросов.
  • [ ] PK включает ключ партиционирования.
  • [ ] Партиций сравнимого размера (нет «99% данных в одной»).
  • [ ] Партиций меньше 1000 (обычно — десятки).
  • [ ] Есть автоматическое создание новых партиций (pg_partman или cron-task).
  • [ ] Удаление старых данных — через DROP TABLE партиции, а не DELETE.
  • [ ] Не партиционирована таблица < 10 GB.
  • [ ] Поле partition key не обновляется в типичных операциях.

Связанные

  • WAL — DROP TABLE партиции = почти 0 WAL, DELETE старых данных — десятки GB WAL.
  • Composite-индексы — индексы на партициях.
  • Антипаттерны.