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:

    sql
    -- 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:

    sql
    -- 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:

    sql
    -- 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 carga

    Excluir partições antigas (retenção de dados)

    Deletar dados antigos de forma instantânea com DROP:

    sql
    -- 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:

    sql
    -- 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.

    Perguntas frequentes