SQLLab
Все статьи

Секционирование таблиц в PostgreSQL: PARTITION BY RANGE, LIST, HASH

Секционирование в PostgreSQL: PARTITION BY RANGE, LIST, HASH. Когда нужно секционирование, как создать и управлять секциями, прирост производительности.

24 марта 2026 г.·5 мин чтения·

Когда таблица вырастает до десятков гигабайт, обычные индексы перестают спасать. Секционирование разбивает большую таблицу на физически отдельные части — секции — сохраняя единый интерфейс для запросов.

Когда нужно секционирование

  • Таблица занимает больше 50–100 ГБ и растёт
  • Большинство запросов фильтруют по одной колонке (дата, регион, статус)
  • Нужно быстро удалять старые данные (например, логи старше года)
  • Загрузка данных идёт батчами по временным диапазонам

Не стоит секционировать маленькие таблицы — накладные расходы не окупятся.


PARTITION BY RANGE — по диапазону

Самый частый случай: секционирование по дате. Каждая секция хранит данные за один период.

-- Создаём секционированную таблицу
CREATE TABLE orders (
    id BIGSERIAL,
    user_id INT,
    total NUMERIC,
    status TEXT,
    created_at TIMESTAMPTZ NOT NULL
) 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');

-- DEFAULT-секция для строк не попавших ни в одну секцию
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

Граница TO не включается — строка с created_at = '2026-02-01' попадёт в orders_2026_02, а не в orders_2026_01.

-- INSERT автоматически попадает в нужную секцию
INSERT INTO orders (user_id, total, status, created_at)
VALUES (1, 5000, 'paid', '2026-02-15');
-- → пошёл в orders_2026_02

-- SELECT автоматически читает только нужные секции (partition pruning)
SELECT * FROM orders WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';
-- → читает только orders_2026_02

Partition Pruning — главное преимущество

PostgreSQL анализирует условия WHERE и пропускает секции, в которых заведомо нет нужных данных. Это называется partition pruning:

EXPLAIN SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
Append
  ->  Seq Scan on orders_2026_01
        Filter: (created_at >= '2026-01-01' AND created_at < '2026-02-01')
-- orders_2026_02, orders_2026_03 не читаются вообще!

Без секционирования пришлось бы сканировать всю таблицу. С секционированием — только нужный месяц.


PARTITION BY LIST — по списку значений

Подходит для дискретных значений: регион, статус, категория.

CREATE TABLE events (
    id BIGSERIAL,
    region TEXT NOT NULL,
    event_type TEXT,
    data JSONB,
    created_at TIMESTAMPTZ
) PARTITION BY LIST (region);

CREATE TABLE events_russia PARTITION OF events
    FOR VALUES IN ('russia', 'RU');

CREATE TABLE events_europe PARTITION OF events
    FOR VALUES IN ('germany', 'france', 'spain', 'DE', 'FR', 'ES');

CREATE TABLE events_other PARTITION OF events DEFAULT;
-- Запрос читает только европейские секции
SELECT COUNT(*) FROM events WHERE region IN ('germany', 'france');
-- → Читает только events_europe

PARTITION BY HASH — равномерное распределение

Когда нет естественного ключа для разбивки, HASH распределяет строки равномерно по фиксированному числу секций:

CREATE TABLE user_activity (
    id BIGSERIAL,
    user_id INT NOT NULL,
    action TEXT,
    created_at TIMESTAMPTZ
) PARTITION BY HASH (user_id);

-- 4 секции: MODULUS = количество секций, REMAINDER = номер секции (0..N-1)
CREATE TABLE user_activity_0 PARTITION OF user_activity
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);

CREATE TABLE user_activity_1 PARTITION OF user_activity
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);

CREATE TABLE user_activity_2 PARTITION OF user_activity
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);

CREATE TABLE user_activity_3 PARTITION OF user_activity
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

Hash-партиционирование не даёт pruning по диапазонам, но позволяет распараллелить нагрузку по нескольким физическим хранилищам.


Индексы на секционированных таблицах

-- Индекс на родительской таблице создаётся на всех секциях автоматически
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- → Создаст idx на orders_2026_01, orders_2026_02, orders_2026_03...

-- Индекс только на конкретной секции
CREATE INDEX ON orders_2026_01 (user_id);

-- PRIMARY KEY и UNIQUE должны включать ключ партиционирования
-- Иначе PostgreSQL не сможет гарантировать уникальность
CREATE UNIQUE INDEX ON orders (id, created_at);

Управление секциями: DETACH и ATTACH

Самая полезная операция секционирования — мгновенное удаление старых данных:

-- Отсоединить секцию (данные остаются в таблице, просто отвязываются)
ALTER TABLE orders DETACH PARTITION orders_2024_01;

-- Теперь orders_2024_01 — обычная таблица, можно делать что угодно
DROP TABLE orders_2024_01;  -- мгновенно, не DELETE по строкам!

-- Или заархивировать:
-- COPY orders_2024_01 TO '/archive/orders_2024_01.csv'
-- DROP TABLE orders_2024_01;

-- Подключить существующую таблицу как секцию
ALTER TABLE orders ATTACH PARTITION orders_2026_04
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

DROP TABLE на секции — мгновенная операция, в отличие от DELETE FROM orders WHERE created_at < '2025-01-01' которое может работать часами.


Автоматическое создание секций

В реальных проектах секции создаются заранее (через pg_cron или скрипт деплоя):

-- Создать секцию на следующий месяц
DO $$
DECLARE
    next_month DATE := DATE_TRUNC('month', NOW() + INTERVAL '1 month');
    partition_name TEXT := 'orders_' || TO_CHAR(next_month, 'YYYY_MM');
BEGIN
    EXECUTE format(
        'CREATE TABLE IF NOT EXISTS %I PARTITION OF orders
         FOR VALUES FROM (%L) TO (%L)',
        partition_name,
        next_month,
        next_month + INTERVAL '1 month'
    );
END $$;

Подводные камни

Партиционирование не заменяет индексы. Partition pruning работает только по ключу партиционирования. По остальным полям всё равно нужны индексы.

Ограничения уникальности. UNIQUE и PRIMARY KEY должны включать ключ партиционирования — иначе PostgreSQL физически не может проверить уникальность между секциями.

Миграция существующей таблицы. Нельзя превратить обычную таблицу в секционированную напрямую. Нужно: создать новую секционированную таблицу → скопировать данные → переименовать.


Итог

ТипКогда использовать
RANGEВременные ряды, даты, числовые диапазоны
LISTРегион, статус, категория (дискретные значения)
HASHРавномерное распределение без естественного ключа

Секционирование — инструмент для больших таблиц. Для таблиц до нескольких ГБ индексы решают задачу эффективнее.

Проверь знания по оптимизации PostgreSQL в нашем тренажёре.

Похожие статьи

Попробуй на практике

Тренажёр с реальными задачами — бесплатно и без регистрации

Открыть тренажёр →