В прошлом уроке мы разобрали partition pruning — встроенный механизм Postgres 10+. Но это не первый механизм отсечения partition в Postgres. До declarative partitioning у нас были годы «ручного» partitioning через INHERITS и CHECK-констрейнты, и для них работал отдельный механизм — constraint exclusion. Он до сих пор включён по умолчанию для inheritance-таблиц и иногда играет роль даже для современных partitioned tables.
И параллельно с pruning, для оптимизации JOIN между двумя partitioned-таблицами с одинаковой схемой разбиения есть отдельная фича — partition-wise join. Она не включена по умолчанию, и в продакшене про неё нередко забывают, теряя в производительности кратно.
Constraint exclusion: старая школа
До PostgreSQL 10 partitioning делали так. Создавалась родительская таблица:
CREATE TABLE orders (...);
И «дети», унаследованные от неё, с явным CHECK-констрейнтом по диапазону:
CREATE TABLE orders_2024_01 (
CHECK (placed_at >= '2024-01-01' AND placed_at < '2024-02-01')
) INHERITS (orders);
И триггер на parent, который перенаправлял INSERT в нужный child. Это работало, но было хрупко: запрещало primary key на parent, требовало синхронизации триггера со списком partition, не давало pruning подзапросов.
Когда приходил запрос SELECT * FROM orders WHERE placed_at = '2024-03-15', Postgres проверял: совместим ли CHECK каждой child-таблицы с этим predicate? Если CHECK гарантирует, что в orders_2024_01 всех placed_at < '2024-02-01', а predicate просит = '2024-03-15' — пересечение пусто, можно child пропустить. Это и есть
Constraint exclusion — старый механизм через CHECK; partition pruning — новый, работает через bounds напрямую. Для declarative partitions используется pruning.
Параметр constraint_exclusion имеет три значения:
off— не использовать совсем.partition(default) — использовать только для inheritance-таблиц и UNION ALL-конструкций; не тратить время на обычные таблицы.on— проверять CHECK для любой таблицы. Дорого, потому что planner ходит вpg_constraintдля каждого WHERE на каждой таблице.
Для declarative partitioning Postgres уже использует partition pruning, а не constraint exclusion — pruning работает через partition bounds в системном каталоге, а не через CHECK-констрейнты. Поэтому для типичного PARTITION BY RANGE constraint exclusion не нужен. Но он остаётся важен для двух случаев:
- Legacy-схемы на
INHERITS— миграция на declarative partitioning часто откладывается, и старые системы продолжают работать на constraint exclusion. - UNION ALL над обычными таблицами с CHECK — иногда удобнее разнести данные в несколько отдельных таблиц и склеить view с UNION ALL. Constraint exclusion отсечёт неподходящие.
Constraint exclusion в действии
Покажем второй сценарий — UNION ALL с CHECK.
Три обычные таблицы с CHECK и view через UNION ALL. Constraint exclusion отсечёт две из трёх.
Смотри на план: вместо трёх Seq Scan под Append должны остаться только два или один. orders_2024_q1 отсекается, потому что её CHECK гарантирует placed_at < '2024-04-01', а predicate ищет в мае. Аналогично orders_2024_q3. Это и есть constraint exclusion — без явного partitioning.
Partition-wise join
Теперь к другой оптимизации. Допустим, у тебя две partitioned-таблицы с одинаковой стратегией partitioning по одному ключу: orders и payments, обе partitioned by RANGE (placed_at) по тем же месяцам. JOIN между ними обычно идёт так:
- Append собирает все строки orders.
- Append собирает все строки payments.
- Hash Join по
order_idвсё ко всему.
Это работает, но игнорирует структуру. Если orders_2024_03 и payments_2024_03 имеют одинаковые bounds, то очевидно: строка из payments_2024_03 не может матчиться со строкой из orders_2024_05. Значит, можно сделать N маленьких джойнов partition-к-partition, и склеить через Append, а не один большой джойн.
Вместо одного большого Hash Join — N маленьких, partition-к-partition. Меньше памяти, лучше параллелизация.
Условия для работы partition-wise join:
- Обе таблицы partitioned по одному и тому же ключу с точно совпадающими bounds.
- Join-условие включает partition key.
- Параметр
enable_partitionwise_joinвключён (по умолчанию off до Postgres 11, на с 11, но многие админы оставляют off — это потенциальная ловушка).
Аналогично есть enable_partitionwise_aggregate — для агрегаций по группам, совпадающим с partition.
Две partitioned таблицы с одинаковой структурой и JOIN. Включаем enable_partitionwise_join и сравниваем планы.
Видишь Hash Join поверх двух Append? Это «большой» план: построить хэш-таблицу из всех payments, прохэшировать все orders.
Тот же запрос с enable_partitionwise_join = on. План превращается в Append из 6 маленьких Hash Join.
Теперь под Append — 6 Hash Join, каждый на одну пару partition. Каждая хэш-таблица в 6 раз меньше; они не конкурируют за память, легче параллелизуются и быстрее очищаются.
Почему partition-wise join выключен по умолчанию
Кажется, что включить нужно всегда. На практике есть три случая, когда он вредит:
- Маленькие partitions. Если в каждой partition по 50 строк, 12 хэш-таблиц по 50 строк дают больше overhead, чем один на 600 строк. Имеет смысл от десятков тысяч строк на partition.
- Несовпадающие bounds. Если ты партиционируешь orders по месяцам, а payments по неделям — partition-wise join не сработает (bounds не совпадают), но planner может потратить время на проверку.
- Объём памяти planner’а. Каждая partition превращается в отдельный планировочный путь. С 1000 partitions × 1000 partitions у второй таблицы — planner просто не выдерживает.
Поэтому это opt-in оптимизация: включай, когда знаешь, что схема подходящая.
Partition-wise aggregate
Аналогично работает enable_partitionwise_aggregate: если GROUP BY содержит partition key, агрегацию можно делать по каждой partition отдельно и в конце склеивать.
Агрегация с partition key в GROUP BY. С включённым partitionwise_aggregate Postgres строит N маленьких Aggregate под Append.
Чек-лист
- Constraint exclusion — старый механизм отсечения через CHECK, актуален для inheritance-таблиц и UNION ALL views.
- Partition pruning — современный механизм для declarative partitions, работает через partition bounds; быстрее и точнее constraint exclusion.
- Partition-wise join требует одинакового partition key и совпадающих bounds на обеих сторонах JOIN.
- Параметр
enable_partitionwise_join— opt-in, проверяй EXPLAIN, иначе оптимизация не сработает молча. - Partition-wise aggregate аналогично — GROUP BY по partition key превращается в N локальных Aggregate.
- На маленьких partition или большом числе partition partition-wise джойн может замедлить запрос — измеряй.