Партиционирование — разбивка большой таблицы на физически отдельные части (партиции). Запросы читают только нужные партиции, игнорируя остальные.
Когда нужно партиционирование
- Таблица > 50-100 GB и запросы всегда фильтруют по конкретному столбцу (дата, регион)
- Нужно быстро удалять старые данные (
DROP TABLEпартиции vsDELETEмиллионов строк) - Разные партиции хранятся на разных tablespace (архив → медленный диск)
Партиционирование не нужно для таблиц < 10 GB — накладные расходы перевесят выгоду.
RANGE партиционирование (по дате)
-- Партиционированная таблица
CREATE TABLE orders (
id BIGSERIAL,
created_at TIMESTAMPTZ NOT NULL,
user_id BIGINT,
amount NUMERIC,
status TEXT
) PARTITION BY RANGE (created_at);
-- Создать партиции по месяцам
CREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE orders_2026_02 PARTITION OF orders
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE orders_2026_03 PARTITION OF orders
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- Партиция по умолчанию (для значений вне всех диапазонов)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
Partition pruning в действии
-- Запрос фильтрует по created_at → читает только нужную партицию
EXPLAIN SELECT * FROM orders WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
-- Append
-- Seq Scan on orders_2026_03 ← только одна партиция!
-- (orders_2026_01 и orders_2026_02 исключены)
-- Без партиционирования: Seq Scan всей таблицы
Строка Append в плане — это узел, который объединяет результаты нескольких партиций. Если после pruning осталась ровно одна партиция (как в примере выше), планировщик обычно вообще убирает Append из плана — в EXPLAIN сразу виден Seq Scan on orders_2026_03, без обёртки. Append появляется, когда после pruning остаётся больше одной партиции.
LIST партиционирование (по значению)
CREATE TABLE products (
id BIGSERIAL,
name TEXT,
category TEXT NOT NULL,
price NUMERIC
) PARTITION BY LIST (category);
CREATE TABLE products_electronics PARTITION OF products
FOR VALUES IN ('smartphone', 'laptop', 'tablet');
CREATE TABLE products_clothing PARTITION OF products
FOR VALUES IN ('shirts', 'pants', 'shoes');
CREATE TABLE products_food PARTITION OF products
FOR VALUES IN ('fresh', 'frozen', 'drinks');
CREATE TABLE products_other PARTITION OF products DEFAULT;
HASH партиционирование (равномерное распределение)
-- Хорошо когда нет естественного диапазона, но нужно распределить нагрузку
CREATE TABLE users (
id BIGSERIAL,
email TEXT NOT NULL,
name TEXT
) PARTITION BY HASH (id);
-- 4 равные партиции
CREATE TABLE users_p0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE users_p1 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE users_p2 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE users_p3 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 3);
Индексы на партиционированных таблицах
-- Индекс создаётся на все партиции автоматически
CREATE INDEX ON orders (user_id);
-- ↑ создаст orders_2026_01_user_id_idx, orders_2026_02_user_id_idx и т.д.
CREATE INDEX ON orders (created_at, status);
-- PRIMARY KEY (должен включать ключ партиционирования)
ALTER TABLE orders ADD PRIMARY KEY (id, created_at);
-- ❌ Нельзя: PRIMARY KEY на id без created_at (ключа партиционирования)
Автоматическое создание партиций
PostgreSQL сам не создаёт новые партиции — нужно делать заранее. Автоматизация:
-- Функция для создания партиции на следующий месяц
CREATE OR REPLACE FUNCTION create_monthly_partition(
p_table_name TEXT,
p_year INTEGER,
p_month INTEGER
) RETURNS VOID AS $$
DECLARE
v_partition_name TEXT;
v_from_date DATE;
v_to_date DATE;
BEGIN
v_from_date := MAKE_DATE(p_year, p_month, 1);
v_to_date := v_from_date + INTERVAL '1 month';
v_partition_name := p_table_name || '_' || TO_CHAR(v_from_date, 'YYYY_MM');
EXECUTE FORMAT(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
v_partition_name, p_table_name, v_from_date, v_to_date
);
RAISE NOTICE 'Created partition: %', v_partition_name;
END;
$$ LANGUAGE plpgsql;
-- Создать партиции на следующие 3 месяца
SELECT create_monthly_partition('orders', 2026, 4);
SELECT create_monthly_partition('orders', 2026, 5);
SELECT create_monthly_partition('orders', 2026, 6);
-- Cron job: запускать 1 числа каждого месяца
-- SELECT create_monthly_partition('orders', EXTRACT(YEAR FROM NOW() + INTERVAL '2 months')::INT, ...);
ATTACH/DETACH партиций
-- Создать таблицу отдельно, затем прикрепить как партицию
CREATE TABLE orders_2025 (LIKE orders INCLUDING ALL);
-- Загрузить данные...
COPY orders_2025 FROM '/backup/orders_2025.csv' CSV;
-- Без этого шага ATTACH просканирует всю таблицу, проверяя диапазон построчно
ALTER TABLE orders_2025 ADD CONSTRAINT orders_2025_check
CHECK (created_at >= '2025-01-01' AND created_at < '2026-01-01');
-- CHECK совпадает с диапазоном партиции → PostgreSQL доверяет ограничению
-- и пропускает валидирующее сканирование: операция быстрая, почти без блокировок
ALTER TABLE orders ATTACH PARTITION orders_2025
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
-- Ограничение больше не нужно — партиционирование само проверяет диапазон
ALTER TABLE orders_2025 DROP CONSTRAINT orders_2025_check;
-- Отсоединить партицию (для архивирования)
ALTER TABLE orders DETACH PARTITION orders_2024_01;
-- Таблица orders_2024_01 теперь существует самостоятельно
-- Удалить старую партицию мгновенно (vs DELETE миллионов строк)
DROP TABLE orders_2024_01;
Важно: если прикрепить orders_2025 сразу, без CHECK-ограничения из примера выше, PostgreSQL не поверит вам на слово — он построчно просканирует всю таблицу, чтобы убедиться, что каждая строка действительно попадает в объявленный диапазон FOR VALUES. На большой таблице это может занять заметное время и держать блокировку всё это время. Ограничение с точно таким же диапазоном превращает проверку в мгновенное сопоставление метаданных. С PostgreSQL 14+ для DETACH PARTITION есть режим DETACH PARTITION ... CONCURRENTLY, который не блокирует параллельные запросы к родительской таблице вовсе — полезно, если отсоединение партиции нужно делать на живой базе без окна обслуживания.
Субпартиционирование
-- Партиции по году, затем по месяцу
CREATE TABLE events (
id BIGSERIAL,
event_date DATE NOT NULL,
event_type TEXT NOT NULL,
data JSONB
) PARTITION BY RANGE (event_date);
CREATE TABLE events_2026 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')
PARTITION BY LIST (event_type); -- субпартиция по типу
CREATE TABLE events_2026_click PARTITION OF events_2026
FOR VALUES IN ('click', 'view', 'scroll');
CREATE TABLE events_2026_purchase PARTITION OF events_2026
FOR VALUES IN ('add_to_cart', 'checkout', 'purchase');
Мониторинг партиций
-- Размер каждой партиции
SELECT
child.relname AS partition_name,
pg_size_pretty(pg_relation_size(child.oid)) AS size,
pg_size_pretty(pg_total_relation_size(child.oid)) AS total_size
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY pg_relation_size(child.oid) DESC;
-- Количество строк по партициям (приблизительно)
SELECT
child.relname,
child.reltuples::BIGINT AS estimated_rows
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders';
Подводные камни
1. DEFAULT-партиция замедляет добавление новых партиций. Когда вы прикрепляете новую партицию с диапазоном, который раньше попадал в DEFAULT (например, спохватились и создаёте orders_2026_04 после того, как апрельские заказы уже успели уйти в default), PostgreSQL обязан просканировать DEFAULT-партицию целиком, чтобы убедиться, что в ней не осталось «чужих» строк. На большой DEFAULT-партиции это дорогая блокирующая операция — поэтому лучше создавать партиции заранее (см. автоматизацию выше) и держать DEFAULT пустой, как страховку на крайний случай.
2. PRIMARY KEY и UNIQUE обязаны включать столбец партиционирования. ALTER TABLE orders ADD PRIMARY KEY (id) без created_at не сработает — PostgreSQL не может гарантировать уникальность id глобально, если проверка идёт независимо по каждой партиции. Это не ограничение синтаксиса, а следствие архитектуры: движок не сканирует все партиции разом ради одной проверки уникальности.
3. Секционирование — это не индекс. Partition pruning работает только когда запрос фильтрует именно по ключу партиционирования. WHERE created_at >= ... исключит лишние партиции, а WHERE user_id = ... на таблице, партиционированной по created_at, прочитает все партиции — внутри каждой всё равно нужен обычный индекс.
Потренируйтесь
У вас таблица events(id, region, created_at, event_type, data). 80% запросов фильтруют одновременно и по периоду (created_at), и по региону (region, всего 5 значений: RU, KZ, BY, AM, UZ). Как её партиционировать?
Ответ
Здесь нужна двухуровневая схема — субпартиционирование, как в разделе выше: сначала PARTITION BY RANGE (created_at) (по месяцам, раз запросы регулярно фильтруют по периоду и старые данные нужно будет архивировать), а внутри каждой месячной партиции — PARTITION BY LIST (region) на 5 партиций по регионам.
CREATE TABLE events (
id BIGSERIAL,
region TEXT NOT NULL,
created_at DATE NOT NULL,
event_type TEXT NOT NULL,
data JSONB
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_04 PARTITION OF events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01')
PARTITION BY LIST (region);
CREATE TABLE events_2026_04_ru PARTITION OF events_2026_04 FOR VALUES IN ('RU');
CREATE TABLE events_2026_04_kz PARTITION OF events_2026_04 FOR VALUES IN ('KZ');
-- и так далее для BY, AM, UZ
Запрос вида WHERE created_at BETWEEN ... AND region = 'RU' тогда исключит и лишние месяцы, и лишние регионы одновременно — partition pruning сработает на обоих уровнях.
Вопросы для самопроверки
- Почему партиционирование не имеет смысла для таблицы объёмом 5 ГБ?
- Чем partition pruning отличается от обычного использования индекса?
- Почему
PRIMARY KEYна партиционированной таблице обязан включать столбец партиционирования? - В чём разница между
DETACH PARTITIONиDROP TABLEпартиции? - Когда стоит использовать
HASH-партиционирование вместоRANGE?
Итог
| Тип | Ключ | Применение |
|---|---|---|
RANGE | Дата/число | Логи, заказы, события по времени |
LIST | Категория | Регион, статус, тип |
HASH | ID/ключ | Равномерное распределение нагрузки |
Главные выгоды партиционирования:
- Partition pruning: запросы читают только нужные партиции
- Быстрое удаление старых данных:
DROP TABLE partitionвместоDELETE - Параллельные запросы по разным партициям
- Разные настройки хранения для горячих/холодных данных
Закрепите партиционирование и другие темы по производительности PostgreSQL в нашем тренажёре SQL.