Particionamento PostgreSQL para tabelas de eventos em logs de auditoria
Particionamento PostgreSQL para tabelas de eventos: aprenda quando compensa, como escolher chaves de partição e o que muda nos filtros do painel administrativo e na retenção.

Por que tabelas de eventos e auditoria viram um problema
Tabelas de eventos e tabelas de auditoria parecem similares, mas existem por razões diferentes.
Uma tabela de eventos registra coisas que acontecem: visualizações de página, e-mails enviados, chamadas de webhook, execuções de jobs. Uma tabela de auditoria registra quem mudou o quê e quando: uma alteração de status, atualização de permissões, aprovação de pagamento, frequentemente com detalhes de “antes” e “depois”.
Ambas crescem rapidamente porque são append-only. Raramente você deleta linhas individuais, e novas linhas chegam a cada minuto. Mesmo um produto pequeno pode gerar milhões de linhas de log em poucas semanas quando você inclui jobs em background e integrações.
A dor aparece no trabalho do dia a dia. Painéis administrativos precisam de filtros rápidos como “erros de ontem” ou “ações deste usuário”. À medida que a tabela cresce, essas telas básicas começam a ficar lentas.
Normalmente você notará alguns sintomas primeiro:
- Filtros levam segundos (ou dão timeout) mesmo com intervalo de datas estreito.
- Índices crescem tanto que inserts ficam lentos e custos de armazenamento sobem.
- VACUUM e autovacuum demoram mais, e você começa a notar manutenção constante.
- A retenção fica arriscada: deletar linhas antigas é lento e cria bloat.
Particionamento é uma forma de lidar com isso. Em termos simples, ele divide uma tabela grande em muitas tabelas menores (partições) que compartilham um nome lógico. O PostgreSQL direciona cada nova linha para a partição correta baseada numa regra, geralmente tempo.
É por isso que equipes olham para particionamento PostgreSQL para tabelas de eventos: ele pode manter dados recentes em pedaços menores, permitindo que o PostgreSQL ignore partições inteiras quando uma consulta precisa apenas de uma janela de tempo.
Particionamento não é um interruptor mágico de velocidade. Pode ajudar muito em consultas como “últimos 7 dias” e torna a retenção mais simples (drop de partições antigas é rápido). Mas também pode criar novos problemas:
- Consultas que não usam a chave de partição podem ter que verificar muitas partições.
- Mais partições significam mais objetos para gerenciar e mais formas de configurar algo errado.
- Algumas constraints únicas e índices ficam mais difíceis de aplicar em todos os dados.
Se seu painel administrativo depende muito de filtros por data e regras de retenção previsíveis, particionar pode ser uma vitória real. Se a maioria das consultas for “encontre todas as ações do usuário X em todo o histórico”, pode gerar dores a menos que você projete a UI e os índices cuidadosamente.
Padrões de acesso típicos para logs e auditorias
Tabelas de eventos e auditoria crescem numa direção: para cima. Recebem um fluxo constante de inserts e quase nenhum update. A maioria das linhas é escrita uma vez e depois lida durante suporte, revisões de incidentes ou verificações de conformidade.
Esse formato “append-only” importa. Performance de escrita é uma preocupação constante porque inserts acontecem o dia todo, enquanto a leitura importa em rajadas (quando suporte ou ops precisa de respostas rápidas).
A maioria das leituras são filtros, não buscas aleatórias. Num painel administrativo, alguém geralmente começa amplo (últimas 24 horas) e então estreita para um usuário, uma entidade ou uma ação.
Filtros comuns incluem:
- Um intervalo de tempo
- Um ator (ID de usuário, conta de serviço, endereço IP)
- Um alvo (tipo de entidade + ID da entidade, como Pedido #1234)
- Um tipo de ação (criado, atualizado, excluído, falha de login)
- Um status ou severidade (sucesso/erro)
Intervalo de tempo é o “primeiro corte” natural porque quase sempre está presente. Essa é a intuição chave por trás do particionamento PostgreSQL para tabelas de eventos: muitas consultas querem uma fatia de tempo, e todo o resto é um segundo filtro dentro dessa fatia.
Retenção é a outra constante. Logs raramente vivem para sempre. Equipes costumam manter eventos detalhados por 30 ou 90 dias, depois deletam ou arquivam. Logs de auditoria podem ter requisitos mais longos (365 dias ou mais), mas mesmo assim normalmente você quer uma maneira previsível de remover dados antigos sem travar o banco.
Auditoria vem com expectativas extras. Geralmente você quer que o histórico seja imutável, que cada registro seja rastreável (quem/o quê/quando mais contexto de requisição ou sessão), e que o acesso seja controlado (nem todos devem ver eventos de segurança).
Esses padrões aparecem direto no design da UI. Os filtros que as pessoas esperam por padrão - seletores de data, seleção de usuário, busca por entidade, dropdown de ação - são os mesmos filtros que sua tabela e índices precisam suportar se quiser que a experiência administrativa continue rápida conforme o volume cresce.
Como saber se particionar vale a pena
Particionamento não é uma prática padrão para logs de auditoria. Vale a pena quando uma tabela fica tão grande que consultas do dia a dia e manutenção rotineira começam a conflitar.
Uma dica de tamanho simples: quando uma tabela de eventos alcança dezenas de milhões de linhas, vale a pena medir. Quando a tabela e seus índices crescem para dezenas de gigabytes, até buscas por intervalo de datas podem ficar lentas ou imprevisíveis porque mais páginas de dados são lidas do disco e índices ficam caros de manter.
O sinal mais claro nas consultas é quando você pede regularmente uma pequena janela de tempo (último dia, última semana), mas o PostgreSQL ainda toca grande parte da tabela. Você verá telas de “atividade recente” lentas, ou auditorias filtradas por data mais usuário, tipo de ação ou ID de entidade. Se planos de consulta mostram scans grandes ou reads de buffer consistentemente altos, você está pagando por dados que não queria ler.
Sinais de manutenção importam tanto quanto:
- VACUUM e autovacuum demoram muito mais do que antes.
- Autovacuum fica para trás e dead tuples (bloat) se acumulam.
- Índices crescem mais rápido do que o esperado, especialmente índices multi-coluna.
- Contenção de locks fica mais perceptível quando manutenção se sobrepõe ao tráfego.
Custos operacionais são o gotejamento lento que empurra equipes ao particionamento. Backups e restores ficam mais lentos conforme uma tabela cresce, armazenamento aumenta, e jobs de retenção ficam caros porque DELETEs grandes criam bloat e trabalho extra de vacuum.
Se seus objetivos principais são uma política de retenção limpa e consultas “período recente” mais rápidas, particionar costuma merecer uma análise séria. Se a tabela é moderada e as consultas já são rápidas com bom uso de índices, particionar pode adicionar complexidade sem ganho claro.
Opções de particionamento adequadas para eventos e auditoria
Para a maioria dos dados de auditoria e eventos, a escolha mais simples é particionamento por range de tempo. Logs chegam em ordem temporal, consultas frequentemente focam em “últimas 24 horas” ou “últimos 30 dias”, e a retenção costuma ser por tempo. Com partições por tempo, remover dados antigos pode ser tão simples quanto remover uma partição antiga em vez de rodar um DELETE grande que gera bloat.
Partição por range de tempo também mantém índices menores e mais focados. Cada partição tem seus próprios índices, então uma consulta pela última semana não precisa percorrer um índice gigantesco cobrindo anos de histórico.
Outros estilos existem, mas atendem menos casos de logs e auditoria:
- List (tenant ou cliente) pode funcionar quando você tem um pequeno número de tenants muito grandes e consultas ficam dentro de um tenant. Fica doloroso com centenas ou milhares de tenants.
- Hash (distribuição uniforme de escrita) pode ajudar quando você não tem consultas por janela de tempo e quer distribuir writes. Em auditoria é menos comum porque complica retenção e navegação por tempo.
- Subparticionamento (tempo + tenant) pode ser poderoso, mas a complexidade cresce rápido. É para sistemas de altíssimo volume com necessidade estrita de isolamento por tenant.
Se optar por tempo, escolha um tamanho de partição que combine com como você navega e retém dados. Partições diárias fazem sentido para tabelas de volume muito alto ou retenção rígida. Partições mensais são mais fáceis de gerenciar em volume moderado.
Um exemplo prático: se um time administrativo verifica tentativas de login falhas toda manhã e filtra pelos últimos 7 dias, partições diárias ou semanais fazem com que a consulta toque apenas as partições mais recentes. O PostgreSQL pode ignorar o resto.
Seja qual for a abordagem, planeje as partes chatas: criar partições futuras, lidar com eventos que chegam atrasados e definir o que acontece em cada limite (fim do dia, fim do mês). Particionamento compensa quando essas rotinas permanecem simples.
Como escolher a chave de partição certa
Uma boa chave de partição combina com como você lê a tabela, não com como os dados aparecem no diagrama.
Para logs e auditorias, comece pelo seu painel administrativo: qual filtro as pessoas usam primeiro, quase sempre? Para a maioria das equipes é um intervalo de tempo (últimas 24 horas, últimos 7 dias, datas customizadas). Se isso é verdade para você, particionar por tempo costuma dar o ganho mais previsível porque o PostgreSQL pode pular partições inteiras fora do intervalo selecionado.
Trate a chave como uma promessa de longo prazo. Você está otimizando para consultas que continuará executando por anos.
Comece pelo “primeiro filtro” que as pessoas usam
A maioria das telas administrativas segue um padrão: intervalo de tempo mais opcionalmente usuário, ação, status ou recurso. Particione pelo que diminui resultados cedo e consistentemente.
Um cheque rápido:
- Se a visualização padrão é “eventos recentes”, particione por timestamp.
- Se a visualização padrão é “eventos por um tenant/conta”,
tenant_idpode fazer sentido, mas apenas se tenants forem grandes o bastante. - Se o primeiro passo é sempre “escolher um usuário”,
user_idpode parecer tentador, mas normalmente cria partições demais para gerenciar.
Evite chaves de alta cardinalidade
Particionamento funciona melhor quando cada partição é um pedaço significativo de dados. Chaves como user_id, session_id, request_id ou device_id podem levar a milhares ou milhões de partições. Isso aumenta overhead de metadata, complica a manutenção e frequentemente piora o planejamento.
Partições baseadas em tempo mantêm o número de partições previsível. Você escolhe diário, semanal ou mensal conforme o volume. Poucas partições (uma por ano) não ajudam muito. Partições demais (uma por hora) adicionam overhead rapidamente.
Escolha o timestamp certo: created_at vs occurred_at
Seja explícito sobre o que significa tempo:
occurred_at: quando o evento aconteceu no produto.created_at: quando o banco registrou o evento.
Para auditorias, “ocorrido” costuma ser o que admins querem. Mas entrega tardia (clients móveis offline, retries, filas) significa que occurred_at pode chegar atrasado. Se chegadas tardias são comuns, particionar por created_at e indexar occurred_at para filtragem pode ser mais estável operacionalmente. A outra opção é definir uma política clara de backfill e aceitar que partições antigas ocasionalmente receberão eventos atrasados.
Decida também como armazenar tempo. Use um tipo consistente (frequentemente timestamptz) e trate UTC como fonte da verdade. Formate para o fuso horário do visualizador na UI. Isso mantém limites de partição estáveis e evita surpresas com horário de verão.
Passo a passo: planeje e aplique o particionamento
Particionar é mais fácil quando você trata como um pequeno projeto de migração, não um ajuste rápido. O objetivo é escritas simples, leituras previsíveis e retenção que vire operação rotineira.
Um plano prático de rollout
-
Escolha um tamanho de partição que combine com seu volume. Partições mensais geralmente bastam com algumas centenas de milhares de linhas por mês. Se você insere dezenas de milhões por mês, partições semanais ou diárias mantêm índices menores e trabalho de vacuum mais contido.
-
Projete chaves e constraints para tabelas particionadas. No PostgreSQL, uma constraint única precisa incluir a chave de partição (ou ser aplicada de outra forma). Um padrão comum é
(created_at, id), ondeidé gerado ecreated_até a chave de partição. Isso evita surpresas quando você descobrir que uma constraint esperada não é permitida. -
Crie partições futuras antes de precisar delas. Não espere inserts falharem por não haver partição correspondente. Decida com que antecedência criá-las (por exemplo, 2–3 meses) e torne isso um job rotineiro.
-
Mantenha índices por partição pequenos e intencionais. Particionamento não torna índices gratuitos. A maioria das tabelas de eventos precisa da chave de partição mais um ou dois índices que reflitam filtros reais da administração, como
actor_id,entity_idouevent_type. Evite índices “só por via das dúvidas”. Você pode adicioná-los depois às partições novas e backfill nas antigas se necessário. -
Planeje retenção em torno de drop de partições, não deletes. Se você mantém 180 dias de logs, dropar uma partição antiga é rápido e evita deletes longos e bloat. Documente a regra de retenção, quem a executa e como verificar que funcionou.
Exemplo pequeno
Se sua tabela de auditoria recebe 5 milhões de linhas por semana, partições semanais em created_at são um começo razoável. Crie partições com 8 semanas de antecedência e mantenha dois índices por partição: um para buscas comuns por actor_id e outro por entity_id. Quando a janela de retenção acabar, drope a partição semanal mais antiga em vez de deletar milhões de linhas.
Se você estiver construindo ferramentas internas no AppMaster, ajuda decidir a chave de partição e constraints cedo para que o modelo de dados e o código gerado sigam as mesmas suposições desde o início.
O que muda para filtros do painel administrativo
Depois de particionar uma tabela de logs, filtros do painel administrativo deixam de ser “apenas UI”. Eles se tornam o principal fator que decide se uma consulta toca poucas partições ou varre meses de dados.
A maior mudança prática: tempo não pode ser opcional mais. Se usuários podem executar uma busca sem limites (sem intervalo de datas, só “me mostra tudo para o usuário X”), o PostgreSQL pode precisar checar todas as partições. Mesmo que cada verificação seja rápida, abrir muitas partições adiciona overhead e a página fica lenta.
Uma regra que funciona bem: exija um intervalo de tempo para buscas em logs e auditorias e dê um padrão sensato (como últimas 24 horas). Se alguém realmente precisar de “todo o tempo”, faça disso uma escolha deliberada e avise que os resultados podem ser mais lentos.
Faça os filtros corresponderem ao partition pruning
Partition pruning só ajuda quando a cláusula WHERE inclui a chave de partição em uma forma que o PostgreSQL consegue usar. Filtros como created_at BETWEEN X AND Y fazem o pruning limpo. Padrões que tendem a quebrar pruning incluem casting de timestamps para date, envolver a coluna em funções, ou filtrar principalmente por outra coluna de tempo que não seja a chave.
Dentro de cada partição, índices devem corresponder ao que as pessoas realmente filtram. Na prática, combinações que importam frequentemente são tempo mais mais uma condição: tenant/workspace, usuário, ação/tipo, ID da entidade ou status.
Ordenação e paginação: mantenha raso
Particionamento não corrige paginação lenta por si só. Se o painel ordena por mais recentes e usuários pulam para a página 5000, paginação com OFFSET profunda ainda força o PostgreSQL a percorrer muitas linhas.
Paginações por cursor tendem a se comportar melhor para logs: “carregar eventos antes deste timestamp/id”. Mantém o banco usando índices em vez de pular enormes offsets.
Presets ajudam aqui também. Algumas opções prontas geralmente são suficientes: últimas 24 horas, últimos 7 dias, hoje, ontem, intervalo customizado. Presets reduzem buscas acidentais que varrem tudo e tornam a experiência mais previsível.
Erros comuns e armadilhas
A maioria dos projetos de particionamento falha por razões simples: o particionamento funciona, mas as consultas e a UI administrativa não combinam com ele. Se quiser que particionamento valha a pena, desenhe-o em torno de filtros reais e retenção real.
1) Particionar na coluna de tempo errada
O partition pruning só acontece quando a cláusula WHERE corresponde à chave de partição. Um erro comum é particionar por created_at enquanto a UI filtra por event_time (ou vice-versa). Se seu time de suporte sempre pergunta “o que aconteceu entre 10:00 e 10:15”, mas a tabela está particionada por tempo de ingestão, você ainda pode tocar mais dados do que espera.
2) Criar partições pequenas demais
Partições horárias (ou menores) parecem organizadas, mas adicionam overhead: mais objetos para gerenciar, mais trabalho para o planejador e mais chances de índices faltando ou permissões desalinhadas.
A menos que você tenha volume de escrita extremamente alto e retenção rígida, partições diárias ou mensais são mais fáceis de operar.
3) Assumir que unicidade global ainda funciona
Tabelas particionadas têm limitações: algumas chaves únicas devem incluir a chave de partição, caso contrário o PostgreSQL não consegue aplicá-las entre todas as partições.
Isso costuma surpreender equipes que esperam que event_id seja único globalmente. Se precisar de um identificador único, use um UUID e garanta unicidade junto com a chave de tempo, ou aplique unicidade na camada de aplicação.
4) Deixar a UI rodar buscas sem limites
Painéis administrativos frequentemente têm uma caixa de busca amigável que roda sem filtros. Em uma tabela particionada isso pode significar varrer todas as partições.
Busca de texto livre em payloads de mensagem é especialmente arriscada. Adicione guardrails: exija intervalo de tempo, limite o padrão e faça “todo o tempo” uma escolha deliberada.
5) Não ter plano de retenção (nem plano para partições)
Particionamento não resolve retenção automaticamente. Sem política, você acaba com um monte de partições antigas, armazenamento bagunçado e manutenção mais lenta.
Um conjunto simples de regras operacionais geralmente previne isso: defina quanto tempo eventos brutos permanecem, automatize criação de partições futuras e remoção das antigas, aplique índices consistentemente, monitore contagem de partições e datas de limite, e teste os filtros mais lentos contra volumes realistas.
Checklist rápido antes de se comprometer
Particionamento pode ser uma grande vantagem para logs de auditoria, mas adiciona trabalho rotineiro. Antes de mudar o esquema, cheque como as pessoas realmente usam a tabela.
Se sua dor principal é que páginas administrativas dão timeout quando alguém abre “Últimas 24 horas” ou “Esta semana”, você está perto de um bom caso. Se a maioria das consultas é “ID de usuário em todo o histórico”, particionar pode ajudar menos a menos que você mude como a UI guia as buscas.
Um checklist curto para manter times honestos:
- Intervalo de tempo como filtro padrão. A maioria das consultas inclui uma janela clara (de/até). Se buscas sem limites são comuns, o partition pruning ajuda menos.
- Retenção feita por drop de partições, não por deletes. Você aceita dropar partições antigas e tem uma regra clara de quanto tempo manter.
- Contagem de partições permanece razoável. Estime partições por ano (diário, semanal, mensal). Partições pequenas demais aumentam overhead; partições grandes demais reduzem o benefício.
- Índices combinam com filtros reais. Além da chave de partição, você ainda precisa dos índices por partição corretos para filtros comuns e ordenação.
- Partições são criadas automaticamente e monitoradas. Um job cria partições futuras e você sabe quando isso falha.
Um teste prático: veja os três filtros que suporte ou ops usa mais. Se dois deles normalmente são satisfeitos por “intervalo de tempo + mais uma condição”, particionamento PostgreSQL para tabelas de eventos costuma valer a pena.
Um exemplo realista e próximos passos práticos
Um time de suporte mantém duas telas abertas o dia todo: “Eventos de login” (logins bem-sucedidos e falhos) e “Auditorias de segurança” (reset de senha, mudanças de função, atualizações de chave API). Quando um cliente reporta atividade suspeita, o time filtra por usuário, checa as últimas horas e exporta um relatório curto.
Antes do particionamento, tudo estava em uma única tabela events. Ela cresce rápido e até buscas simples começam a arrastar porque o banco processa muitas linhas antigas. Retenção também é dolorosa: um job noturno deleta linhas antigas, mas deletes grandes demoram, geram bloat e competem com tráfego normal.
Depois de particionar por mês (usando o timestamp do evento), o fluxo melhora. O painel exige filtro de tempo, então a maioria das consultas toca apenas uma ou duas partições. Páginas carregam mais rápido porque o PostgreSQL ignora partições fora do intervalo selecionado. Retenção vira rotina: ao invés de deletar milhões de linhas, você droppa partições antigas.
Uma coisa que continua difícil: busca de texto livre em “todo o tempo”. Se alguém busca um IP ou uma frase vaga sem limite de datas, particionamento não torna isso barato. A correção costuma ser no comportamento do produto: padronizar buscas para uma janela de tempo e tornar “últimas 24 horas / 7 dias / 30 dias” o caminho óbvio.
Próximos passos práticos que costumam funcionar bem:
- Mapeie primeiro os filtros do painel administrativo. Anote quais campos as pessoas usam e quais devem ser obrigatórios.
- Escolha partições que correspondam a como você navega. Partições mensais costumam ser um bom começo; mude para semanais só quando o volume exigir.
- Faça do intervalo de tempo um filtro de primeira classe. Se a UI permitir “sem data”, espere páginas lentas.
- Alinhe índices com filtros reais. Quando tempo está sempre presente, uma estratégia de índice iniciando por tempo é um bom baseline.
- Defina regras de retenção que casem com limites de partição (por exemplo, manter 13 meses e dropar o que for mais antigo).
Se você está construindo um painel administrativo interno com AppMaster (appmaster.io), vale modelar essas suposições cedo: trate filtros limitados por tempo como parte do modelo de dados, não apenas uma escolha de UI. Essa decisão simples protege a performance das consultas conforme o volume de logs cresce.
FAQ
Particionar ajuda mais quando suas consultas comuns são limitadas por tempo (por exemplo, "últimas 24 horas" ou "últimos 7 dias") e a tabela está grande o suficiente para que índices e manutenção se tornem caros. Se suas consultas principais são "todo o histórico do usuário X", particionar pode adicionar overhead, a menos que você imponha filtros de tempo na UI e crie os índices por partição adequados.
Particionamento por range de tempo é geralmente o padrão mais adequado para logs e auditorias, porque os writes chegam em ordem temporal, as consultas normalmente começam por uma janela de tempo e a retenção é baseada em tempo. Particionamento por list ou hash pode funcionar em casos especiais, mas costuma complicar retenção e navegação em workflows de auditoria.
Escolha o campo que os usuários filtram primeiro e quase sempre. Na maioria dos painéis administrativos isso é um intervalo de timestamps, então particionar por tempo é a escolha mais previsível. Considere isso um compromisso de longo prazo, porque mudar a chave de partição depois exige uma migração significativa.
Use chaves como timestamp ou um identificador de tenant apenas quando gerarem um número gerenciável de partições. Evite chaves de alta cardinalidade como user_id, session_id ou request_id, pois podem criar milhares de partições, aumentar o overhead do planejador e tornar operações mais difíceis sem ganhos constantes de desempenho.
Particione por created_at quando precisar de estabilidade operacional e não puder confiar em chegadas tardias (filas, retries, clientes offline). Particione por occurred_at quando o caso de uso principal for “o que aconteceu durante esta janela” e o tempo do evento for confiável. Um compromisso comum é particionar por created_at e indexar occurred_at para filtragem.
Sim — a maioria dos painéis administrativos deve exigir um intervalo de tempo depois que a tabela for particionada. Sem filtro de tempo, o PostgreSQL pode precisar verificar muitas ou todas as partições, deixando as páginas lentas mesmo com índices. Um bom padrão é "últimas 24 horas", deixando "todo o tempo" como uma opção deliberada.
Frequentemente, sim. Envolver a chave de partição em uma função (por exemplo, cast para date) pode impedir pruning, e filtrar por uma coluna de tempo diferente da chave de partição pode forçar a varredura de mais partições. Mantenha filtros em forma simples como created_at BETWEEN X AND Y para garantir que o pruning funcione.
Evite paginação com OFFSET muito profunda para visualizações de logs, pois força o banco a pular muitas linhas. Use paginação por cursor, por exemplo “carregar eventos antes deste (timestamp, id)”, que preserva o uso de índices e mantém o desempenho estável com o crescimento da tabela.
No PostgreSQL, algumas constraints únicas em tabelas particionadas precisam incluir a chave de partição, então uma coluna id globalmente única pode não funcionar como esperado. Um padrão prático é usar uma unicidade composta como (created_at, id) quando created_at é a chave de partição. Se precisar de um identificador único para uso externo, mantenha um UUID e trate a unicidade global com cuidado.
Excluir partições antigas é rápido e evita bloat e trabalho extra de VACUUM causado por grandes DELETEs. O importante é alinhar as regras de retenção com os limites das partições e automatizar a rotina: crie partições futuras antecipadamente e remova as expiradas conforme cronograma. Sem essa automação, particionar vira trabalho manual.


