Продвинутый ~50 мин чтения

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

Урок 8 из 10 в курсе PostgreSQL для backend-разработчика

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

Партиционирование — это разбиение одной логической таблицы на несколько физических частей (партиций) по значению определённого столбца. С точки зрения приложения ничего не меняется: INSERT, UPDATE, DELETE, SELECT работают с родительской таблицей как обычно — PostgreSQL автоматически маршрутизирует данные в нужную партицию. Но физически данные разделены: VACUUM работает на каждой партиции отдельно (меньше времени), индексы меньше (быстрее), удаление устаревших данных — DROP TABLE partition за миллисекунды вместо DELETE за часы. Поддерживаемые типы: RANGE (диапазоны дат/чисел), LIST (дискретные значения: страна, статус), HASH (равномерное распределение). Declarative partitioning доступен с PostgreSQL 10.

Почему это важно: Проблемы больших таблиц появляются неожиданно: система работает нормально при 10M строк, но при 500M начинает тормозить. VACUUM, который раньше выполнялся за минуты, теперь занимает часы и не успевает убирать dead tuples — table bloat растёт. Индексы занимают десятки гигабайт. Удаление данных старше года — DELETE за несколько часов с блокировками. Добавление нового столбца — долгая операция. Партиционирование по времени (created_at) разбивает таблицу на месячные или квартальные части: VACUUM каждой партиции — минуты, удаление старых данных — DROP TABLE, индексы каждой партиции меньше и эффективнее. Это стандартное решение для логов событий, метрик, транзакционных таблиц с неограниченным ростом.

Главная идея

Declarative partitioning в PostgreSQL: CREATE TABLE parent (columns...) PARTITION BY RANGE (partition_key). Затем создаёшь дочерние таблицы: CREATE TABLE child PARTITION OF parent FOR VALUES FROM (...) TO (...). PostgreSQL автоматически маршрутизирует INSERT в нужную партицию и использует partition pruning при SELECT — исключает партиции, которые не могут содержать нужные данные по условию WHERE. Partition pruning работает как на этапе планирования (статическое условие: WHERE month = '2024-01'), так и на этапе выполнения (PostgreSQL 11+, динамическое: WHERE month = $1). Индексы создаются на каждой партиции отдельно (LOCAL indexes). DEFAULT partition ловит строки, не попадающие ни в одну партицию. pg_partman — расширение для автоматического создания новых партиций и удаления устаревших по retention policy.

Как это выглядит на практике

  1. Таблица events растёт на 5M строк в месяц. Через год — 60M строк. VACUUM начинает занимать 40 минут, индекс user_id весит 8GB. Аналитические запросы по месяцам медленные даже с индексом.
  2. Принимаешь решение о партиционировании по created_at с квартальными партициями. Создаёшь родительскую таблицу PARTITION BY RANGE, создаёшь 8 квартальных партиций + DEFAULT.
  3. Миграция: создаёшь новую партиционированную таблицу events_partitioned, переносишь данные батчами (INSERT INTO events_partitioned SELECT ... LIMIT 100000), переименовываешь.
  4. Partition pruning в действии: EXPLAIN SELECT COUNT(*) FROM events WHERE created_at >= '2024-01-01' AND created_at < '2024-04-01' показывает Seq Scan только на events_2024_q1 — три строки вместо чтения 60M.
  5. Удаление данных старше 2 лет: раньше DELETE ... WHERE created_at < '2022-01-01' занимало 3 часа и генерировало огромный WAL. Теперь: DROP TABLE events_2021_q1, events_2021_q2, events_2021_q3, events_2021_q4 — 4 секунды.
  6. VACUUM теперь работает на каждой партиции отдельно — autovacuum завершает обслуживание каждой за 2-3 минуты вместо 40 минут для всей таблицы.
  7. Новый коллега добавляет запрос WHERE user_id = 123 без условия по дате. EXPLAIN показывает Append с 8 партициями — full scan всех. Объясняешь: для запросов без ключа партиционирования нужен отдельный индекс и это медленнее чем единая таблица.
  8. Пытаешься добавить UNIQUE constraint на email в партиционированной таблице. Ошибка: unique constraint must include all partitioning columns. PostgreSQL не поддерживает глобальную уникальность без ключа партиционирования. Решение: уникальность проверяется в коде или через отдельную lookup таблицу.
  9. Устанавливаешь pg_partman и настраиваешь автоматическое создание месячных партиций (premake = 2) и удаление старше 18 месяцев (retention = '18 months'). Добавляешь в cron: SELECT partman.run_maintenance();
  10. Аналитическая задача: агрегировать данные за Q1 2024. Запрос с WHERE created_at BETWEEN '2024-01-01' AND '2024-04-01' — партиция pruning исключает все остальные 7 партиций. Запрос в 8 раз быстрее чем без партиционирования.
  11. Появляется требование хранить данные в разных странах в разных локациях (data residency). LIST partitioning по country + tablespace для каждой партиции. Каждая партиция физически на нужном сервере.
  12. Горячая таблица sessions: 10k INSERT/секунду, частые конкурентные записи вызывают contention. HASH partitioning на 8 частей по user_id распределяет нагрузку — каждая партиция получает ~1/8 трафика, lock contention снижается.

Примеры кода

Создание партиционированной таблицы RANGE по дате

-- Создание партиционированной таблицы событий
CREATE TABLE events (
  id          BIGSERIAL,
  user_id     BIGINT NOT NULL,
  type        TEXT NOT NULL,
  payload     JSONB,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- Создание партиций вручную
CREATE TABLE events_2024_q1 PARTITION OF events
  FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');

CREATE TABLE events_2024_q2 PARTITION OF events
  FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

CREATE TABLE events_2024_q3 PARTITION OF events
  FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');

CREATE TABLE events_2024_q4 PARTITION OF events
  FOR VALUES FROM ('2024-10-01') TO ('2025-01-01');

-- DEFAULT partition ловит строки вне диапазонов
CREATE TABLE events_default PARTITION OF events DEFAULT;

-- Индексы создаются на каждой партиции (LOCAL)
CREATE INDEX ON events_2024_q1 (user_id);
CREATE INDEX ON events_2024_q2 (user_id);
-- Или сразу на родителе — автоматически создаёт на всех партициях
CREATE INDEX ON events (user_id, created_at);

-- INSERT работает прозрачно — PostgreSQL сам маршрутизирует
INSERT INTO events (user_id, type, payload, created_at)
VALUES (123, 'page_view', '{"page": "/home"}', '2024-03-15');
-- Строка попадёт в events_2024_q1

-- Проверка: какие партиции существуют и сколько строк
SELECT
  child.relname AS partition_name,
  pg_size_pretty(pg_total_relation_size(child.oid)) AS total_size,
  pspt.n_live_tup AS approx_rows
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child  ON pg_inherits.inhrelid  = child.oid
JOIN pg_stat_user_tables pspt ON pspt.relname = child.relname
WHERE parent.relname = 'events'
ORDER BY child.relname;

RANGE партиционирование по дате — самый распространённый паттерн для событийных таблиц. Создавай партиции заранее (на 1-2 квартала вперёд) или используй pg_partman для автоматического создания. DEFAULT partition обязательна — без неё INSERT строки вне диапазона вызовет ошибку.

Partition pruning, удаление данных и LIST/HASH

-- Partition pruning в действии
-- Запрос к конкретному кварталу — PostgreSQL обращается только к одной партиции
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*), SUM(amount)
FROM events
WHERE created_at >= '2024-01-01' AND created_at < '2024-04-01';
-- Вывод: Seq Scan on events_2024_q1 (партиция выбрана, остальные исключены)

-- Запрос без условия по ключу — читает ВСЕ партиции (антипаттерн)
EXPLAIN SELECT * FROM events WHERE user_id = 123;
-- Вывод: Append (читает events_2024_q1, events_2024_q2, ... — все партиции!)

-- Мгновенное удаление устаревших данных
-- DELETE 100M строк = часы. DROP TABLE = миллисекунды!
DROP TABLE events_2022_q1;  -- удаляет партицию мгновенно

-- Или: detach перед drop (партиция становится обычной таблицей)
ALTER TABLE events DETACH PARTITION events_2022_q1 CONCURRENTLY;
-- events_2022_q1 теперь обычная таблица, можно анализировать, архивировать
DROP TABLE events_2022_q1;

-- LIST partitioning: по стране (дискретные значения)
CREATE TABLE orders (
  id         BIGSERIAL,
  user_id    BIGINT,
  country    TEXT NOT NULL,
  amount     NUMERIC(10,2),
  created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY LIST (country);

CREATE TABLE orders_ru PARTITION OF orders FOR VALUES IN ('RU', 'BY', 'KZ');
CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('DE', 'FR', 'NL', 'IT');
CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('US', 'CA');
CREATE TABLE orders_other PARTITION OF orders DEFAULT;

-- HASH partitioning: равномерное распределение для горячих таблиц
CREATE TABLE user_sessions (
  id      BIGSERIAL,
  user_id BIGINT NOT NULL,
  data    JSONB
) PARTITION BY HASH (user_id);

CREATE TABLE user_sessions_0 PARTITION OF user_sessions
  FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_sessions_1 PARTITION OF user_sessions
  FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_sessions_2 PARTITION OF user_sessions
  FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_sessions_3 PARTITION OF user_sessions
  FOR VALUES WITH (MODULUS 4, REMAINDER 3);

Partition pruning работает только при условии WHERE по ключу партиционирования. Без такого условия PostgreSQL читает все партиции (Append узел в EXPLAIN) — это медленнее чем единая таблица с индексом. DROP TABLE partition — мгновенно, без блокировок. DETACH CONCURRENTLY (PostgreSQL 14+) позволяет отделить партицию без блокировки родителя.

pg_partman: автоматическое управление партициями

-- Установка pg_partman
CREATE EXTENSION pg_partman;

-- Настройка автоматического управления: создание и удаление партиций
SELECT partman.create_parent(
  p_parent_table => 'public.events',
  p_control      => 'created_at',
  p_type         => 'range',
  p_interval     => 'monthly',     -- или 'daily', 'weekly', 'quarterly'
  p_premake      => 3              -- создать 3 партиции вперёд
);

-- Настройка retention policy (удаление партиций старше 12 месяцев)
UPDATE partman.part_config
SET retention = '12 months',
    retention_keep_table = false,  -- удалять, не только detach
    infinite_time_partitions = true
WHERE parent_table = 'public.events';

-- Запуск обслуживания вручную (обычно по cron или pg_cron)
SELECT partman.run_maintenance('public.events');

-- pg_partman создаёт/удаляет партиции автоматически:
-- Создаёт partitions на p_premake периодов вперёд
-- Удаляет/детачит партиции старше retention

-- Мониторинг партиций
SELECT * FROM partman.part_config WHERE parent_table = 'public.events';

-- Проверка что INSERT попадёт в правильную партицию
SELECT tableoid::regclass AS partition
FROM events
WHERE ctid = (
  INSERT INTO events (user_id, type, created_at)
  VALUES (1, 'test', '2024-06-15')
  RETURNING ctid
);

pg_partman автоматизирует lifecycle партиций: создаёт новые заранее (premake), удаляет устаревшие по retention. Запускается через cron или pg_cron (встроенный cron для PostgreSQL). Без pg_partman нужно вручную создавать партиции и удалять старые — это легко забыть, что приводит к ошибкам INSERT при отсутствии нужной партиции.

Что происходит под капотом

  • Declarative partitioning (PostgreSQL 10+): родительская таблица — только логический объект, физических данных не хранит. Все данные в дочерних таблицах (партициях). Partitioned table inheritance хранится в pg_inherits.
  • Partition pruning этапы: compile-time pruning при статических условиях (WHERE date = '2024-01-01' — PostgreSQL знает в плане какую партицию читать). Runtime pruning (PostgreSQL 11+) для параметров ($1) — исключение выполняется при получении значения параметра.
  • Маршрутизация INSERT: PostgreSQL проверяет значение partition key и находит подходящую партицию через pg_partitioned_table и ограничения чека. Если нет подходящей партиции и нет DEFAULT — ошибка 'no partition of relation found'.
  • LOCAL индексы: каждая партиция имеет свои индексы. CREATE INDEX ON parent создаёт индексы на всех существующих партициях и будет автоматически создавать на новых. GLOBAL индексов в PostgreSQL нет — это отличие от Oracle.
  • UNIQUE constraints: должны включать ключ партиционирования. Это значит что uniqueness проверяется только внутри партиции, не глобально. Нельзя гарантировать уникальность email если email не является ключом партиционирования.
  • Attach/Detach: ATTACH PARTITION позволяет преобразовать обычную таблицу в партицию (с проверкой данных). DETACH PARTITION — обратная операция. PostgreSQL 14+ добавил CONCURRENTLY — без блокировки родительской таблицы.
  • Subpartitioning: партиции могут быть партиционированы дальше. Например, RANGE по году → LIST по стране. Это добавляет сложность планирования и поддержки — используй только при реальной необходимости.
  • Параллельное сканирование: PostgreSQL может сканировать несколько партиций параллельно (Parallel Append). Полезно для аналитических запросов без условия по ключу — каждый worker читает свою партицию.

Типичные ошибки и заблуждения

  • Ошибка: партиционирование ускоряет любые запросы. Partition pruning работает только при условии WHERE по ключу партиционирования. Запрос без такого условия читает ВСЕ партиции (Append план) — медленнее чем единая таблица с индексом.
  • Ошибка: UNIQUE constraint работает глобально на партиционированной таблице. PostgreSQL не поддерживает глобальную уникальность без включения ключа партиционирования. Для уникальности по email нужен отдельный механизм (lookup table или приложение).
  • Ошибка: партиционирование нужно добавлять с самого начала для любой таблицы. До 50-100M строк с правильными индексами производительность обычно отличная. Партиционирование добавляет сложность администрирования — добавляй при реальных проблемах, не превентивно.
  • Ошибка: INSERT в партиционированную таблицу работает так же быстро. INSERT требует проверки partition key и маршрутизации — незначительный overhead. При очень высоком insert rate (100k/s) это может быть заметно — измеряй.
  • Ошибка: автоматическое создание партиций можно опустить. Без pg_partman или ручного cron следующая партиция не будет создана заранее. INSERT в таблицу без подходящей партиции (и без DEFAULT) вернёт ошибку в продакшне.
  • Ошибка: DROP TABLE partition безопасно выполнять в любой момент. Если есть активные транзакции читающие эту партицию, DROP TABLE будет ждать. Планируй удаление в периоды низкой нагрузки или используй DETACH CONCURRENTLY + DROP.

Ключевые выводы

  • Ключ партиционирования должен быть в WHERE большинства запросов — иначе partition pruning не работает.
  • DROP TABLE partition — мгновенное удаление данных за период (vs часы DELETE).
  • UNIQUE constraint должен включать ключ партиционирования — глобальной уникальности нет.
  • pg_partman автоматизирует создание новых и удаление устаревших партиций по retention policy.
  • Партиционирование добавляй при реальных проблемах (bloat, медленный VACUUM) — не превентивно.

Термины урока

Declarative partitioning: синтаксис PARTITION BY для создания партиционированных таблиц (PostgreSQL 10+).
RANGE partitioning: партиции по диапазону значений ключа (даты, числа).
LIST partitioning: партиции по явным спискам значений (страны, статусы).
HASH partitioning: равномерное распределение по hash(key) MOD N.
Partition pruning: исключение нерелевантных партиций из плана выполнения.
DEFAULT partition: партиция для строк, не попадающих ни в одну другую.
LOCAL index: индекс на отдельной партиции (глобальных индексов нет).
pg_partman: PostgreSQL extension для автоматического lifecycle management партиций.

Связь с работой backend-разработчика

Партиционируй по created_at для: событийных логов (clickstream, audit), метрик, транзакционных таблиц с неограниченным ростом. Квартальные или месячные партиции в зависимости от объёма. Настрой pg_partman для автосоздания партиций (premake = 2-3) + retention policy для удаления старых. Мониторь: новые партиции должны создаваться заранее, иначе INSERT зафейлится в проде.

Мини-разбор реальной ситуации

Таблица audit_log: 3 года данных, 2 миллиарда строк, 800GB. VACUUM занимает 8 часов и не успевает — dead tuples растут. Индекс на created_at весит 40GB. Удаление данных старше года: DELETE ... занял бы 12 часов. Решение: CREATE TABLE audit_log_partitioned PARTITION BY RANGE (created_at), создание 36 месячных партиций, миграция данных батчами за выходные. Результат: VACUUM каждой партиции — 15 минут. Удаление 12 старых партиций: DROP TABLE × 12 — 30 секунд. Размер индекса каждой партиции: 1-2GB. pg_partman настроен на автосоздание месячных партиций и удаление партиций старше 24 месяцев.

Что запомнить

  • Запрос без WHERE по ключу партиционирования = full scan всех партиций. Medленнее чем единая таблица!
  • DROP TABLE partition = мгновенно. DELETE по дате = часы. Всегда используй drop для bulk deletion.
  • Партиции нужно создавать ЗАРАНЕЕ — без подходящей партиции INSERT вернёт ошибку в проде.

Итог

Партиционирование — не серебряная пуля, а инструмент для конкретных проблем: очень большие таблицы с непрерывным ростом, необходимость быстрого удаления устаревших данных, медленный VACUUM. Правильный ключ партиционирования критичен — он должен быть в WHERE большинства запросов. С pg_partman lifecycle партиций автоматизируется полностью: создание, удаление, мониторинг.