Представьте таблицу событий, которая накапливает по несколько миллионов строк в день. Через год в ней миллиарды записей. Запросы замедляются, автоматическая очистка не успевает, а удаление старых данных через DELETE превращается в часовую операцию с огромной нагрузкой на диск. Это именно та ситуация, для которой создано партиционирование.
Строку раскладывает по партициям сам PostgreSQL: смотрит на occurred_at и кладёт её в файл нужного месяца. Запрос с условием по тому же ключу открывает только один файл — это и есть partition pruning. А удаление старого месяца — не DELETE на миллионы строк, а DROP TABLE одной партиции.
Что такое партиционирование
Партиционирование — это разделение одной большой таблицы на несколько физических частей (партиций) по заданному правилу. Снаружи вы продолжаете работать с одной таблицей: делаете INSERT, SELECT, UPDATE как обычно. PostgreSQL сам решает, в какую партицию записать строку и какие партиции просматривать при запросе.
Когда вы пишете:
SELECT * FROM event_log WHERE occurred_at >= '2026-05-01' AND occurred_at < '2026-06-01';
PostgreSQL видит, что запрос касается только мая, и читает только одну партицию event_log_2026_05. Остальные месяцы физически не трогаются. Это называется partition pruning (отсечение партиций).
Когда партиционирование оправдано
Партиционирование решает конкретные проблемы. Без проблемы — инструмент не нужен.
Стоит рассмотреть, если:
- Таблица больше 50 GB (или 100 миллионов строк) и продолжает расти.
- Это данные типа time-series: события, метрики, логи — где у каждой строки есть временная метка.
- Старые данные нужно регулярно удалять: за прошлый месяц, за прошлый год.
- Автоочистка (autovacuum) не справляется с таблицей — постоянно отстаёт.
Лишнее, если:
- Таблица меньше 10 GB. Обычные индексы справятся лучше и без сложности.
- Запросы редко используют предполагаемый ключ партиционирования в
WHERE. Тогда отсечения партиций не будет — только накладные расходы. - Данные распределены абсолютно равномерно и запросы читают всё подряд — это не партиционирование, это скорее шардирование.
Декларативное партиционирование
С PostgreSQL 10 появился удобный синтаксис. Сначала создаёте «родительскую» таблицу с указанием типа партиционирования, затем добавляете партиции:
CREATE TABLE event_log (
id bigint GENERATED ALWAYS AS IDENTITY,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (id, occurred_at)
) 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');
Обратите внимание: первичный ключ включает occurred_at — поле партиционирования. Это обязательное требование PostgreSQL. Без этого создать PK не получится.
После этого всё работает прозрачно:
-- INSERT идёт в родительскую таблицу, PostgreSQL сам кладёт в нужную партицию
INSERT INTO event_log (occurred_at, payload) VALUES (now(), '{"type":"click"}');
-- SELECT с отсечением партиций — читается только event_log_2026_05
SELECT * FROM event_log
WHERE occurred_at >= '2026-05-15' AND occurred_at < '2026-05-20';
Три типа партиционирования
RANGE — для временных данных
Самый распространённый тип. Каждая партиция отвечает за диапазон значений — например, один месяц или один год.
PARTITION BY RANGE (occurred_at);
Подходит для time-series, архивов по периодам, числовых шкал.
LIST — для категорий
Когда данные делятся по фиксированному набору значений: регион, тип документа, идентификатор клиентской группы.
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_other PARTITION OF order_doc DEFAULT;
Секция DEFAULT принимает все строки, которые не попали ни в одну из именованных партиций. Без неё INSERT с неизвестным значением упадёт с ошибкой.
HASH — равномерное распределение
Партиции определяются по остатку от деления хэша поля на число партиций.
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);
-- и т.д.
Используется редко. HASH не даёт отсечения при диапазонных запросах — только при точном совпадении (WHERE user_id = ?). Он помогает ровно размазать нагрузку на запись, и этим польза почти исчерпывается.
Как проверить, что отсечение работает
Вся ценность разбиения — в том, что запрос читает одну часть вместо всей таблицы. Проверяется это единственным способом — планом запроса.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM event_log
WHERE occurred_at >= DATE '2026-08-01' AND occurred_at < DATE '2026-09-01';
В плане должен остаться один узел Seq Scan on event_log_2026_08 (или Index Scan по её индексу). Если вместо этого видно Append со списком всех частей — отсечение не сработало, и причин обычно три: в условии нет ключа разбиения, ключ завёрнут в функцию (date_trunc('month', occurred_at) = …) или сравнивается с другим типом, и база не может сопоставить границы.
Отдельный случай, на котором делают неверный вывод, — отсечение во время выполнения. Когда граница приходит параметром (WHERE occurred_at >= $1) или берётся из другого узла плана (соединение, подзапрос), база не может отсечь части на этапе планирования: она ещё не знает значения. Отсечение происходит уже при выполнении, и в обычном EXPLAIN его не видно — там честно перечислены все части. Видно его только в EXPLAIN ANALYZE, по строке вида:
Append (actual rows=... loops=1)
Subplans Removed: 11
Subplans Removed и означает «одиннадцать частей были отброшены на лету». Без ANALYZE этой строки нет, и люди решают, что разбиение не работает, хотя оно работает.
Как выбрать ключ партиционирования
Выбор ключа — самое важное решение при партиционировании.
Правило одно: ключ должен быть в WHERE почти всех запросов к этой таблице.
Хорошие примеры:
- Таблица событий с
WHERE occurred_at > ?→ ключoccurred_at. - Многопользовательская система, где каждый запрос идёт в контексте одного клиента → ключ
tenant_id. - Архив заказов с запросами за период → ключ
created_at.
Плохие примеры:
- Партиционировать
ordersпоstatus, если большинство запросов фильтрует поcustomer_id— отсечения не будет, PostgreSQL будет читать все партиции. - Партиционировать
customersпоcountry, если 90% записей в одной стране — одна партиция будет огромной, остальные почти пустыми.
Перед созданием партиций полезно посмотреть, как строки ложатся по месяцам. Запрос одинаковый для любой таблицы — вот он на заказах:
живой пример
SELECT date_trunc('month', created_at) AS month,
count(*) AS cnt
FROM orders
GROUP BY month
ORDER BY month;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если данные распределены неравномерно — переосмыслите ключ.
Размер партиций
Ориентируйтесь на 1–50 GB на партицию. Для time-series это означает:
- Помесячные партиции: при нескольких миллионах событий в месяц.
- Понедельные: при десятках миллионов событий в неделю.
- Подневные: при сотнях миллионов событий в день.
Тысячи партиций раньше были приговором: начиная с PostgreSQL 12 планировщик быстро отсекает ненужные — но только если в запросе есть условие по ключу партиционирования и отсечение действительно работает. Если его нет, база вынуждена открыть и заблокировать все партиции разом, и на нескольких тысячах это уже заметно: растёт время планирования и расход памяти на блокировки. Если нужны подневные партиции за несколько лет, заранее думайте об архивировании.
Управление партициями
Создавайте партиции заранее
Если строка попадает в таблицу, а нужной партиции ещё нет — INSERT упадёт с ошибкой (если нет DEFAULT партиции). Заводите партиции на 1–2 периода вперёд.
CREATE TABLE event_log_2026_07 PARTITION OF event_log
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
Мгновенное удаление старых данных — главный плюс
Вот ради чего обычно и партиционируют time-series данные:
DROP TABLE event_log_2025_05; -- мгновенно: файл удаляется целиком
Сравните с обычным удалением:
DELETE FROM event_log WHERE occurred_at < '2026-01-01';
При миллионах строк этот DELETE:
- Ищет строки по индексу, а без подходящего индекса — полным сканом.
- Оставляет столько же мёртвых версий строк: физически PostgreSQL их сразу не убирает.
- Требует автоочистки, чтобы место снова стало пригодным для записи.
- Может занять десятки минут с высокой нагрузкой на диск и WAL.
DROP TABLE партиции удаляет файл с диска мгновенно — не читая строки и не создавая мёртвых версий, которые потом пришлось бы убирать. Одна оговорка: чтобы отцепить партицию от родителя, команде нужна исключительная блокировка на всю таблицу, и она встанет в очередь за любым долгим запросом — а следом за ней встанут все остальные. На нагруженной базе безопаснее сначала ALTER TABLE ... DETACH PARTITION CONCURRENTLY, а уже потом удалять отцепленную таблицу.
Статистику родительской таблицы собирают руками
Тонкость, которая тихо портит планы. Автоматическая уборка и автоматический анализ работают по каждой части отдельно — у каждой свои счётчики и своя статистика. А вот сводную статистику родительской таблицы автоанализ не собирает: считать её пришлось бы по всем частям сразу, и этого не делается.
Пока запросы попадают в одну часть, это незаметно: планировщик берёт её собственную статистику. Но запрос, который идёт по нескольким частям или по родителю целиком, планируется вслепую — с оценками по умолчанию, из которых получаются неудачные соединения и неверный выбор индексов.
Лечится явным анализом родителя, поставленным в регламент:
ANALYZE event_log;
Его добавляют в то же ночное задание, которое создаёт новые части и отцепляет старые, — раз в сутки достаточно. Поведение стоит перепроверить на своей мажорной версии: детали здесь от версии к версии уточняются, а цена ошибки — плохие планы на самых тяжёлых запросах.
Частые ошибки
Слишком много партиций. Подневное партиционирование за несколько лет легко даст тысячи партиций. Пока в запросе есть ключ, планировщик это переживёт; запрос без ключа откроет и заблокирует их все разом.
Менять значение поля партиционирования через UPDATE. PostgreSQL фактически удаляет строку из одной партиции и вставляет в другую — это неэффективно и может вызвать неожиданные блокировки. Ключ партиционирования должен быть неизменяемым.
Соединения и агрегаты по частям
Две настройки, без которых половина ожидаемого выигрыша не наступает, а выключены они по умолчанию.
enable_partitionwise_join разрешает соединять две одинаково нарезанные таблицы по частям: август с августом, сентябрь с сентябрём, вместо того чтобы сливать их целиком и соединять одной большой операцией. Условие — одинаковый ключ и совпадающие границы частей у обеих таблиц.
enable_partitionwise_aggregate делает то же для группировки: считает итоги по каждой части отдельно и потом складывает. Особенно заметно на GROUP BY по ключу разбиения — тогда складывать даже нечего.
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;
Выключены они не случайно: на таблицах с десятками частей планирование само по себе становится дороже, потому что вариантов больше. Поэтому их включают осознанно — на уровне сессии для тяжёлых отчётов или глобально, если разбиение есть у большинства больших таблиц. И проверяют результат тем же EXPLAIN: в плане должны появиться соединения внутри Append, а не одно большое соединение поверх него.
Чего партиционированная таблица не умеет
Несколько ограничений, о которые спотыкаются при переходе.
Внешний ключ на партиционированную таблицу (то есть когда она родительская сторона связи) появился только в PostgreSQL 12. На более старых версиях сослаться на такую таблицу нельзя вовсе — это регулярная причина, по которой переход откладывают.
INSERT … ON CONFLICT требует уникального индекса, а уникальный индекс на партиционированной таблице обязан включать ключ разбиения. То есть «уникальный email на всю таблицу» при разбиении по дате недостижим: уникальность получится только в пределах части. Это не обходится настройкой — это следствие устройства, и либо уникальность переносят на другой уровень (отдельная таблица-справочник с ключами), либо разбивают по тому, по чему нужна уникальность.
Перерезать часть, которая выросла, нельзя без переноса данных: границы части неизменны. Нужный ход — создать новые, более мелкие части, перелить в них данные и отцепить старую. Поэтому ориентир «одна часть в пределах десятков гигабайт» выбирают с запасом на рост: он взят из практики обслуживания (уборка, перестройка индексов и резервное копирование одной части должны укладываться в окно обслуживания), а не из какого-то ограничения базы.
Разбиение в два уровня
Часть сама может быть разбита дальше — по другому ключу. Классика: месяц, а внутри месяца регион.
CREATE TABLE event_log_2026_08 PARTITION OF event_log
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01')
PARTITION BY LIST (region);
CREATE TABLE event_log_2026_08_eu PARTITION OF event_log_2026_08 FOR VALUES IN ('EU');
CREATE TABLE event_log_2026_08_us PARTITION OF event_log_2026_08 FOR VALUES IN ('US');
Выигрыш — отсечение сразу по двум условиям. Цена — рост числа частей по произведению: двенадцать месяцев на пять регионов это шестьдесят таблиц, и каждая со своими индексами, своей уборкой и своим местом в планировании. Второй уровень заводят, когда второй ключ действительно есть почти в каждом запросе; в остальных случаях дешевле обычный индекс по региону внутри месячной части.
Глубже: Механика партиций на маленькой программерасширенное
Отсечение партиций из первого раздела проще всего пощупать без PostgreSQL.
Ту же механику видно на маленькой программе без базы: она раскладывает строки по месяцам, а потом отбирает партиции под диапазон запроса.
живой пример
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class MonthlyPartitions {
static String partitionOf(LocalDate day) {
return "event_log_" + YearMonth.from(day).toString().replace('-', '_');
}
public static void main(String[] args) {
List<LocalDate> rows = List.of(
LocalDate.parse("2026-04-03"), LocalDate.parse("2026-05-11"),
LocalDate.parse("2026-06-14"), LocalDate.parse("2026-06-29"),
LocalDate.parse("2026-07-02"));
Map<String, Integer> partitions = new TreeMap<>();
for (LocalDate row : rows) {
partitions.merge(partitionOf(row), 1, Integer::sum);
}
System.out.println("строки разложены: " + partitions);
String from = partitionOf(LocalDate.parse("2026-06-01"));
String to = partitionOf(LocalDate.parse("2026-06-30"));
for (String name : partitions.keySet()) {
boolean read = name.compareTo(from) >= 0 && name.compareTo(to) <= 0;
System.out.println(name + (read ? " — читается" : " — пропущена"));
}
String oldest = partitions.keySet().iterator().next();
int gone = partitions.remove(oldest);
System.out.println("DROP TABLE " + oldest + " — строк внутри: " + gone + ", файл удалён целиком");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Глубже: Цена DEFAULT-партициирасширенное
Речь о секции DEFAULT, которая в LIST-партиционировании принимает строки без своей партиции.
Страховка удобная, но у неё две цены, и обе всплывают не сразу.
Первая: пока в DEFAULT что-то лежит, добавление новой партиции перестаёт быть мгновенным. PostgreSQL обязан убедиться, что в DEFAULT не завалялось строк, которые теперь должны были бы попасть в новую партицию, — а для этого надо прочитать её целиком, под жёсткой блокировкой. Если туда год копились «прочие» регионы, очередной ATTACH PARTITION встанет надолго, а найдя такую строку, просто откажется:
ERROR: updated partition constraint for default partition "order_doc_other"
would be violated by some row
Вторая: пока у таблицы есть DEFAULT-партиция — даже пустая, — не работает DETACH PARTITION ... CONCURRENTLY, о котором речь ниже. Придётся либо отцеплять партиции обычной командой с жёсткой блокировкой, либо на время удалять DEFAULT.
Поэтому DEFAULT держат как сигнализацию, а не как склад: ставят оповещение на любые строки внутри и разбирают их, а не копят.
Глубже: Автоматизация через pg_partmanрасширенное
Заводить партиции на периоды вперёд, как в разделе про управление, — рутина по календарю.
Автоматизировать это можно через расширение pg_partman:
CREATE SCHEMA partman;
CREATE EXTENSION pg_partman SCHEMA partman;
SELECT partman.create_parent(
p_parent_table => 'public.event_log',
p_control => 'occurred_at',
p_interval => '1 month',
p_type => 'range', -- в pg_partman 4 на этом месте стояло 'native'
p_premake => 4 -- создать 4 партиции наперёд
);
-- вызывается по расписанию (cron или pg_cron)
SELECT partman.run_maintenance('public.event_log');
pg_partman создаёт новые партиции автоматически и может удалять устаревшие по настраиваемым правилам хранения.
Глубже: Отделение партиции без удалениярасширенное
Иногда старые данные нужно не удалить, а перенести — в архивную таблицу или другое хранилище:
ALTER TABLE event_log DETACH PARTITION event_log_2025_05;
-- теперь event_log_2025_05 — обычная самостоятельная таблица
В PostgreSQL 14 появился вариант, который не берёт исключительную блокировку на родителя, — но выполнять его можно только вне транзакционного блока:
ALTER TABLE event_log DETACH PARTITION event_log_2025_05 CONCURRENTLY;
Работает он в два этапа, и это важно: если сессию оборвали между ними, партиция остаётся в промежуточном состоянии — уже не полноценная часть таблицы, но ещё и не самостоятельная. Новые ALTER TABLE по этой таблице будут отказывать, пока отцепление не довести до конца:
ALTER TABLE event_log DETACH PARTITION event_log_2025_05 FINALIZE;
Проверить, не зависло ли что-то, можно по системному каталогу:
SELECT inhrelid::regclass AS partition, inhdetachpending
FROM pg_inherits WHERE inhparent = 'event_log'::regclass;
Если inhdetachpending везде false — добивать нечего, FINALIZE в таком случае ответит There's no pending concurrent detach.
Только помните про оговорку из раздела про DEFAULT: если у таблицы есть DEFAULT-партиция, щадящий вариант недоступен вовсе:
ERROR: cannot detach partitions concurrently when a default partition exists
Выбирать приходится: либо DEFAULT как страховка от неожиданных значений, либо отцепление партиций без остановки записи. Для time-series, где партиции по датам заводятся наперёд и неожиданных значений не бывает, обычно отказываются от DEFAULT.
Глубже: Индексы на партиционированных таблицахрасширенное
Индекс, созданный на родительской таблице, автоматически создаётся на каждой партиции:
-- выражение берут в двойные скобки: одиночные PostgreSQL примет за имя колонки
CREATE INDEX ON event_log ((payload->>'event_type'));
-- PostgreSQL создаёт индекс на event_log_2026_05, event_log_2026_06 и т.д.
Удобно — и ровно здесь ломается правило «индексы всегда CONCURRENTLY». На партиционированной таблице такой команды просто нет:
CREATE INDEX CONCURRENTLY ix_p ON event_log (occurred_at);
ERROR: cannot create index on partitioned table "event_log" concurrently
То есть индекс на родителе строится обычной командой, а она берёт жёсткую блокировку и не пускает запись во все партиции сразу, пока не построится последняя. На живой таблице в несколько сотен гигабайт это неприемлемо.
Рабочая последовательность — в три шага:
-- 1. Пустой индекс только на родителе: ON ONLY, мгновенно
CREATE INDEX ix_event_log_type ON ONLY event_log ((payload->>'event_type'));
-- 2. По каждой партиции — уже CONCURRENTLY, по одной, без блокировок
CREATE INDEX CONCURRENTLY ix_event_log_2026_05_type
ON event_log_2026_05 ((payload->>'event_type'));
CREATE INDEX CONCURRENTLY ix_event_log_2026_06_type
ON event_log_2026_06 ((payload->>'event_type'));
-- 3. Подцепить готовые индексы к родительскому
ALTER INDEX ix_event_log_type ATTACH PARTITION ix_event_log_2026_05_type;
ALTER INDEX ix_event_log_type ATTACH PARTITION ix_event_log_2026_06_type;
Индекс на родителе считается недостроенным (indisvalid = false в pg_index), пока к нему не подцеплены индексы всех партиций; как только подцеплён последний — становится рабочим. Проверить можно так:
SELECT indexrelid::regclass, indisvalid
FROM pg_index WHERE indrelid = 'event_log'::regclass;
Для уникальных индексов есть ограничение: они обязаны включать ключ партиционирования. Тогда уникальность держится по всей таблице — строки с одинаковым ключом в любом случае попадут в одну и ту же партицию. А вот уникальности по одному только id или UUID, без ключа партиционирования, PostgreSQL не даст: под неё придётся завести отдельную справочную таблицу.
Глубже: Миграция существующей таблицы в партиционированнуюрасширенное
Если таблица уже существует и разрослась, её можно переместить в партиционированную структуру. Рецепт ниже — только для остановленной записи: в окно обслуживания, на таблицу, в которую в этот момент никто не пишет. Почему так — сразу после кода.
-- 1. Создать новую партиционированную таблицу
-- INCLUDING ALL перенесёт и первичный ключ: он обязан содержать occurred_at
CREATE TABLE event_log_new (LIKE event_log INCLUDING ALL)
PARTITION BY RANGE (occurred_at);
-- Создать партиции...
-- 2. Скопировать данные
INSERT INTO event_log_new SELECT * FROM event_log;
-- 3. Переименовать атомарно
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;
Для таблицы в 500 GB копирование займёт несколько часов, и потребуется место под обе версии одновременно. Но главная проблема не в месте.
Шаг 2 молча теряет данные, если запись не остановлена. INSERT ... SELECT копирует срез на момент своего начала. Всё, что приложение запишет в event_log за те несколько часов, пока идёт копирование, попадёт в старую таблицу — а на шаге 3 вы переименуете на её место новую, без этих строк. Потери никто не заметит: ошибок не будет, просто в таблице не хватает куска за полдня.
Шаг 2 ещё и сам по себе вредная операция. Это одна транзакция на несколько часов: она держит блокировки, копит журнал и мешает уборке мёртвых строк во всей базе — ровно то, чего избегают при миграциях без даунтайма. Копировать надо порциями, с коммитом между ними:
-- по месяцу за раз, каждый месяц — отдельная транзакция
INSERT INTO event_log_new
SELECT * FROM event_log
WHERE occurred_at >= '2026-05-01' AND occurred_at < '2026-06-01';
Если запись останавливать нельзя, порядок другой и он длиннее: приложение начинает писать сразу в обе таблицы, старые данные переносятся порциями в фоне, и только когда новая догнала старую, чтение переключают на неё. Это тот же expand-contract, что и в статье про миграции, и занимает он несколько релизов, а не одну ночь.
Коротко
- Партиционирование — одна логическая таблица, разрезанная на физические файлы по заданному правилу; снаружи
INSERT,SELECTиUPDATEне меняются, PostgreSQL сам кладёт строку в нужную партицию и при запросе с условием по ключу открывает только её. - Стоит партиционировать от 50 GB (100 миллионов строк) при регулярном удалении старых периодов и данных вида time-series; меньше 10 GB — обычные индексы справятся лучше и без сложности.
- Ключ партиционирования обязан стоять в
WHEREбольшинства запросов, иначе отсечения не будет; менять ключ черезUPDATEнельзя. Сработало ли отсечение, показывает план: один узел вместоAppendсо всеми частями, а по параметру запроса оно идёт на лету и видно только вEXPLAIN ANALYZEстрокойSubplans Removed. - Три типа:
RANGEпо датам — основной,LISTпо фиксированному набору значений,HASH— только ровная запись и отсечение по точному совпадению. - Первичный ключ и любой уникальный индекс обязаны включать ключ партиционирования (поэтому «уникальный email на всю таблицу» при разбиении по дате недостижим).
CREATE INDEX CONCURRENTLYна партиционированной таблице не работает: индекс заводят черезON ONLYна родителе,CONCURRENTLYпо каждой части иALTER INDEX ... ATTACH PARTITION. DEFAULT-партиция — страховка с ценой: пока она есть, недоступенDETACH PARTITION ... CONCURRENTLY, а пока она непуста, добавление новой партиции сканирует её под жёсткой блокировкой; для time-series от неё обычно отказываются.- Главный плюс для time-series:
DROP TABLEстарой партиции мгновенен, аDELETEмиллионов строк тянется десятки минут и оставляет мёртвые версии; на нагруженной базе сначалаDETACH PARTITION CONCURRENTLY, потомDROP. - Целевой размер партиции 1–50 GB; их заводят на 1–2 периода вперёд (иначе
INSERTупадёт), аpg_partmanделает это по расписанию — в то же ночное задание ставятANALYZEродителя, потому что сводную статистику автоанализ не собирает. - Переезд существующей таблицы через
INSERT ... SELECTи переименование — только при остановленной записи; на живой таблице — двойная запись и перенос порциями, тот же expand-contract, что в миграциях без даунтайма. enable_partitionwise_joinиenable_partitionwise_aggregateвыключены по умолчанию — без них соединение и группировка не разбиваются по частям; выросшую часть без переноса данных не перерезать.
Что почитать дальше
- Автовакуум в PostgreSQL — как autovacuum работает с партиционированными таблицами.
- Materialized views — альтернатива для агрегатов и отчётов.
- Multi-tenancy паттерны — когда партиции по
tenant_idпротив row-per-tenant с RLS. - Миграции без даунтайма — как безопасно переехать на партиционированную таблицу на работающей базе.