Dieser Artikel ist derzeit auf Russisch — die englische Übersetzung ist in Arbeit.
Когда таблица маленькая, с ней обычно всё просто.
Есть таблица orders, в ней лежат заказы. Есть таблица logs, в ней лежат события. Запросы работают, индексы помогают, старые данные можно удалить обычным DELETE.
Но со временем таблица может вырасти до десятков или сотен миллионов строк.
И тут начинаются знакомые проблемы:
Запросы по последнему месяцу читают слишком много данных.
Индексы становятся огромными.
VACUUM работает долго.
Удаление старых строк превращается в тяжёлую операцию.
Бэкапы и обслуживание таблицы становятся неприятными.
Особенно больно удалять старые данные.
Например, нужно удалить все заказы старше года:
DELETE FROM orders
WHERE created_at < now() - interval '1 year';
На большой таблице такой запрос может работать долго, создавать много WAL, блокировать другие операции, нагружать диск и превращать обычную чистку данных в продакшен-пожар.
Секционирование помогает решить эту проблему иначе.
Идея простая:
Снаружи у нас как будто одна большая таблица, а внутри она физически разбита на несколько маленьких таблиц-секций.
Для данных по времени это особенно удобно.
Например:
orders_2024
orders_2025
orders_2026
Или помесячно:
orders_2026_01
orders_2026_02
orders_2026_03
PostgreSQL сам понимает, в какую секцию положить новую строку, и может читать только нужные секции, если запрос фильтрует данные по дате.
Что такое секционирование
Секционирование — это когда одна логическая таблица разбивается на несколько физических частей.
Есть родительская таблица:
orders
И есть её секции:
orders_2024
orders_2025
orders_2026
Пользователь обычно работает с родительской таблицей:
SELECT *
FROM orders
WHERE created_at >= '2025-03-01'
AND created_at < '2025-04-01';
Но внутри PostgreSQL может понять:
Запросу нужны данные только за 2025 год.
Секцию orders_2024 можно не читать.
Секцию orders_2026 можно не читать.
Это и есть один из главных смыслов партиций: не трогать лишние куски данных.
Почему чаще всего секционируют по времени
Секционирование можно делать по разным признакам, но самый частый вариант — по дате.
Например:
- заказы по
created_at;
- платежи по
paid_at;
- логи по
created_at;
- события аналитики по
event_time;
- история изменений по
changed_at;
- сообщения по
sent_at.
Почему дата так удобна?
Потому что во многих системах данные естественно живут во времени.
Мы часто спрашиваем:
Покажи заказы за март.
Покажи логи за последние 24 часа.
Построй отчёт за прошлый месяц.
Удали события старше 90 дней.
Если таблица разбита по времени, такие операции становятся намного понятнее и часто быстрее.
PARTITION BY RANGE: секционирование по диапазону
Для разбиения по времени в PostgreSQL обычно используют:
PARTITION BY RANGE
RANGE означает, что каждая секция отвечает за свой диапазон значений.
Например:
orders_2024 хранит строки с created_at от 2024-01-01 до 2025-01-01
orders_2025 хранит строки с created_at от 2025-01-01 до 2026-01-01
orders_2026 хранит строки с created_at от 2026-01-01 до 2027-01-01
Важное правило:
FROM включается.
TO не включается.
То есть диапазон:
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01')
означает:
created_at >= '2025-01-01'
created_at < '2026-01-01'
Дата 2025-01-01 входит в секцию.
Дата 2026-01-01 уже не входит.
Это удобно, потому что соседние диапазоны не пересекаются.
Создаём родительскую таблицу
Начнём с таблицы заказов.
В PostgreSQL декларативное секционирование создаётся так:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
Здесь важная часть:
PARTITION BY RANGE (created_at)
Она говорит PostgreSQL:
Таблица orders будет разбита на секции по диапазонам created_at.
Сама родительская таблица orders не хранит строки как обычная таблица. Она больше похожа на входную точку или общий интерфейс для всех секций.
Вы делаете:
INSERT INTO orders (...)
а PostgreSQL сам отправляет строку в нужную секцию.
Почему PRIMARY KEY составной
В примере первичный ключ такой:
PRIMARY KEY (id, created_at)
А не просто:
PRIMARY KEY (id)
Это не случайность.
В PostgreSQL есть ограничение: если вы создаёте UNIQUE или PRIMARY KEY на секционированной таблице, в него должны входить все колонки ключа секционирования.
У нас ключ секционирования:
created_at
Значит, первичный ключ тоже должен включать created_at.
Почему так?
Потому что PostgreSQL должен уметь проверять уникальность по секциям. Если уникальный ключ не содержит колонку секционирования, одна и та же id теоретически могла бы оказаться в разных секциях, а глобального уникального индекса на все секции в привычном виде у PostgreSQL нет.
Поэтому для секционированной таблицы часто делают так:
PRIMARY KEY (id, created_at)
Или вообще не объявляют первичный ключ на родителе, если глобальная уникальность id обеспечивается другим способом, например последовательностью и логикой приложения. Но если нужен именно PRIMARY KEY на секционированной таблице, колонка секционирования должна быть внутри.
Создаём секции
Теперь создадим секции по годам:
CREATE TABLE orders_2024 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE orders_2025 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE orders_2026 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
Теперь PostgreSQL знает:
| Секция |
Какие строки хранит |
orders_2024 |
2024-01-01 <= created_at < 2025-01-01 |
orders_2025 |
2025-01-01 <= created_at < 2026-01-01 |
orders_2026 |
2026-01-01 <= created_at < 2027-01-01 |
Если вставить заказ за 2025 год:
INSERT INTO orders (user_id, amount, status, created_at)
VALUES (100, 2500.00, 'paid', '2025-03-15 10:00:00+00');
он попадёт в секцию orders_2025.
Если вставить заказ за 2024 год:
INSERT INTO orders (user_id, amount, status, created_at)
VALUES (101, 900.00, 'paid', '2024-08-20 10:00:00+00');
он попадёт в orders_2024.
Приложение при этом всё равно работает с таблицей orders.
Что будет, если подходящей секции нет
Допустим, у нас есть секции только до 2026 года.
А приложение пытается вставить заказ за 2027 год:
INSERT INTO orders (user_id, amount, status, created_at)
VALUES (102, 1500.00, 'paid', '2027-01-10 10:00:00+00');
Если секции для 2027 года нет, PostgreSQL не поймёт, куда положить строку, и запрос упадёт с ошибкой.
Это частая проблема в проектах, где секции создают вручную и однажды забывают создать новую.
Поэтому будущие секции нужно создавать заранее.
Например, в конце 2026 года создать секцию для 2027:
CREATE TABLE orders_2027 PARTITION OF orders
FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');
В реальных проектах это часто автоматизируют через cron-задачу, миграции или расширения вроде pg_partman.
Годовые или месячные секции
Секции можно делать по годам:
orders_2024
orders_2025
orders_2026
А можно по месяцам:
orders_2026_01
orders_2026_02
orders_2026_03
Что выбрать?
Зависит от объёма данных и от того, как вы их читаете и удаляете.
Годовые секции проще:
меньше таблиц;
проще администрировать;
удобно для не очень огромных объёмов.
Месячные секции гибче:
легче удалять данные по месяцам;
меньше каждая отдельная секция;
лучше для логов и событий с большим потоком данных.
Если у вас в год 5 миллионов заказов, годовые секции могут быть нормальным решением.
Если у вас 500 миллионов логов в месяц, годовые секции будут слишком большими. Лучше смотреть в сторону месячных, недельных или даже дневных секций.
Правило простое:
Секция должна быть достаточно большой, чтобы не плодить тысячи мелких таблиц,
но достаточно маленькой, чтобы её было удобно обслуживать и удалять.
Индексы на секционированной таблице
Индексы тоже важны.
Если создать индекс на родительской таблице:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
PostgreSQL создаст соответствующие индексы на секциях.
То же самое для частого поиска заказов пользователя:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at);
Такой индекс может быть полезен для запросов:
SELECT *
FROM orders
WHERE user_id = 100
AND created_at >= '2025-01-01'
AND created_at < '2026-01-01';
Важно понимать: индекс на секционированной таблице — это не один огромный физический индекс на все данные. На практике у каждой секции свои индексы.
Это часто плюс.
Индексы становятся меньше, их проще обслуживать, и при удалении старой секции удаляются сразу и её данные, и её индексы.
Partition pruning: PostgreSQL читает только нужные секции
Главная оптимизация при секционировании называется partition pruning.
По-русски можно сказать: PostgreSQL отбрасывает ненужные секции.
Допустим, у нас есть секции:
orders_2024
orders_2025
orders_2026
И мы делаем запрос только за март 2025:
SELECT sum(amount)
FROM orders
WHERE created_at >= '2025-03-01'
AND created_at < '2025-04-01';
PostgreSQL видит условие по created_at и понимает:
Март 2025 года может быть только в секции orders_2025.
Секцию orders_2024 читать не нужно.
Секцию orders_2026 читать не нужно.
Это и есть pruning.
Проверить это можно через EXPLAIN:
EXPLAIN
SELECT sum(amount)
FROM orders
WHERE created_at >= '2025-03-01'
AND created_at < '2025-04-01';
В плане вы должны увидеть, что PostgreSQL работает только с нужной секцией.
Почему фильтр по дате так важен
Секционирование помогает только тогда, когда запрос позволяет PostgreSQL понять, какие секции нужны.
Хороший запрос:
SELECT *
FROM orders
WHERE created_at >= '2025-03-01'
AND created_at < '2025-04-01';
Здесь есть понятный диапазон по ключу секционирования created_at.
Плохой с точки зрения pruning запрос:
SELECT *
FROM orders
WHERE user_id = 100;
В нём нет условия по created_at.
PostgreSQL не знает, в каком году искать заказы пользователя 100. Они могут быть и в orders_2024, и в orders_2025, и в orders_2026.
Поэтому базе придётся смотреть все секции.
Это типичная причина разочарования:
Мы сделали партиции, а запросы не ускорились.
А потом оказывается, что основные запросы не фильтруют по ключу секционирования.
Поэтому ключ секционирования нужно выбирать под реальные запросы.
Если почти все запросы идут по дате — дата хороший ключ.
Если почти все запросы идут только по user_id, а дата не участвует, секционирование по времени может не дать ожидаемого ускорения для этих запросов.
Лучше писать диапазон, а не функцию от даты
Для запросов по времени лучше использовать диапазоны:
WHERE created_at >= '2025-03-01'
AND created_at < '2025-04-01'
А не так:
WHERE date_trunc('month', created_at) = '2025-03-01'
Почему?
Потому что в первом варианте PostgreSQL напрямую видит диапазон по created_at.
Во втором варианте над колонкой применяется функция. Планировщику сложнее сопоставить такое условие с диапазонами секций, а обычный индекс по created_at тоже может использоваться хуже.
Для новичка хорошее правило такое:
Если таблица секционирована по created_at,
пишите фильтр по created_at через >= и <.
Например:
WHERE created_at >= '2026-06-01'
AND created_at < '2026-07-01'
Это аккуратный и понятный стиль для работы с датами.
Удаление старых данных: главный плюс партиций
Одна из главных причин использовать секционирование — быстрое удаление старых данных.
Без секций удаление старых строк выглядит так:
DELETE FROM orders
WHERE created_at < '2025-01-01';
Если таких строк 100 миллионов, база должна:
найти строки;
удалить их;
записать изменения в WAL;
обновить индексы;
оставить после удаления мёртвые строки;
потом ещё ждать VACUUM.
Это тяжёлая операция.
С партициями всё проще.
Если все данные за 2024 год лежат в секции orders_2024, можно удалить всю секцию:
DROP TABLE orders_2024;
Это операция над целой таблицей-секцией. Она не удаляет строки по одной.
По сравнению с огромным DELETE это обычно намного быстрее и чище: вместе с секцией исчезают и её индексы.
В реальной базе DROP TABLE всё равно берёт блокировки на время изменения схемы, поэтому его тоже нужно делать аккуратно. Но это совсем другой масштаб проблемы, чем построчно удалять миллионы строк.
DETACH PARTITION: сначала отсоединить, потом решить судьбу данных
Иногда старые данные не нужно сразу уничтожать.
Например, заказы за 2024 год нужно:
- выгрузить в архив;
- перенести в отдельное хранилище;
- оставить на дешёвом диске;
- передать аналитикам;
- удалить позже.
Тогда можно не делать сразу DROP TABLE, а сначала отсоединить секцию:
ALTER TABLE orders
DETACH PARTITION orders_2024;
После этого orders_2024 перестанет быть секцией orders и станет обычной самостоятельной таблицей.
Данные в ней останутся.
Теперь её можно выгрузить:
COPY orders_2024 TO '/archive/orders_2024.csv' CSV HEADER;
А потом удалить:
DROP TABLE orders_2024;
Смысл такой:
DETACH — вынули секцию из общей таблицы.
DROP — удалили её окончательно.
Это удобно для архивирования и ретеншна.
ATTACH PARTITION: подключить готовую таблицу как секцию
Есть и обратная операция — ATTACH PARTITION.
Она нужна, если у вас уже есть отдельная таблица с историческими данными, и вы хотите подключить её как секцию.
Например:
ALTER TABLE orders
ATTACH PARTITION orders_2024
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
Но здесь важно понимать: PostgreSQL должен убедиться, что в подключаемой таблице действительно лежат только строки из указанного диапазона.
Если такой проверки нельзя избежать, база может просканировать таблицу.
Поэтому для больших таблиц ATTACH PARTITION тоже нужно планировать аккуратно.
Часто перед подключением добавляют подходящий CHECK-constraint, который подтверждает диапазон данных. Тогда PostgreSQL может понять, что строки уже соответствуют нужным границам, и не делать лишний полный скан.
DEFAULT-секция: запасной контейнер
Если строка не попадает ни в один диапазон, вставка в секционированную таблицу упадёт с ошибкой.
Иногда делают специальную секцию по умолчанию:
CREATE TABLE orders_default PARTITION OF orders
DEFAULT;
Она принимает всё, что не подошло ни под одну обычную секцию.
На первый взгляд это удобно.
Например, забыли создать секцию для 2027 года — строки всё равно не упадут, а попадут в orders_default.
Но у этого есть опасность.
DEFAULT-секция может незаметно превратиться в мусорный ящик.
Туда начнут попадать данные, для которых вы забыли создать нормальные секции. Через какое-то время она станет большой, и работать с ней будет неприятно.
Почему DEFAULT-секция может мешать
Допустим, мы забыли создать секцию orders_2027.
Все заказы за 2027 год начали попадать в orders_default.
Потом мы решили исправиться и добавить нормальную секцию:
CREATE TABLE orders_2027 PARTITION OF orders
FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');
Но PostgreSQL должен убедиться, что новая секция не пересекается с тем, что уже лежит в orders_default.
Если в orders_default много данных, это может привести к долгому сканированию и блокировкам.
Поэтому DEFAULT-секция — это не замена нормальному созданию будущих секций.
Лучше относиться к ней как к сигнализации:
Если в orders_default что-то попало,
значит мы забыли создать нужную секцию или получили неожиданные данные.
В хорошей схеме DEFAULT-секция должна быть пустой или почти пустой.
Как заранее создавать будущие секции
Для таблиц по времени лучше заранее создавать секции на будущее.
Например, если сейчас 2026 год, можно уже создать секции на несколько месяцев вперёд:
CREATE TABLE orders_2026_07 PARTITION OF orders
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE orders_2026_08 PARTITION OF orders
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE orders_2026_09 PARTITION OF orders
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
Так приложение не упадёт первого числа нового месяца.
На практике это автоматизируют:
cron-задачей;
миграцией;
админским скриптом;
расширением pg_partman;
внутренним сервисным процессом.
Главная мысль:
Партиции по времени нужно обслуживать заранее.
Секционирование — это не «создал один раз и забыл». Это часть жизненного цикла базы.
Когда секционирование действительно помогает
Секционирование полезно, если у вас есть хотя бы одна из этих задач:
Нужно быстро удалять старые данные целыми периодами.
Большая таблица естественно делится по времени.
Большинство запросов фильтруют по дате.
Индексы на одной огромной таблице становятся слишком тяжёлыми.
Нужно отдельно обслуживать старые и новые данные.
Хорошие кандидаты:
- логи;
- события аналитики;
- заказы;
- платежи;
- история статусов;
- аудиторские записи;
- сообщения;
- трекинг действий пользователя.
Когда секционирование может не помочь
Партиции — не волшебная кнопка ускорения.
Они могут не дать пользы, если:
- таблица не очень большая;
- запросы не фильтруют по ключу секционирования;
- секций слишком много;
- секции слишком мелкие;
- индексы спроектированы плохо;
- приложение часто ищет данные по признаку, который не связан с партициями.
Например, если вы секционировали orders по created_at, а главный запрос такой:
SELECT *
FROM orders
WHERE user_id = 100;
то PostgreSQL может быть вынужден искать пользователя по всем секциям.
В такой ситуации могут помочь индексы на каждой секции, например:
CREATE INDEX idx_orders_user_id
ON orders (user_id);
Но само секционирование по времени не позволит отбросить старые годы, если запрос не говорит, какой период нужен.
Поэтому перед секционированием нужно смотреть не только на размер таблицы, но и на реальные запросы.
VACUUM и обслуживание
У секционирования есть ещё один плюс: обслуживание становится более локальным.
Вместо одной огромной таблицы у вас несколько секций.
Свежие секции активно меняются:
orders_2026_06
orders_2026_07
Старые секции почти не трогаются:
orders_2024_01
orders_2024_02
Это удобно.
Старые данные можно сделать более стабильными, реже трогать, проще архивировать. Новые секции можно активнее обслуживать, анализировать и вакуумить.
Кроме того, когда вы удаляете старую секцию через DROP TABLE, вам не нужно ждать, пока VACUUM уберёт миллионы мёртвых строк после DELETE. Вы просто убираете целый физический кусок таблицы.
Практический пример: таблица логов
Для логов часто делают помесячные секции.
Создадим родительскую таблицу:
CREATE TABLE app_logs (
id bigint GENERATED ALWAYS AS IDENTITY,
level text NOT NULL,
message text NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
Создадим секции:
CREATE TABLE app_logs_2026_06 PARTITION OF app_logs
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE app_logs_2026_07 PARTITION OF app_logs
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE app_logs_2026_08 PARTITION OF app_logs
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
Добавим индекс для частого поиска ошибок по времени:
CREATE INDEX idx_app_logs_level_created
ON app_logs (level, created_at);
Теперь запрос за июль:
SELECT *
FROM app_logs
WHERE level = 'ERROR'
AND created_at >= '2026-07-01'
AND created_at < '2026-08-01';
должен читать только секцию app_logs_2026_07.
А удалить старый месяц можно так:
DROP TABLE app_logs_2026_06;
Это намного приятнее, чем удалять миллионы логов по одному условию WHERE.
MySQL: похожая идея, другие детали
В MySQL тоже есть секционирование по диапазону.
Например, можно использовать PARTITION BY RANGE или RANGE COLUMNS.
Идея похожая:
одна логическая таблица;
несколько физических партиций;
старые партиции можно удалять целиком.
Но детали отличаются от PostgreSQL.
В MySQL другие ограничения, другой синтаксис и другой подход к обслуживанию партиций. Например, старую партицию обычно убирают через:
ALTER TABLE orders DROP PARTITION p2024;
А не через DROP TABLE orders_2024, как в PostgreSQL, где секция является отдельной таблицей.
Также в MySQL важно внимательно смотреть правила по ключам, уникальным индексам и выражениям в partitioning key. В разных версиях и движках поведение может отличаться.
Главная мысль: сама идея похожа, но переносить синтаксис PostgreSQL в MySQL напрямую нельзя.
ClickHouse: партиции — это не совсем то же самое
В ClickHouse партиции тоже часто делают по времени.
Например:
CREATE TABLE orders
(
id UInt64,
user_id UInt64,
amount Decimal(12, 2),
status String,
created_at DateTime
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (created_at, user_id);
Но в ClickHouse партиции играют немного другую роль.
Они особенно полезны для операций с целыми кусками данных:
удалить месяц;
перенести партицию;
заморозить партицию;
работать с большими блоками данных.
Для ускорения запросов в ClickHouse очень важен не только PARTITION BY, но и ORDER BY, то есть ключ сортировки.
Именно ORDER BY помогает ClickHouse быстро читать нужные диапазоны внутри данных.
Поэтому в ClickHouse нельзя думать так:
Сделал PARTITION BY — и все запросы стали быстрыми.
Там нужно отдельно проектировать PARTITION BY и ORDER BY под реальные запросы.
Короткая шпаргалка
Создать секционированную таблицу по времени:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
Создать годовую секцию:
CREATE TABLE orders_2026 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
Создать месячную секцию:
CREATE TABLE orders_2026_06 PARTITION OF orders
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
Хороший фильтр для pruning:
WHERE created_at >= '2026-06-01'
AND created_at < '2026-07-01'
Удалить старую секцию:
DROP TABLE orders_2024;
Отсоединить секцию для архива:
ALTER TABLE orders
DETACH PARTITION orders_2024;
Создать секцию по умолчанию:
CREATE TABLE orders_default PARTITION OF orders
DEFAULT;
Посмотреть секции в psql:
\d+ orders
Главное, что нужно запомнить
Секционирование по диапазону в PostgreSQL — это способ разбить большую таблицу на физические части по диапазонам значений.
Для данных, которые растут во времени, чаще всего используют дату:
PARTITION BY RANGE (created_at)
Это помогает:
- читать только нужные периоды;
- быстрее удалять старые данные;
- держать индексы меньшими;
- проще обслуживать большие таблицы;
- архивировать данные целыми секциями.
Но партиции помогают не всегда.
Они работают хорошо, когда ваши запросы и операции действительно совпадают с ключом секционирования.
Если таблица разбита по created_at, но запросы не фильтруют по created_at, PostgreSQL не сможет просто так отбросить старые секции.
Главное правило:
Ключ секционирования выбирают не по красоте,
а по тому, как данные читают, обслуживают и удаляют.
Для логов, заказов, событий и истории изменений хороший старт — секционирование по времени: по месяцам или по годам.
А дальше важно не забыть про практику:
создавать будущие секции заранее;
писать запросы с фильтром по дате;
не превращать DEFAULT-секцию в мусорку;
понимать ограничения PRIMARY KEY и UNIQUE;
проверять реальные планы через EXPLAIN.
Секционирование — это не просто способ «разбить таблицу». Это инструмент, который помогает большой базе жить спокойнее: быстрее чистить старые данные, меньше трогать лишнее и не превращать каждую операцию обслуживания в битву с сотнями миллионов строк.
Когда таблица маленькая, с ней обычно всё просто.
Есть таблица
orders, в ней лежат заказы. Есть таблицаlogs, в ней лежат события. Запросы работают, индексы помогают, старые данные можно удалить обычнымDELETE.Но со временем таблица может вырасти до десятков или сотен миллионов строк.
И тут начинаются знакомые проблемы:
Особенно больно удалять старые данные.
Например, нужно удалить все заказы старше года:
DELETE FROM orders WHERE created_at < now() - interval '1 year';На большой таблице такой запрос может работать долго, создавать много WAL, блокировать другие операции, нагружать диск и превращать обычную чистку данных в продакшен-пожар.
Секционирование помогает решить эту проблему иначе.
Идея простая:
Для данных по времени это особенно удобно.
Например:
Или помесячно:
PostgreSQL сам понимает, в какую секцию положить новую строку, и может читать только нужные секции, если запрос фильтрует данные по дате.
Что такое секционирование
Секционирование — это когда одна логическая таблица разбивается на несколько физических частей.
Есть родительская таблица:
И есть её секции:
Пользователь обычно работает с родительской таблицей:
SELECT * FROM orders WHERE created_at >= '2025-03-01' AND created_at < '2025-04-01';Но внутри PostgreSQL может понять:
Это и есть один из главных смыслов партиций: не трогать лишние куски данных.
Почему чаще всего секционируют по времени
Секционирование можно делать по разным признакам, но самый частый вариант — по дате.
Например:
created_at;paid_at;created_at;event_time;changed_at;sent_at.Почему дата так удобна?
Потому что во многих системах данные естественно живут во времени.
Мы часто спрашиваем:
Если таблица разбита по времени, такие операции становятся намного понятнее и часто быстрее.
PARTITION BY RANGE: секционирование по диапазону
Для разбиения по времени в PostgreSQL обычно используют:
PARTITION BY RANGERANGEозначает, что каждая секция отвечает за свой диапазон значений.Например:
Важное правило:
То есть диапазон:
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01')означает:
Дата
2025-01-01входит в секцию.Дата
2026-01-01уже не входит.Это удобно, потому что соседние диапазоны не пересекаются.
Создаём родительскую таблицу
Начнём с таблицы заказов.
В PostgreSQL декларативное секционирование создаётся так:
CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY, user_id bigint NOT NULL, amount numeric(12,2) NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL, PRIMARY KEY (id, created_at) ) PARTITION BY RANGE (created_at);Здесь важная часть:
PARTITION BY RANGE (created_at)Она говорит PostgreSQL:
Сама родительская таблица
ordersне хранит строки как обычная таблица. Она больше похожа на входную точку или общий интерфейс для всех секций.Вы делаете:
INSERT INTO orders (...)а PostgreSQL сам отправляет строку в нужную секцию.
Почему PRIMARY KEY составной
В примере первичный ключ такой:
PRIMARY KEY (id, created_at)А не просто:
PRIMARY KEY (id)Это не случайность.
В PostgreSQL есть ограничение: если вы создаёте
UNIQUEилиPRIMARY KEYна секционированной таблице, в него должны входить все колонки ключа секционирования.У нас ключ секционирования:
Значит, первичный ключ тоже должен включать
created_at.Почему так?
Потому что PostgreSQL должен уметь проверять уникальность по секциям. Если уникальный ключ не содержит колонку секционирования, одна и та же
idтеоретически могла бы оказаться в разных секциях, а глобального уникального индекса на все секции в привычном виде у PostgreSQL нет.Поэтому для секционированной таблицы часто делают так:
PRIMARY KEY (id, created_at)Или вообще не объявляют первичный ключ на родителе, если глобальная уникальность
idобеспечивается другим способом, например последовательностью и логикой приложения. Но если нужен именноPRIMARY KEYна секционированной таблице, колонка секционирования должна быть внутри.Создаём секции
Теперь создадим секции по годам:
CREATE TABLE orders_2024 PARTITION OF orders FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); CREATE TABLE orders_2025 PARTITION OF orders FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); CREATE TABLE orders_2026 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');Теперь PostgreSQL знает:
orders_20242024-01-01 <= created_at < 2025-01-01orders_20252025-01-01 <= created_at < 2026-01-01orders_20262026-01-01 <= created_at < 2027-01-01Если вставить заказ за 2025 год:
INSERT INTO orders (user_id, amount, status, created_at) VALUES (100, 2500.00, 'paid', '2025-03-15 10:00:00+00');он попадёт в секцию
orders_2025.Если вставить заказ за 2024 год:
INSERT INTO orders (user_id, amount, status, created_at) VALUES (101, 900.00, 'paid', '2024-08-20 10:00:00+00');он попадёт в
orders_2024.Приложение при этом всё равно работает с таблицей
orders.Что будет, если подходящей секции нет
Допустим, у нас есть секции только до 2026 года.
А приложение пытается вставить заказ за 2027 год:
INSERT INTO orders (user_id, amount, status, created_at) VALUES (102, 1500.00, 'paid', '2027-01-10 10:00:00+00');Если секции для 2027 года нет, PostgreSQL не поймёт, куда положить строку, и запрос упадёт с ошибкой.
Это частая проблема в проектах, где секции создают вручную и однажды забывают создать новую.
Поэтому будущие секции нужно создавать заранее.
Например, в конце 2026 года создать секцию для 2027:
CREATE TABLE orders_2027 PARTITION OF orders FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');В реальных проектах это часто автоматизируют через cron-задачу, миграции или расширения вроде
pg_partman.Годовые или месячные секции
Секции можно делать по годам:
А можно по месяцам:
Что выбрать?
Зависит от объёма данных и от того, как вы их читаете и удаляете.
Годовые секции проще:
Месячные секции гибче:
Если у вас в год 5 миллионов заказов, годовые секции могут быть нормальным решением.
Если у вас 500 миллионов логов в месяц, годовые секции будут слишком большими. Лучше смотреть в сторону месячных, недельных или даже дневных секций.
Правило простое:
Индексы на секционированной таблице
Индексы тоже важны.
Если создать индекс на родительской таблице:
CREATE INDEX idx_orders_created_at ON orders (created_at);PostgreSQL создаст соответствующие индексы на секциях.
То же самое для частого поиска заказов пользователя:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);Такой индекс может быть полезен для запросов:
SELECT * FROM orders WHERE user_id = 100 AND created_at >= '2025-01-01' AND created_at < '2026-01-01';Важно понимать: индекс на секционированной таблице — это не один огромный физический индекс на все данные. На практике у каждой секции свои индексы.
Это часто плюс.
Индексы становятся меньше, их проще обслуживать, и при удалении старой секции удаляются сразу и её данные, и её индексы.
Partition pruning: PostgreSQL читает только нужные секции
Главная оптимизация при секционировании называется
partition pruning.По-русски можно сказать: PostgreSQL отбрасывает ненужные секции.
Допустим, у нас есть секции:
И мы делаем запрос только за март 2025:
SELECT sum(amount) FROM orders WHERE created_at >= '2025-03-01' AND created_at < '2025-04-01';PostgreSQL видит условие по
created_atи понимает:Это и есть pruning.
Проверить это можно через
EXPLAIN:EXPLAIN SELECT sum(amount) FROM orders WHERE created_at >= '2025-03-01' AND created_at < '2025-04-01';В плане вы должны увидеть, что PostgreSQL работает только с нужной секцией.
Почему фильтр по дате так важен
Секционирование помогает только тогда, когда запрос позволяет PostgreSQL понять, какие секции нужны.
Хороший запрос:
SELECT * FROM orders WHERE created_at >= '2025-03-01' AND created_at < '2025-04-01';Здесь есть понятный диапазон по ключу секционирования
created_at.Плохой с точки зрения pruning запрос:
SELECT * FROM orders WHERE user_id = 100;В нём нет условия по
created_at.PostgreSQL не знает, в каком году искать заказы пользователя
100. Они могут быть и вorders_2024, и вorders_2025, и вorders_2026.Поэтому базе придётся смотреть все секции.
Это типичная причина разочарования:
А потом оказывается, что основные запросы не фильтруют по ключу секционирования.
Поэтому ключ секционирования нужно выбирать под реальные запросы.
Если почти все запросы идут по дате — дата хороший ключ.
Если почти все запросы идут только по
user_id, а дата не участвует, секционирование по времени может не дать ожидаемого ускорения для этих запросов.Лучше писать диапазон, а не функцию от даты
Для запросов по времени лучше использовать диапазоны:
WHERE created_at >= '2025-03-01' AND created_at < '2025-04-01'А не так:
WHERE date_trunc('month', created_at) = '2025-03-01'Почему?
Потому что в первом варианте PostgreSQL напрямую видит диапазон по
created_at.Во втором варианте над колонкой применяется функция. Планировщику сложнее сопоставить такое условие с диапазонами секций, а обычный индекс по
created_atтоже может использоваться хуже.Для новичка хорошее правило такое:
Например:
WHERE created_at >= '2026-06-01' AND created_at < '2026-07-01'Это аккуратный и понятный стиль для работы с датами.
Удаление старых данных: главный плюс партиций
Одна из главных причин использовать секционирование — быстрое удаление старых данных.
Без секций удаление старых строк выглядит так:
DELETE FROM orders WHERE created_at < '2025-01-01';Если таких строк 100 миллионов, база должна:
Это тяжёлая операция.
С партициями всё проще.
Если все данные за 2024 год лежат в секции
orders_2024, можно удалить всю секцию:DROP TABLE orders_2024;Это операция над целой таблицей-секцией. Она не удаляет строки по одной.
По сравнению с огромным
DELETEэто обычно намного быстрее и чище: вместе с секцией исчезают и её индексы.В реальной базе
DROP TABLEвсё равно берёт блокировки на время изменения схемы, поэтому его тоже нужно делать аккуратно. Но это совсем другой масштаб проблемы, чем построчно удалять миллионы строк.DETACH PARTITION: сначала отсоединить, потом решить судьбу данных
Иногда старые данные не нужно сразу уничтожать.
Например, заказы за 2024 год нужно:
Тогда можно не делать сразу
DROP TABLE, а сначала отсоединить секцию:ALTER TABLE orders DETACH PARTITION orders_2024;После этого
orders_2024перестанет быть секциейordersи станет обычной самостоятельной таблицей.Данные в ней останутся.
Теперь её можно выгрузить:
COPY orders_2024 TO '/archive/orders_2024.csv' CSV HEADER;А потом удалить:
DROP TABLE orders_2024;Смысл такой:
Это удобно для архивирования и ретеншна.
ATTACH PARTITION: подключить готовую таблицу как секцию
Есть и обратная операция —
ATTACH PARTITION.Она нужна, если у вас уже есть отдельная таблица с историческими данными, и вы хотите подключить её как секцию.
Например:
ALTER TABLE orders ATTACH PARTITION orders_2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');Но здесь важно понимать: PostgreSQL должен убедиться, что в подключаемой таблице действительно лежат только строки из указанного диапазона.
Если такой проверки нельзя избежать, база может просканировать таблицу.
Поэтому для больших таблиц
ATTACH PARTITIONтоже нужно планировать аккуратно.Часто перед подключением добавляют подходящий
CHECK-constraint, который подтверждает диапазон данных. Тогда PostgreSQL может понять, что строки уже соответствуют нужным границам, и не делать лишний полный скан.DEFAULT-секция: запасной контейнер
Если строка не попадает ни в один диапазон, вставка в секционированную таблицу упадёт с ошибкой.
Иногда делают специальную секцию по умолчанию:
CREATE TABLE orders_default PARTITION OF orders DEFAULT;Она принимает всё, что не подошло ни под одну обычную секцию.
На первый взгляд это удобно.
Например, забыли создать секцию для 2027 года — строки всё равно не упадут, а попадут в
orders_default.Но у этого есть опасность.
DEFAULT-секция может незаметно превратиться в мусорный ящик.Туда начнут попадать данные, для которых вы забыли создать нормальные секции. Через какое-то время она станет большой, и работать с ней будет неприятно.
Почему DEFAULT-секция может мешать
Допустим, мы забыли создать секцию
orders_2027.Все заказы за 2027 год начали попадать в
orders_default.Потом мы решили исправиться и добавить нормальную секцию:
CREATE TABLE orders_2027 PARTITION OF orders FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');Но PostgreSQL должен убедиться, что новая секция не пересекается с тем, что уже лежит в
orders_default.Если в
orders_defaultмного данных, это может привести к долгому сканированию и блокировкам.Поэтому
DEFAULT-секция — это не замена нормальному созданию будущих секций.Лучше относиться к ней как к сигнализации:
В хорошей схеме
DEFAULT-секция должна быть пустой или почти пустой.Как заранее создавать будущие секции
Для таблиц по времени лучше заранее создавать секции на будущее.
Например, если сейчас 2026 год, можно уже создать секции на несколько месяцев вперёд:
CREATE TABLE orders_2026_07 PARTITION OF orders FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); CREATE TABLE orders_2026_08 PARTITION OF orders FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); CREATE TABLE orders_2026_09 PARTITION OF orders FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');Так приложение не упадёт первого числа нового месяца.
На практике это автоматизируют:
Главная мысль:
Секционирование — это не «создал один раз и забыл». Это часть жизненного цикла базы.
Когда секционирование действительно помогает
Секционирование полезно, если у вас есть хотя бы одна из этих задач:
Хорошие кандидаты:
Когда секционирование может не помочь
Партиции — не волшебная кнопка ускорения.
Они могут не дать пользы, если:
Например, если вы секционировали
ordersпоcreated_at, а главный запрос такой:SELECT * FROM orders WHERE user_id = 100;то PostgreSQL может быть вынужден искать пользователя по всем секциям.
В такой ситуации могут помочь индексы на каждой секции, например:
CREATE INDEX idx_orders_user_id ON orders (user_id);Но само секционирование по времени не позволит отбросить старые годы, если запрос не говорит, какой период нужен.
Поэтому перед секционированием нужно смотреть не только на размер таблицы, но и на реальные запросы.
VACUUM и обслуживание
У секционирования есть ещё один плюс: обслуживание становится более локальным.
Вместо одной огромной таблицы у вас несколько секций.
Свежие секции активно меняются:
Старые секции почти не трогаются:
Это удобно.
Старые данные можно сделать более стабильными, реже трогать, проще архивировать. Новые секции можно активнее обслуживать, анализировать и вакуумить.
Кроме того, когда вы удаляете старую секцию через
DROP TABLE, вам не нужно ждать, покаVACUUMуберёт миллионы мёртвых строк послеDELETE. Вы просто убираете целый физический кусок таблицы.Практический пример: таблица логов
Для логов часто делают помесячные секции.
Создадим родительскую таблицу:
CREATE TABLE app_logs ( id bigint GENERATED ALWAYS AS IDENTITY, level text NOT NULL, message text NOT NULL, created_at timestamptz NOT NULL, PRIMARY KEY (id, created_at) ) PARTITION BY RANGE (created_at);Создадим секции:
CREATE TABLE app_logs_2026_06 PARTITION OF app_logs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); CREATE TABLE app_logs_2026_07 PARTITION OF app_logs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); CREATE TABLE app_logs_2026_08 PARTITION OF app_logs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');Добавим индекс для частого поиска ошибок по времени:
CREATE INDEX idx_app_logs_level_created ON app_logs (level, created_at);Теперь запрос за июль:
SELECT * FROM app_logs WHERE level = 'ERROR' AND created_at >= '2026-07-01' AND created_at < '2026-08-01';должен читать только секцию
app_logs_2026_07.А удалить старый месяц можно так:
DROP TABLE app_logs_2026_06;Это намного приятнее, чем удалять миллионы логов по одному условию
WHERE.MySQL: похожая идея, другие детали
В MySQL тоже есть секционирование по диапазону.
Например, можно использовать
PARTITION BY RANGEилиRANGE COLUMNS.Идея похожая:
Но детали отличаются от PostgreSQL.
В MySQL другие ограничения, другой синтаксис и другой подход к обслуживанию партиций. Например, старую партицию обычно убирают через:
ALTER TABLE orders DROP PARTITION p2024;А не через
DROP TABLE orders_2024, как в PostgreSQL, где секция является отдельной таблицей.Также в MySQL важно внимательно смотреть правила по ключам, уникальным индексам и выражениям в partitioning key. В разных версиях и движках поведение может отличаться.
Главная мысль: сама идея похожа, но переносить синтаксис PostgreSQL в MySQL напрямую нельзя.
ClickHouse: партиции — это не совсем то же самое
В ClickHouse партиции тоже часто делают по времени.
Например:
CREATE TABLE orders ( id UInt64, user_id UInt64, amount Decimal(12, 2), status String, created_at DateTime ) ENGINE = MergeTree PARTITION BY toYYYYMM(created_at) ORDER BY (created_at, user_id);Но в ClickHouse партиции играют немного другую роль.
Они особенно полезны для операций с целыми кусками данных:
Для ускорения запросов в ClickHouse очень важен не только
PARTITION BY, но иORDER BY, то есть ключ сортировки.Именно
ORDER BYпомогает ClickHouse быстро читать нужные диапазоны внутри данных.Поэтому в ClickHouse нельзя думать так:
Там нужно отдельно проектировать
PARTITION BYиORDER BYпод реальные запросы.Короткая шпаргалка
Создать секционированную таблицу по времени:
CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY, user_id bigint NOT NULL, amount numeric(12,2) NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL, PRIMARY KEY (id, created_at) ) PARTITION BY RANGE (created_at);Создать годовую секцию:
CREATE TABLE orders_2026 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');Создать месячную секцию:
CREATE TABLE orders_2026_06 PARTITION OF orders FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');Хороший фильтр для pruning:
WHERE created_at >= '2026-06-01' AND created_at < '2026-07-01'Удалить старую секцию:
DROP TABLE orders_2024;Отсоединить секцию для архива:
ALTER TABLE orders DETACH PARTITION orders_2024;Создать секцию по умолчанию:
CREATE TABLE orders_default PARTITION OF orders DEFAULT;Посмотреть секции в
psql:\d+ ordersГлавное, что нужно запомнить
Секционирование по диапазону в PostgreSQL — это способ разбить большую таблицу на физические части по диапазонам значений.
Для данных, которые растут во времени, чаще всего используют дату:
PARTITION BY RANGE (created_at)Это помогает:
Но партиции помогают не всегда.
Они работают хорошо, когда ваши запросы и операции действительно совпадают с ключом секционирования.
Если таблица разбита по
created_at, но запросы не фильтруют поcreated_at, PostgreSQL не сможет просто так отбросить старые секции.Главное правило:
Для логов, заказов, событий и истории изменений хороший старт — секционирование по времени: по месяцам или по годам.
А дальше важно не забыть про практику:
Секционирование — это не просто способ «разбить таблицу». Это инструмент, который помогает большой базе жить спокойнее: быстрее чистить старые данные, меньше трогать лишнее и не превращать каждую операцию обслуживания в битву с сотнями миллионов строк.