Когда PostgreSQL начинает тормозить
PostgreSQL отлично справляется с таблицами до 10-20 миллионов строк. Индексы работают, VACUUM успевает, запросы летают. Но когда таблица перевалила за 100 миллионов — начинаются проблемы.
Симптомы знакомы каждому, кто работал с растущим проектом: VACUUM не успевает за INSERT-ами и таблица раздувается (table bloat), индексы становятся огромными и перестают помещаться в shared_buffers, DELETE старых данных блокирует таблицу на минуты, а простой COUNT(*) с фильтром по дате занимает 30+ секунд.
Партиционирование — решение, встроенное в PostgreSQL. Таблица физически разбивается на части (партиции), каждая из которых — отдельная таблица на диске. Запросы автоматически обращаются только к нужным партициям (partition pruning), VACUUM работает с маленькими таблицами вместо одной гигантской, а удаление старых данных — мгновенный DROP партиции вместо DELETE миллионов строк.
В моих проектах партиционирование применялось на трёх таблицах: события аналитики (500M+ строк), логи API-запросов (200M+) и история изменений (audit log, 150M+). Вот как это работает на практике.
Виды партиционирования
Range Partitioning — по диапазону
Самый распространённый вид. Данные разбиваются по диапазону значений — обычно по дате.
CREATE TABLE events (
id bigserial,
event_time timestamptz NOT NULL,
user_id bigint NOT NULL,
event_type text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (event_time);
-- Партиции по месяцам
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE events_2026_03 PARTITION OF events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE events_2026_04 PARTITION OF events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE events_2026_05 PARTITION OF events
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
-- DEFAULT партиция для данных вне диапазонов
CREATE TABLE events_default PARTITION OF events DEFAULT;
Запрос с фильтром по дате автоматически обращается только к нужной партиции:
-- Читает ТОЛЬКО events_2026_05
EXPLAIN SELECT count(*) FROM events
WHERE event_time >= '2026-05-01' AND event_time < '2026-06-01';
-- Append (cost=0.00..35.50 rows=1500 width=8)
-- -> Seq Scan on events_2026_05 events_1
-- Filter: (event_time >= '2026-05-01' AND event_time < '2026-06-01')
List Partitioning — по значению
Данные разбиваются по конкретным значениям — например, по региону или типу.
CREATE TABLE orders (
id bigserial,
region text NOT NULL,
customer_id bigint NOT NULL,
total numeric(12, 2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY LIST (region);
CREATE TABLE orders_eu PARTITION OF orders
FOR VALUES IN ('DE', 'FR', 'IT', 'ES', 'NL', 'PL', 'AT', 'BE');
CREATE TABLE orders_us PARTITION OF orders
FOR VALUES IN ('US', 'CA', 'MX');
CREATE TABLE orders_asia PARTITION OF orders
FOR VALUES IN ('JP', 'KR', 'CN', 'IN', 'SG');
CREATE TABLE orders_other PARTITION OF orders DEFAULT;
List partitioning полезен, когда запросы почти всегда фильтруют по конкретному значению: WHERE region = 'DE' — попадает только в orders_eu.
Hash Partitioning — равномерное распределение
Данные распределяются по хешу значения — равномерно по N партициям.
CREATE TABLE user_activities (
id bigserial,
user_id bigint NOT NULL,
action text NOT NULL,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY HASH (user_id);
CREATE TABLE user_activities_0 PARTITION OF user_activities
FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE user_activities_1 PARTITION OF user_activities
FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ... и так до 7
CREATE TABLE user_activities_7 PARTITION OF user_activities
FOR VALUES WITH (MODULUS 8, REMAINDER 7);
Hash partitioning не поддерживает partition pruning по диапазону — только точное совпадение: WHERE user_id = 12345. Используется когда нет естественного ключа для range/list, но нужно разбить большую таблицу на управляемые части для параллельного VACUUM.
Составное партиционирование (sub-partitioning)
-- Первый уровень: по месяцу
CREATE TABLE logs (
id bigserial,
log_time timestamptz NOT NULL,
level text NOT NULL,
service text NOT NULL,
message text,
metadata jsonb
) PARTITION BY RANGE (log_time);
-- Второй уровень: по сервису внутри месяца
CREATE TABLE logs_2026_05 PARTITION OF logs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01')
PARTITION BY LIST (service);
CREATE TABLE logs_2026_05_api PARTITION OF logs_2026_05
FOR VALUES IN ('api');
CREATE TABLE logs_2026_05_worker PARTITION OF logs_2026_05
FOR VALUES IN ('worker');
CREATE TABLE logs_2026_05_web PARTITION OF logs_2026_05
FOR VALUES IN ('web');
CREATE TABLE logs_2026_05_other PARTITION OF logs_2026_05 DEFAULT;
Двухуровневое партиционирование: запрос WHERE log_time >= '2026-05-01' AND service = 'api' попадает ровно в одну таблицу — logs_2026_05_api.
Индексы на партиционированных таблицах
Индексы создаются на родительской таблице
-- Индекс автоматически создаётся на КАЖДОЙ партиции
CREATE INDEX idx_events_user_id ON events (user_id);
CREATE INDEX idx_events_type ON events (event_type);
CREATE INDEX idx_events_time_user ON events (event_time, user_id);
-- Уникальный индекс ДОЛЖЕН включать partition key
-- Это ограничение PostgreSQL: уникальность гарантируется только внутри партиции
CREATE UNIQUE INDEX idx_events_id_time ON events (id, event_time);
-- НЕ РАБОТАЕТ:
-- CREATE UNIQUE INDEX idx_events_id ON events (id);
-- ERROR: unique constraint on partitioned table must include all partitioning columns
Важное ограничение: уникальный индекс на партиционированной таблице обязан включать partition key. Это значит, что id сам по себе не может быть unique — нужен (id, event_time). Для большинства сценариев это не проблема: primary key становится (id, partition_key).
Частичные индексы по партициям
-- Индекс только на «горячей» партиции текущего месяца
CREATE INDEX idx_events_current_month_type
ON events_2026_05 (event_type, user_id)
WHERE event_type IN ('purchase', 'signup');
-- На старых партициях — минимум индексов (они в основном для аналитики)
-- events_2026_01 может обойтись только базовым индексом по event_time
Это экономит место и ускоряет INSERT: на старых партициях меньше индексов для обновления.
Автоматическое создание партиций
pg_partman — расширение для автоуправления
-- Установка
CREATE EXTENSION pg_partman;
-- Настройка автопартиционирования
SELECT partman.create_parent(
p_parent_table := 'public.events',
p_control := 'event_time',
p_type := 'native',
p_interval := 'monthly',
p_premake := 3 -- создать 3 партиции заранее
);
-- Автоматическое обслуживание (вызывать через cron каждый час)
SELECT partman.run_maintenance();
pg_partman автоматически создаёт новые партиции заранее и может удалять старые:
-- Настройка retention: автоудаление партиций старше 12 месяцев
UPDATE partman.part_config
SET retention = '12 months',
retention_keep_table = false -- DROP, не detach
WHERE parent_table = 'public.events';
Ручное создание через cron + SQL
Если pg_partman недоступен (managed PostgreSQL без расширений):
-- Функция создания следующих партиций
CREATE OR REPLACE FUNCTION create_monthly_partitions(
table_name text,
months_ahead int DEFAULT 3
) RETURNS void AS $$
DECLARE
start_date date;
end_date date;
partition_name text;
i int;
BEGIN
FOR i IN 0..months_ahead LOOP
start_date := date_trunc('month', current_date + (i || ' months')::interval);
end_date := start_date + '1 month'::interval;
partition_name := format('%s_%s', table_name, to_char(start_date, 'YYYY_MM'));
-- Проверить существование
IF NOT EXISTS (
SELECT 1 FROM pg_class WHERE relname = partition_name
) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
partition_name, table_name, start_date, end_date
);
RAISE NOTICE 'Created partition: %', partition_name;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- Вызов
SELECT create_monthly_partitions('events', 3);
# cron: каждый день в 3:00 создавать партиции на 3 месяца вперёд
0 3 * * * psql -U app -d production -c "SELECT create_monthly_partitions('events', 3);"
Миграция существующей таблицы на партиционирование
Стратегия 1: Создать новую таблицу, перенести данные
-- 1. Создать партиционированную таблицу
CREATE TABLE events_partitioned (
id bigserial,
event_time timestamptz NOT NULL,
user_id bigint NOT NULL,
event_type text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (event_time);
-- 2. Создать партиции за существующий период
SELECT create_monthly_partitions('events_partitioned', 3);
-- Для прошлых месяцев:
CREATE TABLE events_partitioned_2025_01 PARTITION OF events_partitioned
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
-- ... и так далее
-- 3. Перенести данные батчами (чтобы не блокировать production)
DO $$
DECLARE
batch_size int := 100000;
last_id bigint := 0;
rows_moved int;
BEGIN
LOOP
WITH moved AS (
INSERT INTO events_partitioned (id, event_time, user_id, event_type, payload, created_at)
SELECT id, event_time, user_id, event_type, payload, created_at
FROM events_old
WHERE id > last_id
ORDER BY id
LIMIT batch_size
RETURNING id
)
SELECT count(*), max(id) INTO rows_moved, last_id FROM moved;
EXIT WHEN rows_moved = 0;
RAISE NOTICE 'Moved % rows, last_id = %', rows_moved, last_id;
-- Пауза чтобы не перегрузить I/O
PERFORM pg_sleep(0.5);
END LOOP;
END $$;
-- 4. Переключить (в момент минимальной нагрузки)
BEGIN;
ALTER TABLE events RENAME TO events_old_backup;
ALTER TABLE events_partitioned RENAME TO events;
-- Пересоздать sequence
ALTER SEQUENCE events_partitioned_id_seq RENAME TO events_id_seq;
COMMIT;
-- 5. Проверить и удалить старую таблицу
-- (через неделю, убедившись что всё работает)
-- DROP TABLE events_old_backup;
Стратегия 2: pg_partman для миграции на лету
-- pg_partman может мигрировать данные из default-партиции
-- в правильные партиции через partition_data_proc
-- 1. Переименовать старую таблицу и создать партиционированную
ALTER TABLE events RENAME TO events_legacy;
CREATE TABLE events (LIKE events_legacy INCLUDING ALL)
PARTITION BY RANGE (event_time);
-- 2. Присоединить старую таблицу как default
ALTER TABLE events ATTACH PARTITION events_legacy DEFAULT;
-- 3. Создать партиции
SELECT partman.create_parent(
p_parent_table := 'public.events',
p_control := 'event_time',
p_type := 'native',
p_interval := 'monthly',
p_premake := 3
);
-- 4. Перенести данные из default в правильные партиции
-- (делается фоново, порциями)
CALL partman.partition_data_proc('public.events', p_batch := 50000);
Удаление старых данных: DROP vs DELETE
Главное преимущество партиционирования для retention — мгновенное удаление данных.
DELETE: медленно и болезненно
-- 100M строк, удалить данные старше года
DELETE FROM events WHERE event_time < now() - interval '1 year';
-- Время: 30-60 минут
-- Блокировки: row-level locks на всё время DELETE
-- WAL: генерирует гигабайты WAL-логов
-- VACUUM: после DELETE нужен VACUUM FULL (ещё 30-60 минут)
-- Итого: 1-2 часа downtime
DROP PARTITION: мгновенно
-- Удалить данные за январь 2025 — мгновенно
DROP TABLE events_2025_01;
-- Время: < 1 секунды
-- Блокировки: минимальные (только DDL lock на долю секунды)
-- WAL: почти нет
-- VACUUM: не нужен
-- Итого: 0 downtime
Или через DETACH для безопасности:
-- 1. Отсоединить партицию (мгновенно)
ALTER TABLE events DETACH PARTITION events_2025_01 CONCURRENTLY;
-- 2. Убедиться что никто не читает
-- 3. Удалить
DROP TABLE events_2025_01;
DETACH CONCURRENTLY (PostgreSQL 14+) не блокирует чтение из основной таблицы.
VACUUM и обслуживание
Почему VACUUM быстрее на партициях
На монолитной таблице в 500M строк VACUUM может работать часами. На партиционированной таблице — каждая партиция vacuumится отдельно:
-- VACUUM одной партиции (миллисекунды-секунды)
VACUUM (VERBOSE) events_2026_05;
-- Автоматический autovacuum работает на каждой партиции независимо
-- Если events_2026_05 активно пополняется — autovacuum чаще
-- events_2026_01 (архивная) — autovacuum редко, потому что нет изменений
Настройка autovacuum по партициям
-- «Горячая» текущая партиция: агрессивный autovacuum
ALTER TABLE events_2026_05 SET (
autovacuum_vacuum_scale_factor = 0.01, -- vacuum при 1% dead tuples
autovacuum_analyze_scale_factor = 0.005,
autovacuum_vacuum_cost_delay = 2 -- быстрее vacuum
);
-- «Холодная» архивная партиция: ленивый autovacuum
ALTER TABLE events_2025_01 SET (
autovacuum_vacuum_scale_factor = 0.2, -- vacuum при 20% dead tuples
autovacuum_enabled = false -- или вовсе отключить
);
Partition Pruning и оптимизация запросов
Как работает partition pruning
PostgreSQL автоматически исключает партиции, которые не могут содержать нужные данные:
-- Включено по умолчанию
SET enable_partition_pruning = on;
EXPLAIN (ANALYZE, BUFFERS) SELECT *
FROM events
WHERE event_time >= '2026-05-01' AND event_time < '2026-06-01'
AND event_type = 'purchase';
-- Append (actual time=0.5..12.3 rows=1523 loops=1)
-- -> Index Scan on events_2026_05 (actual time=0.5..12.1 rows=1523 loops=1)
-- Index Cond: (event_type = 'purchase')
-- Filter: (event_time >= '2026-05-01' AND event_time < '2026-06-01')
-- Buffers: shared hit=156
-- Planning Time: 0.8 ms
-- Execution Time: 13.1 ms
Только events_2026_05 участвует в запросе. Остальные 11+ партиций даже не открываются.
Runtime partition pruning
PostgreSQL 12+ поддерживает runtime pruning — исключение партиций на этапе выполнения, а не только планирования:
-- Параметризованный запрос — pruning на этапе выполнения
PREPARE find_events (timestamptz, timestamptz) AS
SELECT * FROM events WHERE event_time >= $1 AND event_time < $2;
EXECUTE find_events('2026-05-15', '2026-05-16');
-- Runtime pruning: только events_2026_05
Когда pruning НЕ работает
-- 1. Функция на partition key
SELECT * FROM events WHERE date_trunc('month', event_time) = '2026-05-01';
-- НЕТ pruning — PostgreSQL не может вывести диапазон из функции
-- Решение: переписать на диапазон
SELECT * FROM events
WHERE event_time >= '2026-05-01' AND event_time < '2026-06-01';
-- 2. Неявное приведение типов
SELECT * FROM events WHERE event_time >= '2026-05-01';
-- Если event_time — timestamptz, а '2026-05-01' — text
-- Pruning РАБОТАЕТ (PostgreSQL умеет приводить), но explicit cast надёжнее:
SELECT * FROM events WHERE event_time >= '2026-05-01'::timestamptz;
-- 3. OR с разными партициями
SELECT * FROM events
WHERE (event_time >= '2026-01-01' AND event_time < '2026-02-01')
OR (event_time >= '2026-05-01' AND event_time < '2026-06-01');
-- Pruning РАБОТАЕТ — сканируются только 2 партиции
Агрегация по партиционированным таблицам
Parallel Append
PostgreSQL может параллельно сканировать несколько партиций:
SET max_parallel_workers_per_gather = 4;
EXPLAIN (ANALYZE) SELECT
date_trunc('day', event_time) AS day,
event_type,
count(*),
count(DISTINCT user_id)
FROM events
WHERE event_time >= '2026-01-01' AND event_time < '2026-06-01'
GROUP BY day, event_type
ORDER BY day;
-- Gather Merge (actual time=450..520 rows=750 loops=1)
-- Workers Planned: 4
-- Workers Launched: 4
-- -> Sort
-- -> Parallel Append
-- -> Parallel Seq Scan on events_2026_01
-- -> Parallel Seq Scan on events_2026_02
-- -> Parallel Seq Scan on events_2026_03
-- -> Parallel Seq Scan on events_2026_04
-- -> Parallel Seq Scan on events_2026_05
4 воркера параллельно сканируют 5 партиций. На 100M+ строк это кратно быстрее последовательного сканирования.
Partition-wise Aggregation
SET enable_partitionwise_aggregate = on;
-- PostgreSQL выполняет GROUP BY внутри каждой партиции,
-- а потом объединяет результаты
-- Экономит память: не нужно держать все данные в hash table
Мониторинг партиций
Размеры партиций
SELECT
schemaname || '.' || tablename AS partition,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) AS data_size,
pg_size_pretty(pg_indexes_size(schemaname || '.' || tablename)) AS index_size,
(SELECT reltuples::bigint FROM pg_class WHERE relname = tablename) AS approx_rows
FROM pg_tables
WHERE tablename LIKE 'events_2026%'
ORDER BY tablename;
partition | total_size | data_size | index_size | approx_rows
-----------------------|------------|-----------|------------|------------
public.events_2026_01 | 2.1 GB | 1.4 GB | 712 MB | 18500000
public.events_2026_02 | 1.9 GB | 1.3 GB | 640 MB | 16800000
public.events_2026_03 | 2.3 GB | 1.5 GB | 780 MB | 19200000
public.events_2026_04 | 2.0 GB | 1.3 GB | 700 MB | 17600000
public.events_2026_05 | 1.8 GB | 1.2 GB | 620 MB | 15100000
Проверка partition pruning
-- Обёртка для быстрой проверки pruning
CREATE OR REPLACE FUNCTION check_partition_pruning(query text)
RETURNS TABLE (plan_line text) AS $$
BEGIN
RETURN QUERY EXECUTE 'EXPLAIN (FORMAT TEXT) ' || query;
END;
$$ LANGUAGE plpgsql;
SELECT * FROM check_partition_pruning(
'SELECT count(*) FROM events WHERE event_time >= ''2026-05-01'' AND event_time < ''2026-06-01'''
);
-- Если видите только events_2026_05 в плане — pruning работает
Мониторинг bloat по партициям
SELECT
schemaname || '.' || relname AS partition,
n_live_tup,
n_dead_tup,
CASE WHEN n_live_tup > 0
THEN round(n_dead_tup * 100.0 / n_live_tup, 2)
ELSE 0
END AS dead_pct,
last_vacuum,
last_autovacuum,
last_analyze
FROM pg_stat_user_tables
WHERE relname LIKE 'events_2026%'
ORDER BY dead_pct DESC;
Интеграция с Rails
Миграция для создания партиционированной таблицы
# db/migrate/20260528_create_partitioned_events.rb
class CreatePartitionedEvents < ActiveRecord::Migration[8.0]
def up
execute <<-SQL
CREATE TABLE events (
id bigserial,
event_time timestamptz NOT NULL,
user_id bigint NOT NULL,
event_type text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, event_time)
) PARTITION BY RANGE (event_time);
-- Текущий и будущие месяцы
CREATE TABLE events_2026_05 PARTITION OF events
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE events_2026_06 PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE events_2026_07 PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE events_default PARTITION OF events DEFAULT;
CREATE INDEX idx_events_user_id ON events (user_id);
CREATE INDEX idx_events_type ON events (event_type);
SQL
end
def down
drop_table :events, force: :cascade
end
end
Модель с scope-ами для партиций
# app/models/event.rb
class Event < ApplicationRecord
self.primary_key = [:id, :event_time]
scope :in_month, ->(date) {
start_of_month = date.beginning_of_month
end_of_month = date.end_of_month + 1.day
where(event_time: start_of_month...end_of_month)
}
scope :recent, -> { where(event_time: 30.days.ago..) }
scope :by_type, ->(type) { where(event_type: type) }
end
# Использование — partition pruning работает автоматически
Event.in_month(Date.today).by_type('purchase').count
Event.recent.where(user_id: 123).order(event_time: :desc)
Rake-задача для создания партиций
# lib/tasks/partitions.rake
namespace :partitions do
desc "Create monthly partitions for the next N months"
task create: :environment do
months_ahead = (ENV['MONTHS'] || 3).to_i
table = ENV['TABLE'] || 'events'
months_ahead.times do |i|
date = Date.today.beginning_of_month + (i + 1).months
partition_name = "#{table}_#{date.strftime('%Y_%m')}"
start_date = date.strftime('%Y-%m-%d')
end_date = (date + 1.month).strftime('%Y-%m-%d')
sql = <<-SQL
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = '#{partition_name}') THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
'#{partition_name}', '#{table}', '#{start_date}', '#{end_date}'
);
RAISE NOTICE 'Created: %', '#{partition_name}';
ELSE
RAISE NOTICE 'Already exists: %', '#{partition_name}';
END IF;
END $$;
SQL
ActiveRecord::Base.connection.execute(sql)
puts "#{partition_name}: #{start_date} -> #{end_date}"
end
end
desc "Drop partitions older than N months"
task drop_old: :environment do
months = (ENV['MONTHS'] || 12).to_i
table = ENV['TABLE'] || 'events'
cutoff = Date.today.beginning_of_month - months.months
partitions = ActiveRecord::Base.connection.select_values(
"SELECT tablename FROM pg_tables WHERE tablename LIKE '#{table}_%' AND tablename != '#{table}_default' ORDER BY tablename"
)
partitions.each do |part|
match = part.match(/_(\d{4})_(\d{2})$/)
next unless match
part_date = Date.new(match[1].to_i, match[2].to_i, 1)
if part_date < cutoff
puts "Dropping #{part} (#{part_date})"
ActiveRecord::Base.connection.execute("DROP TABLE #{part}")
end
end
end
end
# Cron: ежедневно создавать партиции на 3 месяца вперёд
0 3 * * * cd /var/www/app && bin/rails partitions:create MONTHS=3 RAILS_ENV=production
# Ежемесячно: удалять партиции старше 12 месяцев
0 4 1 * * cd /var/www/app && bin/rails partitions:drop_old MONTHS=12 RAILS_ENV=production
Бенчмарки: с партиционированием и без
Таблица events: 200M строк, 50GB данных, PostgreSQL 16, 8 ядер, 32GB RAM.
Запросы
| Запрос | Без партиций | С партициями (по месяцам) | Разница |
|---|---|---|---|
| COUNT за текущий месяц | 28.4s | 0.9s | 31x |
| SELECT по user_id + дата | 4.2s | 0.08s | 52x |
| GROUP BY date за 3 месяца | 45s | 3.2s | 14x |
| INSERT (одиночный) | 1.2ms | 1.3ms | ~1x |
| INSERT (батч 10000) | 850ms | 890ms | ~1x |
| DELETE за месяц (30M строк) | 38 min | 0.3s (DROP) | 7600x |
Обслуживание
| Операция | Без партиций | С партициями |
|---|---|---|
| VACUUM | 45 min | 2-3 min (на партицию) |
| REINDEX | 30 min | 1-2 min (на партицию) |
| pg_dump (backup) | 2+ hours | Можно по партициям, параллельно |
| Общий размер индексов | 28 GB | 26 GB (чуть меньше из-за меньших B-tree) |
INSERT практически не замедляется — PostgreSQL быстро определяет нужную партицию по partition key. Основной выигрыш — на чтении с фильтром по дате и на обслуживании.
Подводные камни
1. Foreign keys к партиционированной таблице
-- Это НЕ РАБОТАЕТ в PostgreSQL < 17:
ALTER TABLE order_items
ADD CONSTRAINT fk_event
FOREIGN KEY (event_id, event_time) REFERENCES events (id, event_time);
-- ERROR: cannot reference partitioned table
-- Решение до PG 17: foreign key на уровне приложения (Rails validates)
-- или триггер-based constraint
-- PostgreSQL 17+: поддерживает FK к партиционированным таблицам
2. Слишком много партиций
-- Плохо: партиция на каждый день × 3 года = 1095 партиций
-- Planning time растёт линейно с количеством партиций
-- Хорошо: партиция на месяц × 3 года = 36 партиций
-- Или: партиция на неделю для последнего года, на месяц для остальных
Рекомендация: не больше 100-200 партиций на таблицу. При 1000+ партиций planning time может стать проблемой.
3. Запросы без partition key
-- Этот запрос сканирует ВСЕ партиции
SELECT * FROM events WHERE user_id = 12345;
-- Нет фильтра по event_time — pruning не работает
-- Решение: всегда добавлять фильтр по partition key
SELECT * FROM events
WHERE user_id = 12345
AND event_time >= now() - interval '30 days';
4. Sequence и ID
-- Sequence общий для всех партиций — ID всегда уникален
-- НО: максимальный ID не равен количеству строк
-- SELECT max(id) FROM events — сканирует все партиции
-- Для подсчёта строк используйте:
SELECT sum(reltuples::bigint) AS approx_count
FROM pg_class
WHERE relname LIKE 'events_2026%';
Итоговый чеклист
Перед партиционированием
- [ ] Таблица > 50M строк или растёт > 5M/месяц — партиционирование оправдано
- [ ] Определить partition key: обычно timestamp (eventtime, createdat)
- [ ] Определить гранулярность: месяц (для большинства), неделя (для очень активных), год (для медленно растущих)
- [ ] Проверить запросы: 80%+ должны фильтровать по partition key
Создание
- [ ] PARTITION BY RANGE для временных данных (самый частый случай)
- [ ] DEFAULT партиция — обязательно, чтобы не терять данные при отсутствии подходящей партиции
- [ ] PRIMARY KEY включает partition key:
(id, event_time) - [ ] Индексы на родительской таблице — автоматически на всех партициях
Автоматизация
- [ ] pg_partman или rake-задача для создания партиций заранее
- [ ] Cron: ежедневное создание новых партиций (premake 3 месяца)
- [ ] Retention: автоматический DROP старых партиций
- [ ] Мониторинг: количество партиций, размеры, dead tuples
Миграция
- [ ] Создать новую партиционированную таблицу
- [ ] Перенести данные батчами (100K-500K строк за раз)
- [ ] Переименовать таблицы в транзакции
- [ ] Проверить EXPLAIN — pruning работает
- [ ] Удалить старую таблицу через неделю после миграции
Мониторинг в production
- [ ] Размеры партиций: не должны расти неравномерно
- [ ] Dead tuples: горячая партиция должна vacuumиться чаще
- [ ] Planning time: если > 10ms — слишком много партиций
- [ ] Partition pruning: проверять EXPLAIN для ключевых запросов
Комментарии (0)