В 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-файлами, привязанные к родителю.
Parent — только схема и роутинг. Каждая 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 — для упорядоченных ключей, LIST — для категориальных, HASH — для равномерного распределения.
RANGE: типичный сценарий для time-series
Самая частая ситуация в реальных системах — журнал событий, заказов, логов, метрик: данные приходят упорядоченно по времени, старые данные постепенно становятся холодными. RANGE по дате — почти всегда правильный выбор.
Создаём partitioned orders по placed_at. Сразу делаем 4 месячные partition + DEFAULT для «всё остальное». pglite полностью поддерживает declarative partitioning.
Обрати внимание на два момента. Первое: 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 ловит всё, что не подошло ни под один список.
Важно: 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.
Ожидание: ~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). Двухуровневая иерархия.
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.
Чек-лист
- 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 попала строка.