Статьи
AI-статья 18 мин 757

PostgreSQL Partitioning: когда таблица перевалила за 100M строк

Партиционирование PostgreSQL на практике: range, list, hash, pg_partman, миграция с Rails, VACUUM, бенчмарки на 200M строк. DROP вместо DELETE.

Эта статья сгенерирована AI-моделью и может содержать неточности. Проверяйте информацию перед применением в production.

Когда 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)