Эффективное хранение документов в Postgres: партиции, индексы, архив
Предположим, что корпоративная СЭД обрабатывает порядка ~1 000 000 документов в сутки (≈365 млн строк в год). Хранение всех записей в одной таблице приводит к деградации производительности:
- Индексы неконтролируемо растут;
- Типовые запросы вынужденно сканируют гигабайты данных;
autovacuumне успевает поддерживать таблицу в рабочем состоянии.
⚠️
autovacuum— это встроенный процесс PostgreSQL, который чистит строки послеUPDATE/DELETEи поддерживает статистику. При массовыхDELETEнагрузка на него резко возрастает. При работе с партициями этой проблемы не возникает, т.к. старые данные удаляются целиком.
Целевые требования
- Стабильная скорость вставки новых документов. Поток данных идет непрерывно, и вставки должны оставаться одинаково быстрыми при росте объема. Если скорость деградирует, переполняются очереди интеграции, начинают отваливаться сервисы, и система становится нестабильной.
- Низкая латентность оперативных выборок по свежим данным. Пользователь ждет быстрых откликов при поиске и просмотре недавних документов. Для него критично, чтобы запросы по последним 30–90 дням выполнялись в миллисекундах, а не в секундах.
- Изоляция и экономичное хранение исторических данных. Документы старше года редко используются, но при этом они составляют основной объем. Хранить их в том же виде, что и новые данные, — дорого и неэффективно. Нужно изолировать их в отдельные партиции или даже в архивное хранилище, чтобы они не мешали рабочей нагрузке.
Базовый подход
Для выполнения этих требований используется комбинация трех проверенных техник, которые в сумме дают прогнозируемую производительность даже при больших потоках:
Временное партиционирование. Основная таблица разбивается на диапазоны по дате создания (
created_at). Каждая партиция покрывает небольшой интервал (обычно сутки при высоком трафике). Это позволяет планировщику PostgreSQL быстро отсекать лишние партиции при запросах и не сканировать десятки миллионов строк. Кроме того, операцииVACUUMиANALYZEстановятся локальными для каждой партиции, что резко снижает нагрузку на систему.Архивация партиций по политике ретеншна. Устаревшие партиции (например, старше 12 месяцев) не удаляются построчно, а целиком отсоединяются (
DETACH) или удаляются (DROP). Такой подход позволяет мгновенно освободить сотни гигабайт данных без долгих транзакций и блокировок. При необходимости они могут быть перенесены в отдельный архивный кластер или сохранены в файловом формате (например, Apache Parquet).Разделение метаданных и полезной нагрузки. В PostgreSQL целесообразно хранить только “шапку документа”: идентификаторы, даты, статусы и ключевые поля для поиска. Тяжелый payload (большие XML/JSON, бинарные вложения, файлы) лучше вынести в объектное хранилище (S3, MinIO). В БД остается лишь ссылка и контрольная сумма. Это уменьшает размер партиций и ускоряет индексацию.
⚠️ Ретеншн (retention policy) — это просто “политика хранения данных”, то есть правила, сколько времени и в каком виде данные хранятся в основной БД. Например, хранить документы 90 дней в “горячих” партициях, до 12 месяцев в “теплых” партициях, все старше года переносить в архив или удалять.
Модель hot/warm/cold
В крупных системах документооборота все данные делятся на три слоя хранения. Такой подход позволяет задать разные режимы обслуживания и разные требования к ресурсам:
- Hot (горячие данные, последние 30–90 дней). Основная зона активной работы пользователей. Данные находятся в партициях на быстрых дисках (SSD/NVMe). Используются полноценные B-Tree индексы (часто частичные —
WHERE status = 'ACTIVE'), чтобы обеспечивать миллисекундные отклики. На этом уровне работают все критичные запросы: выборки, фильтрация по статусу, сортировка по дате. - Warm (теплые данные, 3–12 месяцев). Данные обращаются реже: отчеты, аудит, выборки по истории. Могут храниться в базе, но с минимальным набором индексов. Для больших объемов лучше использовать компактные BRIN-индексы, которые не растут пропорционально числу строк, а работают за счет диапазонов. Хранение можно вынести на более дешевое хранилище (например, HDD).
- Cold (холодные данные, старше года). Практически не используются в ежедневной работе. Архивируются через
DETACHпартиций и переносятся в отдельный PostgreSQL-кластер или в объектное хранилище (S3/MinIO) в формате Parquet/CSV. В основной базе остаются только ключевые метаданные и ссылки на архив, чем достигается экономия места и снижение нагрузки на продакшн-кластер.
Партиционирование по времени
В PostgreSQL есть встроенный механизм RANGE partitioning, который позволяет разрезать таблицу на части по диапазону значений. В случае документооборота естественным ключом для партиционирования является поле created_at.
Такой подход гарантирует, что каждая партиция содержит данные за определенный интервал времени (чаще всего за сутки). Это делает таблицу управляемой по размеру и облегчает обслуживание.
Пример создания базовой структуры:
Пример:
1
2
3
4
5
6
7
8
9
10
11
12
13
create table document (
id bigserial primary key,
created_at timestamptz not null,
status text not null,
account_id bigint not null,
payload_ref text, -- ссылка на объект в S3
payload_hash bytea
) partition by range (created_at);
-- Партиция на день
create table document_2025_08_21
partition of document
for values from ('2025-08-21') to ('2025-08-22');
Когда данные устаревают, работать с ними построчно нецелесообразно. Массовые DELETE приводят к росту таблицы и индексов, а также увеличивают нагрузку на обслуживание. Вместо этого операции выполняются на уровне целых партиций:
1
2
3
4
5
-- Отсоединение партиции (операция выполняется быстро, без долгих блокировок)
alter table document detach partition document_2024_08_21;
-- Полное удаление партиции
drop table document_2024_08_21;
Таким образом, управление жизненным циклом данных происходит не за счет операций над отдельными строками, а через управление целыми партициями, что значительно эффективнее для больших объемов.
Архивация
При достижении срока хранения старые данные нужно либо перенести в архив, либо удалить. Делать это следует на уровне целых партиций, а не отдельных строк. Такой подход позволяет управлять объемами данных без лишних накладных расходов.
Перенос в отдельную схему
Если требуется сохранить доступ к данным (например, для отчетности или аудита), устаревшую партицию можно «отцепить» от основной таблицы и прикрепить к таблице в схеме archive:
1
2
3
4
5
6
-- Отсоединяем партицию от основной таблицы
alter table document detach partition document_2024_08_21;
-- Прикрепляем ее к архивной структуре
alter table archive.document attach partition document_2024_08_21
for values from ('2024-08-21') to ('2024-08-22');
Такой перенос выполняется быстро, так как PostgreSQL работает с метаданными, а не физически копирует данные.
Удаление после истечения срока хранения
Если данные больше не нужны, партицию можно удалить полностью:
1
drop table document_2024_08_21;
В результате освобождается место на диске, а производственная база продолжает работать только с актуальными данными.
Разделение метаданных и payload
Документ в системе обычно состоит из двух частей:
- Метаданные — идентификатор, дата создания, статус, бизнес-ключи, ссылки на связанный объект;
- Payload — собственно содержимое документа: XML, JSON, вложенные файлы.
Для СУБД эти части ведут себя по-разному. Метаданные постоянно участвуют в фильтрации и выборках (WHERE status = 'ACTIVE', WHERE created_at > ...), поэтому они должны храниться в PostgreSQL в “плоском” виде, доступном для индексов. Payload же почти никогда не фильтруется в запросах, и его размер может быть в десятки или сотни килобайт.
Если payload хранить в PostgreSQL напрямую (например, в bytea или jsonb), включается механизм TOAST (внутреннее хранение больших значений вне основной страницы). Это резко увеличивает I/O, усложняет вакуумирование и раздувает бэкапы.
Поэтому практическая схема выглядит так:
- В PostgreSQL хранится только шапка документа (метаданные) и ссылка на содержимое (например,
payload_ref), плюс контрольная сумма (payload_hash) для проверки целостности; - В объектном хранилище (S3, MinIO) хранится payload — большие XML/JSON и бинарные файлы.
Преимущества:
- Нагрузка на Postgres снижается, так как в запросах участвуют только легкие поля;
- Индексы остаются компактными;
- Резервное копирование базы ускоряется, так как бэкапятся только метаданные, а не сотни гигабайт документов;
- payload можно масштабировать независимо — вынести в дешевое облачное хранилище, подключить CDN или отдельный кластер для файлов.
Индексация
Индексы — ключевой инструмент для ускорения выборок в PostgreSQL. Однако при работе с большими объемами данных важно правильно выбирать их тип и область применения.
B-Tree индексы
B-Tree — это стандартный тип индекса в PostgreSQL. Он оптимален для точных сравнений (=) и диапазонных запросов (>, <, BETWEEN). На практике такие индексы применяются для “горячих” партиций, где выполняется большинство оперативных выборок.
Пример:
1
2
3
create index on document_2025_08_21 (account_id, created_at desc)
where status = 'ACTIVE';
В данном случае используется partial index — он строится только для подмножества строк, удовлетворяющих условию (status = 'ACTIVE'). Такой прием позволяет уменьшить размер индекса и ускорить выборки по часто используемому сценарию: “все активные документы за последние дни”.
BRIN индексы
BRIN (Block Range Index) — компактный индекс, который хранит статистику по диапазонам данных в блоках таблицы. Его размер не зависит линейно от числа строк и остается очень маленьким даже для сотен миллионов записей.
Пример:
1
2
3
create index document_2025_01_created_brin
on document_2025_01 using brin (created_at);
BRIN не подходит для поиска конкретного id, но эффективно ускоряет запросы, работающие с диапазонами времени, например: “все документы за январь”. Для исторических данных, где нагрузка в основном состоит из отчетных выборок по дате, это оптимальное решение.
Практическая схема
- На горячих (hot) партициях: полнофункциональные B-Tree (часто частичные) для полей
status,account_id,created_at. - На теплых (warm): минимум индексов, возможен переход на BRIN.
- На холодных (cold): либо только BRIN, либо индексы отсутствуют вовсе (если данные перенесены в архивное хранилище).
Запросы
Чтобы партиционирование и индексация реально работали, запросы к таблице должны быть правильно сформулированы.
Основной принцип
Планировщик PostgreSQL может “отсечь” ненужные партиции только в том случае, если в WHERE присутствует фильтр по полю, которое используется для партиционирования (например, created_at).
Пример корректного запроса:
1
2
3
4
5
6
7
select id, created_at
from document
where created_at >= now() - interval '30 days'
and status = 'ACTIVE'
and account_id = :acc
order by created_at desc
limit 100;
В этом запросе сразу заданы:
- Диапазон по дате (
created_at >= ...) — отсекаются партиции за ненужные месяцы; - Статус (
status = 'ACTIVE') — задействуетсяpartial index; - Бизнес-ключ (
account_id = :acc) — используется составной индекс.
Ошибки, которых нужно избегать
- 1) Функции на поле партиционирования.
1
where date(created_at) = current_date
Такой фильтр приведет к полному сканированию всех партиций, потому что оптимизатор не сможет сопоставить выражение с границами секций.
2) Фильтры, не совпадающие с индексами. Если построен индекс (
account_id, created_at desc) where status = 'ACTIVE', а запрос используетstatus in ('ACTIVE', 'ARCHIVED'), индекс не будет использован полностью.- 3) Отсутствие ограничения по дате. Запрос без фильтра по
created_atзаставит планировщик обходить все партиции, что эквивалентно сканированию одной большой таблицы.
Рекомендации
- Всегда проверять планы выполнения (
explain analyze); - Поддерживать соответствие фильтров в запросах и условий индексов;
- Для API-запросов внедрять обязательные параметры фильтрации по времени.
Готовые сниппеты
Создание дневных партиций на месяц вперед
Создает daily-партиции для родительской таблицы с RANGE-секционированием по created_at:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
create or replace function create_daily_partitions(
parent_table text, -- имя родителя, например 'document'
start_date date, -- с какой даты создавать
days int -- сколько дней вперед
) returns void
language plpgsql
as $$
declare
d date;
sql text;
part_name text;
begin
for i in 0..days-1 loop
d := start_date + i;
part_name := format('%I_%s', parent_table, to_char(d, 'YYYY_MM_DD'));
sql := format(
'create table if not exists %s partition of %I
for values from (%L) to (%L);',
part_name, parent_table, d::timestamptz, (d + 1)::timestamptz
);
execute sql;
end loop;
end
$$;
-- пример использования:
select create_daily_partitions('document', date '2025-09-01', 31);
Функция предполагает, что document уже создан как partition by range (created_at).
Удаление партиций старше N дней (ретеншн)
Безопасно парсит дату из имени партиции формата document_YYYY_MM_DD и дропает старше порога. Без CASCADE по умолчанию:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
create or replace function drop_old_partitions(
table_prefix text, -- префикс родителя, например 'document'
keep_days int, -- сколько дней держать
use_cascade boolean default false -- использовать ли CASCADE (по умолчанию нет)
) returns void
language plpgsql
as $$
declare
r record;
cutoff date := (now() - (keep_days || ' days')::interval)::date;
part_date date;
drop_sql text;
begin
for r in
select schemaname, tablename
from pg_tables
where tablename like table_prefix || '\_%' escape '\'
loop
begin
part_date := to_date(
substring(r.tablename from '.+_(\d{4}_\d{2}_\d{2})$'),
'YYYY_MM_DD'
);
if part_date is not null and part_date < cutoff then
drop_sql := format('drop table if exists %I.%I %s;',
r.schemaname, r.tablename,
case when use_cascade then 'cascade' else '' end
);
execute drop_sql;
end if;
exception when others then
raise notice 'skip %.% due to: %', r.schemaname, r.tablename, sqlerrm;
end;
end loop;
end
$$;
-- пример: держим 120 дней, без CASCADE
select drop_old_partitions('document', 120, false);
Рукомендуется держать имена партиций в едином формате document_YYYY_MM_DD. Если есть внешние ключи на партиции — внимательно отнестись к CASCADE.
Перенос партиции в архивную схему
Отсоединяет партицию от «боевого» родителя и прикрепляет к “родителю-архиву” в схеме archive:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- предпосылка: в схеме archive есть родитель с тем же DDL:
-- create table archive.document (...) partition by range (created_at);
do $$
declare
part text := 'document_2024_08_21';
begin
execute format('alter table %I detach partition %I;', 'document', part);
execute format(
'alter table %I.%I attach partition %I for values from (%L) to (%L);',
'archive', 'document', part, timestamp '2024-08-21', timestamp '2024-08-22'
);
end $$;
Если архив хранится в другом кластере — вместо attach используйте логическую репликацию/дамп или оставьте партицию как отдельную таблицу archive.document_YYYY_MM_DD.
Индексы для hot-партиций (partial B-Tree)
Создает частичный индекс “под запросы”: активные документы конкретной партиции:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
create or replace function ensure_hot_indexes(part_table text)
returns void language plpgsql as $$
begin
execute format(
'create index if not exists %I_acc_created_active_idx
on %I (account_id, created_at desc)
where status = ''ACTIVE'';',
part_table || '_', part_table
);
execute format(
'create index if not exists %I_created_desc_idx
on %I (created_at desc);',
part_table || '_', part_table
);
end $$;
-- пример:
select ensure_hot_indexes('document_2025_08_21');
BRIN для исторических партиций
Компактный индекс по времени для больших “теплых”/”холодных” таблиц:
1
2
3
4
5
6
7
8
9
10
11
12
13
create or replace function ensure_brin_created(part_table text)
returns void language plpgsql as $$
begin
execute format(
'create index if not exists %I_created_brin
on %I using brin (created_at) with (pages_per_range=128);',
part_table || '_', part_table
);
end $$;
-- пример:
select ensure_brin_created('document_2025_01');
pages_per_range подберите под свой профиль данных и размер блоков. Чем больше параметр — тем меньше индекс и тем грубее диапазоны.
Пример корректного запроса с отсечением партиций
Фильтр по полю партиционирования обязателен; поля в WHERE должны совпадать с индексами:
1
2
3
4
5
6
7
8
explain analyze
select id, created_at
from document
where created_at >= now() - interval '30 days'
and status = 'ACTIVE'
and account_id = :acc
order by created_at desc
limit 100;
Следет избегать date(created_at) = ... — такие функции на колонке мешают отсечению партиций и использованию индексов.
Заготовка CRON-job (pg_cron) для обслуживания
Плановое создание будущих партиций и удаление старых:
1
2
3
4
5
6
7
8
9
10
-- пример для расширения pg_cron
-- создать партиции на 30 дней вперед каждую ночь
select cron.schedule('0 2 * * *',
$$select create_daily_partitions('document', (current_date + 1), 30);$$
);
-- удалять партиции старше 120 дней каждую ночь
select cron.schedule('30 2 * * *',
$$select drop_old_partitions('document', 120, false);$$
);
Вместо pg_cron можно использовать внешние планировщики (systemd timer, Jenkins, Airflow). Главное — запускать операции вне пиков.
Мини-FAQ
- Почему нельзя просто делать DELETE старого? Потому что PostgreSQL при
DELETEтолько помечает строки как удаленные, а сами страницы таблицы остаются занятыми до очисткиVACUUM. В итоге растет объем таблицы и индексов, а производительность падает. Удаление целой партиции (DROP PARTITION) выполняется мгновенно и не оставляет «хвостов». - Зачем отделять payload? Большие XML/JSON перегружают дисковый ввод-вывод и механизм TOAST. Храните payload во внешнем объектном хранилище (MinIO, S3), а в базе — только ссылку и хеш. Так метаданные остаются компактными, а запросы быстрыми.
- А если нужен поиск по payload? Извлекайте необходимые атрибуты из payload при загрузке и сохраняйте их как отдельные колонки или индексы в метаданных. Сам payload пусть остаеся за пределами БД.
- Week или Day партиции? При потоке порядка
1Mдокументов в день лучше использовать дневные партиции. Если поток ниже (например,50–100k/день), можно ограничиться недельными.
Итог
Для нагрузки в миллион документов в день рабочая схема выглядит следующим образом:
- Партиционирование по времени (обычно дневные партиции);
- Хранение payload во внешнем объектном хранилище, в БД — только метаданные и ссылки;
- B-Tree и partial индексы для горячих данных, BRIN — для исторических;
- Удаление и архивирование через
DETACH/DROP PARTITION; - Контроль вставки через батчи и настройку
autovacuumна уровне партиций.
Такой подход позволяет системе стабильно работать при больших объемах вставки и не терять производительность при росте данных.