Particionamento de tabelas no PostgreSQL
Quando uma tabela de eventos ou logs tem bilhões de linhas, queries ficam lentas mesmo com índices perfeitos. Particionamento divide a tabela em partes menores que o PostgreSQL consulta seletivamente — queries filtradas por data tocam apenas as partições relevantes, e deletar dados antigos se torna um DROP instantâneo em vez de um DELETE lento.
Particionamento por RANGE (por data)
O padrão mais comum: particionar tabela de eventos por mês:
-- Criar tabela particionada por mês (RANGE no campo data):
CREATE TABLE eventos (
id BIGSERIAL,
ocorrido_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tipo TEXT NOT NULL,
usuario_id BIGINT,
dados JSONB
) PARTITION BY RANGE (ocorrido_em);
-- Criar partições mensais:
CREATE TABLE eventos_2026_05 PARTITION OF eventos
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE eventos_2026_06 PARTITION OF eventos
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE eventos_2026_07 PARTITION OF eventos
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- Partição para valores não cobertos (default):
CREATE TABLE eventos_outros PARTITION OF eventos DEFAULT;
-- Índices são criados POR PARTIÇÃO:
CREATE INDEX ON eventos_2026_06 (usuario_id);
CREATE INDEX ON eventos_2026_06 (tipo, ocorrido_em);
-- Ou criar índice na tabela pai (se aplica a todas as partições):
CREATE INDEX ON eventos (tipo, ocorrido_em);
-- Inserir funciona normalmente — PostgreSQL roteia para a partição correta:
INSERT INTO eventos (tipo, usuario_id) VALUES ('login', 123);
-- Query que filtra por data usa apenas a partição relevante:
EXPLAIN SELECT * FROM eventos WHERE ocorrido_em >= '2026-06-01' AND ocorrido_em < '2026-07-01';
-- "Append → Seq Scan on eventos_2026_06" (apenas 1 partição!)Automatizar criação de partições
Script para criar partições automaticamente todo mês:
-- Função para criar a próxima partição mensal:
CREATE OR REPLACE FUNCTION criar_particao_proxima()
RETURNS void AS $
DECLARE
inicio DATE := DATE_TRUNC('month', NOW() + INTERVAL '1 month')::DATE;
fim DATE := DATE_TRUNC('month', NOW() + INTERVAL '2 months')::DATE;
nome TEXT := 'eventos_' || TO_CHAR(inicio, 'YYYY_MM');
BEGIN
-- Verificar se a partição já existe:
IF NOT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = nome AND n.nspname = 'public'
) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF eventos FOR VALUES FROM (%L) TO (%L)',
nome, inicio::TEXT, fim::TEXT
);
EXECUTE format('CREATE INDEX ON %I (usuario_id)', nome);
EXECUTE format('CREATE INDEX ON %I (tipo, ocorrido_em)', nome);
RAISE NOTICE 'Partição % criada', nome;
ELSE
RAISE NOTICE 'Partição % já existe', nome;
END IF;
END;
$ LANGUAGE plpgsql;
-- Agendar com pg_cron (extensão de cron para PostgreSQL):
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule('0 0 1 * *', -- todo dia 1 à meia-noite
$SELECT criar_particao_proxima()$);
-- Ou usar pg_partman (extensão especializada em particionamento):
-- SELECT partman.create_parent('public.eventos', 'ocorrido_em', 'native', 'monthly');
-- SELECT partman.run_maintenance();Particionamento por LIST e HASH
Outros tipos de particionamento para casos específicos:
-- PARTITION BY LIST: partições por valor específico (ex: por país ou status):
CREATE TABLE pedidos_por_pais (
id BIGSERIAL,
criado_em TIMESTAMPTZ DEFAULT NOW(),
pais TEXT NOT NULL,
total NUMERIC(10,2)
) PARTITION BY LIST (pais);
CREATE TABLE pedidos_brasil PARTITION OF pedidos_por_pais FOR VALUES IN ('BR');
CREATE TABLE pedidos_mexico PARTITION OF pedidos_por_pais FOR VALUES IN ('MX');
CREATE TABLE pedidos_outros PARTITION OF pedidos_por_pais DEFAULT;
-- PARTITION BY HASH: distribuição uniforme (ex: sharding por user_id):
CREATE TABLE eventos_hash (
id BIGSERIAL,
usuario_id BIGINT NOT NULL,
tipo TEXT,
dados JSONB
) PARTITION BY HASH (usuario_id);
-- 4 partições com distribuição de hash:
CREATE TABLE eventos_hash_0 PARTITION OF eventos_hash FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE eventos_hash_1 PARTITION OF eventos_hash FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE eventos_hash_2 PARTITION OF eventos_hash FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE eventos_hash_3 PARTITION OF eventos_hash FOR VALUES WITH (MODULUS 4, REMAINDER 3);
-- Hash partitioning: queries que filtram por usuario_id usam apenas 1 partição
-- Útil quando não há um campo de data mas precisa distribuir a cargaExcluir partições antigas (retenção de dados)
Deletar dados antigos de forma instantânea com DROP:
-- Deletar dados com mais de 6 meses via DROP (instantâneo!):
-- vs DELETE: pode levar horas e criar dead tuples
-- Verificar partições existentes:
SELECT
inhrelid::regclass AS particao,
pg_size_pretty(pg_relation_size(inhrelid)) AS tamanho
FROM pg_inherits
WHERE inhparent = 'eventos'::regclass
ORDER BY particao;
-- Identificar partições para remover:
SELECT
c.relname AS nome,
pg_size_pretty(pg_relation_size(c.oid)) AS tamanho
FROM pg_class c
JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE i.inhparent = 'eventos'::regclass
AND c.relname < 'eventos_' || TO_CHAR(NOW() - INTERVAL '6 months', 'YYYY_MM');
-- Desanexar (detach) e dropar:
ALTER TABLE eventos DETACH PARTITION eventos_2025_10;
DROP TABLE eventos_2025_10;
-- Toda operação leva milissegundos!
-- Automatizar via função de retenção:
CREATE OR REPLACE FUNCTION limpar_particoes_antigas(meses INT DEFAULT 6)
RETURNS void AS $
DECLARE
nome TEXT;
BEGIN
FOR nome IN
SELECT c.relname
FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE i.inhparent = 'eventos'::regclass
AND c.relname < 'eventos_' || TO_CHAR(NOW() - (meses || ' months')::INTERVAL, 'YYYY_MM')
LOOP
EXECUTE format('ALTER TABLE eventos DETACH PARTITION %I', nome);
EXECUTE format('DROP TABLE %I', nome);
RAISE NOTICE 'Partição % removida', nome;
END LOOP;
END;
$ LANGUAGE plpgsql;Particionar tabela existente sem downtime
Migrar tabela grande para particionada com mínimo impacto:
-- Estratégia para tabelas existentes (sem downtime):
-- 1. Criar nova tabela particionada (nome temporário):
CREATE TABLE eventos_v2 (LIKE eventos INCLUDING ALL)
PARTITION BY RANGE (ocorrido_em);
-- 2. Criar partições históricas + futuras:
-- (executar o script de criação de partições)
-- 3. Copiar dados em batches (sem bloquear):
-- pg_partman faz isso automaticamente, mas manualmente:
INSERT INTO eventos_v2
SELECT * FROM eventos
WHERE ocorrido_em >= '2026-01-01' AND ocorrido_em < '2026-02-01';
-- Repetir para cada mês histórico
-- 4. Sincronizar writes novos com trigger temporário na tabela original
-- (ou usar downtime mínimo — fora do horário de pico)
-- 5. Trocar as tabelas:
BEGIN;
ALTER TABLE eventos RENAME TO eventos_legado;
ALTER TABLE eventos_v2 RENAME TO eventos;
COMMIT;
-- 6. Redirecionar a aplicação para a nova tabela
-- (pode ser transparente se o nome não mudou)
-- 7. Verificar e dropar a tabela legada:
-- DROP TABLE eventos_legado; -- após confirmação de que tudo funciona$ runstack deploy --plan starter
Não quer configurar manualmente?
Não quer configurar manualmente? Implante o VPS para APIs em menos de 3 minutos com a Runstack. Infraestrutura da OPEN DATACENTER, com servidores no Brasil.