Партиционирование таблиц
Партиционирование — это разбиение одной логической таблицы на несколько физических частей, чтобы запросы и обслуживание касались только нужного куска данных.
Партиционирование (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-партиции и уникальный ключ без колонки партиционирования.