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

Привет, %username%! Мне понадобилось на работе заняться партицированием одной из таблиц в БД у заказчика. Не буду рассказывать о том, почему именно я выбрал партиционирование т.к. по этому вопросу и так много информации в сети Интернет. Далее в статье будет немного о том, как делается партицирование (секционирование).
🔄 Обновлено 2026-05-15: схема ниже через
INHERITS+ триггеры — это legacy-подход, который работал до PostgreSQL 10. На современных версиях (PG 10+) есть нативное декларативное партицирование черезPARTITION BY— оно проще, быстрее, лучше планировщиком оптимизируется и не требует триггеров вовсе. Старый текст оставлен для тех, кто поддерживает легаси-инсталляции с PG 9.x. Если ты пилишь это с нуля сегодня — иди сразу в раздел «Декларативное партицирование (PG 16/17)» в конце поста.Также:
WITH (OIDS=FALSE)в исходных DDL — с PG 12 (октябрь 2019) этот синтаксис уже выдаёт ошибку. OIDs из обычных таблиц вырезали, эти строки нужно просто удалить.
Для начала определимся с понятиями:
Партицирование (partitioning) - это разбиение больших таблиц на логические части по выбранным критериям. Партиционированные или секционированные таблицы призваны улучшить производительнос и управляемость базами данных.
Вроде бы всё понятно. Осталось только понять как разбить таблицу на секции или партиции? В PostgreSQL эта процедура потребует небольших усилий, но оно того стоит!
Представим, что у нас есть таблица (db.item_pulse), которая за день может вырасти на несколько сотен тысяч строк (за месяц несколько миллионов). Логичным действием по оптимизации является разбиение на секции. Для упрощения будем дробить по дням (управлять в моем случае так будет немного проще).
Структура у таблицы пусть будет такая:
CREATE TABLE item_pulse ( "item_p_id" integer NOT NULL, "date" timestamp without tome zone NOT NULL DEFAULT now(), "user_ip" cidr NOT NULL ) WITH ( OIDS=FALSE );Для пониманиия: ad_id - это уникальный ID, date - дата и время сбора метки, user_ip - IP-адрес запроса. На этом я думаю хватит для примера, но в реальности количество полей может быть огромным.
Далее нам необходимо создать триггер, который будет срабатывать на каждую вставку в таблицу и создавать новую партицию при необходимости.
Пусть наш триггер будет выглядеть вот таким образом:
CREATE OR REPLAE FUNCTION item_pulse_insert_trigger() RETURNS trigger AS $BODY$ DECLARE table_master varchar(255) := 'item_pulse';
table_part varchar(255) := '';
BEGIN
-- Даём имя партиции --------------------------------------------------
table_part := table_master || '_y' || date_part( 'year', NEW.date )::text || '_m' || date_part( 'month', NEW.date )::text || '_d' || date_part( 'day', NEW.date )::text;
-- Проверяем партицию на существование --------------------------------
PERFORM 1 FROM pg_class WHERE relname = table_part LIMIT 1;
-- Если её ещё нет, то создаём --------------------------------------------
IF NOT FOUND THEN
-- Cоздаём партицию, наследуя мастер-таблицу --------------------------
EXECUTE ' CREATE TABLE ' || table_part || ' ( ) INHERITS ( ' || table_master || ' ) WITH ( OIDS=FALSE )';
-- Создаём индексы для текущей партиции -------------------------------
EXECUTE ' CREATE INDEX ' || table_part || '_adid_date_index ON ' || table_part || ' USING btree (item_p_id, date)';
END IF;
-- Вставляем данные в партицию --------------------------------------------
EXECUTE ' INSERT INTO ' || table_part || ' SELECT ( (' || quote_literal(NEW) || ')::' || TG_RELNAME || ' ).*';
RETURN NULL;
END;
$BODY$ LANGUAGE plpgsql VOLATILE COST 100;Привязываем созданный нами триггер к таблице:
CREATE TRIGGER item_pulse_insert_triger BEFORE INSERT ON item_pulse FOR EACH ROW EXECUTE PROCEDURE item_pulse_insert_trigger();Теперь таблицы будут создаваться вот такого вида item_pulse_y<год>_m<месяц>_d<день> (пример: item_pulse_y2018_m07_d16).
В случае если ваши таблици, которые вы собираетесь секциониорвать содержат уникальный ID, то его стоит указывать в каждой партиции с указанием nextval() от sequence мастер-таблицы.
Пусть sequence будет описан таким образом:
CREATE SEQUENCE item_pulse_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 1 CACHE 1;В таком случае в триггере следует написать вот так:
...
-- создаём партицию, наследуя мастер-таблицу --------------------------
EXECUTE ' CREATE TABLE ' || table_part || ' ( id bigint NOT NULL DEFAULT nextval(''' || table_master || '_id_seq''::regclass), CONSTRAINT ' || table_part || '_id_pk PRIMARY KEY (id) ) INHERITS ( ' || table_master || ' ) WITH ( OIDS=FALSE )';
...А в мастер-таблице в таком случае id стоит описать вот так:
CREATE TABLE item_pulse ( id bigserial NOT NULL, ... CONSTRAINT item_pulse_id_pk PRIMARY KEY (id) ) WITH ( OIDS=FALSE )Вот собственно и всё! На этом имеет смысл завершить.
Декларативное партицирование (PG 16/17)#
В PG 10 (октябрь 2017) завезли нативную декларативную систему партицирования — PARTITION BY RANGE/LIST/HASH. Дальше она от релиза к релизу обрастала плюшками: hash-партиционирование, default-партиция, partition pruning во время выполнения, attach/detach concurrently, partition-wise joins, partition-wise aggregates. К 2026-му её можно считать единственным разумным путём.
Базовый синтаксис: RANGE#
Та же таблица item_pulse, что в исходнике, но через декларативное:
CREATE TABLE item_pulse (
item_p_id bigserial,
date timestamptz NOT NULL DEFAULT now(),
user_ip cidr NOT NULL,
PRIMARY KEY (item_p_id, date)
) PARTITION BY RANGE (date);Несколько важных нюансов:
- В первичный ключ должен входить ключ партицирования (
date). Без этого PostgreSQL не даст создать PK на партицированной таблице. WITH (OIDS=FALSE)больше не нужен и не работает.timestamp without time zoneя бы заменил наtimestamptz— у него меньше граблей с таймзонами клиентов.
Создаём первую партицию:
CREATE TABLE item_pulse_2026_05
PARTITION OF item_pulse
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE INDEX ON item_pulse_2026_05 (item_p_id, date);Никаких триггеров — INSERT в item_pulse сам уедет в нужную партицию по значению date. Если значение не попадает ни в одну партицию, по умолчанию INSERT упадёт. Чтобы этого не было — default-партиция:
CREATE TABLE item_pulse_default
PARTITION OF item_pulse DEFAULT;В default обычно ничего держать не хотят (она съест partition pruning по всему диапазону), но для отлова «не туда воткнули» полезно.
LIST и HASH#
LIST — когда ключ партицирования имеет конечный набор значений (страны, типы событий):
CREATE TABLE events (
id bigserial, event_type text, payload jsonb
) PARTITION BY LIST (event_type);
CREATE TABLE events_signup PARTITION OF events FOR VALUES IN ('signup');
CREATE TABLE events_login PARTITION OF events FOR VALUES IN ('login');
CREATE TABLE events_other PARTITION OF events DEFAULT;HASH (с PG 11) — когда нужно ровное распределение по N бакетам без естественного ключа диапазона:
CREATE TABLE users (
id bigserial, email text, created_at timestamptz
) PARTITION BY HASH (id);
CREATE TABLE users_p0 PARTITION OF users FOR VALUES WITH (modulus 8, remainder 0);
CREATE TABLE users_p1 PARTITION OF users FOR VALUES WITH (modulus 8, remainder 1);
-- ... до remainder 7HASH полезен для шардирования нагрузки по диску — но не даёт partition pruning по диапазону, только по точному WHERE id = ....
pg_partman — автоматизация#
Создавать партиции по расписанию руками лень. pg_partman — расширение, которое делает это за тебя:
CREATE EXTENSION pg_partman;
SELECT partman.create_parent(
p_parent_table => 'public.item_pulse',
p_control => 'date',
p_type => 'native',
p_interval => 'daily',
p_premake => 7 -- держать 7 дней наперёд
);Дальше отдельный фоновый job (run_maintenance_proc() или внешний cron/Patroni-таймер) каждый день создаёт новые партиции и опционально удаляет старые. Это де-факто стандарт для time-series-данных в PostgreSQL, если ты не хочешь TimescaleDB.
Partition pruning#
Главная польза партицирования — планировщик отрезает партиции, которые точно не нужны для запроса:
EXPLAIN SELECT * FROM item_pulse WHERE date >= '2026-05-10' AND date < '2026-05-12';
-- В плане: Append → Seq Scan на item_pulse_2026_05, остальные пропущеныС PG 11 это работает и во время выполнения (enable_partition_pruning = on по умолчанию) — то есть параметризованные запросы с WHERE date = $1 тоже эффективно режут партиции.
Partition-wise joins и aggregates#
С PG 11 (но по умолчанию выключены) есть partition-wise joins
— когда обе таблицы партиционированы одинаково, join делается попартиционно, а не «склей всё → джойни». На больших данных может ускорять в разы. Включается в сессии или в postgresql.conf:
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;Платится памятью при планировании (рост work_mem × число партиций), так что для маленьких баз эффект может быть обратный — мерь.
Что ещё стоит знать#
ATTACH ... CONCURRENTLY(с PG 12) — присоединение/отсоединение партиций без полной блокировки. На проде это критично.- Foreign keys между партиционированными таблицами — стабильно работают с PG 12.
- Глобальный индекс через всю партицированную таблицу — до сих пор не сделан. Каждый индекс физически создаётся на каждой партиции. Это нужно учитывать при операциях
CREATE INDEX(используй... CONCURRENTLY). - TimescaleDB — отдельное расширение, которое строит свою иерархию hypertable+chunk поверх partitioning. Если основной кейс — time-series, и хочется compression, continuous aggregates и retention-политики «из коробки» — посмотри в его сторону.
TL;DR апдейта#
Если ты сегодня делаешь partitioning в PG 14+:
- Никаких
INHERITS+ триггеров. ТолькоPARTITION BY. - Ключ партицирования — в PK таблицы.
- Для time-series —
pg_partmanили TimescaleDB вместо ручных скриптов. - Проверь, что
enable_partition_pruning = on(дефолт), и попробуй включитьpartitionwise_join/partitionwise_aggregateпод свой workload.
Если у тебя есть вопросы, комментарии и/или замечания – заходи в чат , а так же подписывайся на канал .
О способах отблагодарить автора можно почитать на странице “Донаты ”.