Learning Platform
Урок 13.01 · 22 мин
Продвинутый
PartitioningPARTITION BYRANGELISTHASH

В Fundamentals и в первых модулях курса мы говорили о таблице как об одном heap-файле, который растёт сегментами по 1 GiB. Эта модель работает, пока таблица помещается в десятки гигабайт. Но как только она перевалила за сотни ГиБ, начинаются проблемы: индексы распухают и не лезут в shared_buffers, VACUUM крутит часами, удалить старые данные DELETE нельзя без огромного WAL и блокировок, а планы становятся непредсказуемыми.

Решение — partitioning: разбить логически одну таблицу на десятки физических под-таблиц, каждая из которых — отдельный heap со своими индексами и своим VACUUM. PostgreSQL 10 принёс declarative partitioning: больше не нужно вручную писать триггеры и CHECK-констрейнты, как раньше с inheritance — Postgres сам направляет INSERT в нужную partition.

Партиционированная таблица и её части

Когда ты пишешь CREATE TABLE ... PARTITION BY ..., Postgres создаёт partitioned table — особый объект, который не имеет своего heap-файла. У него только схема (список колонок, типы, partition key). Реальные данные лежат в partitions — это уже обычные таблицы с heap-файлами, привязанные к родителю.

Анатомия partitioned table

Parent — только схема и роутинг. Каждая partition — полноценная таблица с собственным heap, индексами и статистикой.

parent: orders (partitioned by RANGE (placed_at))схема + правила роутинга, БЕЗ heap
partition: orders_2024_01placed_at в [2024-01-01, 2024-02-01)
свой heap~50K строк
свои индексыpkey, btree(customer_id)
partition: orders_2024_02placed_at в [2024-02-01, 2024-03-01)
свой heap~52K строк
свои индексыpkey, btree(customer_id)
partition: orders_2024_03placed_at в [2024-03-01, 2024-04-01)
свой heap~58K строк
свои индексыpkey, btree(customer_id)
INSERT INTO orders (...)Postgres вычисляет partition по ключу и пишет в соответствующий heap

С точки зрения приложения orders — обычная таблица: SELECT * FROM orders WHERE customer_id = 42 работает, как раньше. С точки зрения Postgres — это append relation: планировщик собирает план для каждой partition и склеивает результат через Append node.

Три стратегии разбиения

PostgreSQL поддерживает три типа partition key:

  • RANGE — каждая partition покрывает диапазон значений: [FROM, TO). Типично для time-series: placed_at по месяцам или дням. Гарантирует упорядоченность и легко добавить новую partition «справа».
  • LIST — каждая partition хранит конкретный набор значений: ('RU'), ('DE', 'NL'). Подходит, когда ключ — категориальный с конечным числом значений (страна, тенант, статус).
  • HASH — Postgres сам считает hash(key) mod N и раскидывает строки. Используется, когда нужно равномерно распределить нагрузку, а естественного ключа для RANGE/LIST нет. Минус:
    нельзя расширять без rehash
    .
RANGE vs LIST vs HASH

Один и тот же набор строк, три разные стратегии. RANGE — для упорядоченных ключей, LIST — для категориальных, HASH — для равномерного распределения.

RANGE (placed_at)по месяцам
p_2024_01[Jan, Feb)
p_2024_02[Feb, Mar)
p_2024_03[Mar, Apr)
LIST (country)по странам
p_eu('DE','FR','IT')
p_ru('RU')
p_defaultDEFAULT
HASH (customer_id)modulus 4
p_0hash mod 4 = 0
p_1hash mod 4 = 1
p_2, p_3...

RANGE: типичный сценарий для time-series

Самая частая ситуация в реальных системах — журнал событий, заказов, логов, метрик: данные приходят упорядоченно по времени, старые данные постепенно становятся холодными. RANGE по дате — почти всегда правильный выбор.

Создаём partitioned orders по placed_at. Сразу делаем 4 месячные partition + DEFAULT для «всё остальное». pglite полностью поддерживает declarative partitioning.

PostgreSQL

Обрати внимание на два момента. Первое: primary key обязательно включает partition key. PRIMARY KEY (id, placed_at) — потому что Postgres гарантирует уникальность только внутри partition, а partition определяется по placed_at. Чистый PRIMARY KEY (id) на partitioned table сделать нельзя — будет ошибка. Это серьёзный архитектурный нюанс: глобальные unique-индексы поверх partitions Postgres не поддерживает.

Второе: tableoid::regclass показывает, в какую partition попала каждая строка. Это полезный диагностический трюк — без него непонятно, сработал ли роутинг.

LIST: разбиение по категории

Допустим, у тебя есть multi-tenant SaaS, где каждый клиент изолирован своим tenant_id. Или географический shard: EU-данные хранятся отдельно от остальных. LIST подходит идеально.

LIST partitioning по country. DEFAULT-partition ловит всё, что не подошло ни под один список.

PostgreSQL

Важно: users_other (DEFAULT) ловит IL и GE, которых нет ни в одном явном списке. Без DEFAULT-partition Postgres вернёт ошибку при INSERT строки без подходящей partition. Поэтому в продакшене DEFAULT-partition желательна — иначе INSERT падает при появлении неожиданного значения ключа.

HASH: когда нет естественного ключа

HASH полезен, когда нужно равномерно распределить нагрузку, но естественной шкалы нет. Например, таблица events со 100 млн строк, где user_id — главный ключ доступа, но количество пользователей миллионы и LIST не построить.

HASH partitioning по customer_id, 4 partition. Postgres сам считает hashext(customer_id) mod 4.

PostgreSQL

Ожидание: ~10 000 строк в каждой из 4 partition. Расхождение в пределах 1-2% — это нормально и зависит от внутренней хэш-функции (hashint4).

Главное ограничение HASH: чтобы изменить число partition (например, с 4 на 8), нужно переразбить все данные. RANGE и LIST расширяются простым CREATE TABLE ... PARTITION OF. Поэтому HASH стоит выбирать только тогда, когда заранее ясно, сколько partition нужно, и эта цифра не изменится.

Multi-column partition keys

Partition key может состоять из нескольких колонок. Это полезно, когда хочется сочетать стратегии — например, RANGE по дате + sub-partition по тенанту.

Sub-partitioning: parent RANGE (placed_at), а каждая месячная partition сама — LIST (country). Двухуровневая иерархия.

PostgreSQL

Sub-partitioning стоит использовать осторожно: каждый дополнительный уровень умножает число partition. Если у тебя 24 месяца × 5 регионов = 120 partitions — это уже нагружает planner (каждый запрос проверяет все partition на pruning). PostgreSQL хорошо работает до ~1000 partitions, на больших числах планирование начинает замедляться.

Что ещё нужно знать

  • Индексы. Индекс на parent-таблице автоматически создаётся на всех существующих и будущих partitions (это partitioned index, появился в Postgres 11). До Postgres 11 индексы создавались только на каждой partition вручную.
  • Foreign keys. Можно делать FK с partitioned-таблицы (Postgres 12+) и на partitioned-таблицу. Но FK не может ссылаться на партиционированную таблицу без уникального ключа, включающего partition key.
  • ATTACH/DETACH. Существующую обычную таблицу можно «приклеить» как partition: ALTER TABLE orders ATTACH PARTITION orders_2023 FOR VALUES FROM ('2023-01-01') TO ('2024-01-01'). Postgres проверит, что данные удовлетворяют диапазону. Это базовый механизм миграции на partitioning.
Проверка знанийKnowledge check
Таблица logs, 500M строк, растёт на 5M/день. Главный запрос — фильтр по date в последние 7 дней. Какую стратегию partitioning выберешь и почему?
ОтветAnswer
RANGE по date с дневными partition. Аргументы. (1) Запрос фильтрует по диапазону date — это идеальный case для RANGE pruning: planner отсечёт все partitions кроме последних 7. (2) Дневные partitions означают ~5M строк = ~500 MB на partition; это укладывается в shared_buffers и VACUUM проходит быстро. (3) Старые данные удаляются простым DROP TABLE logs_2023_01_15 вместо DELETE — никакого WAL, никаких блокировок. (4) Можно построить partition-specific индекс на свежих данных, а старые перевести в read-only. LIST и HASH здесь не подходят: LIST не имеет естественного списка для дат, HASH разрушит локальность диапазонного запроса (придётся сканировать все partitions).

Чек-лист

  • Partitioned table не имеет своего heap, только схему и роутинг; данные — в partitions (обычные таблицы).
  • RANGE — диапазоны (даты, числовые ключи), расширяется добавлением новой partition без миграции.
  • LIST — конкретные значения (категории, страны, тенанты), всегда нужен DEFAULT-partition в продакшене.
  • HASH — равномерное распределение по hash(key) mod N, число partition фиксировано на этапе создания.
  • Primary key на partitioned table должен включать partition key — глобальных unique-индексов нет.
  • Sub-partitioning — каждая partition сама может быть partitioned. Будь осторожен с числом — > 1000 partition тормозят planner.
  • tableoid::regclass — диагностический трюк, чтобы увидеть, в какую partition попала строка.
PARTITION BY: жизненный цикл, не оптимизация запросов Hidden partitioning и partition evolution в Apache Iceberg

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что физически представляет собой partitioned table (parent) в PostgreSQL?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5