Home Projetos & Releases BigBangcraft Blog Sobre & Carreira
Ruby on Rails • Hub 7 min de leitura 17 visualizações
Raw .md

A pressa de culpar o ActiveRecord antes de abrir o plano de execução no PostgreSQL

PD

Pedro Dalben

CTO no Integrar Plus • Publicado em 18 de October de 2026

A pressa de culpar o ActiveRecord antes de abrir o plano de execução no PostgreSQL

Quando uma requisição em Rails passa de dois segundos em produção, a equipe costuma se dividir em dois lados quase religiosos. O primeiro grupo aponta imediatamente o dedo para o ActiveRecord, assume que existe uma consulta N+1 escondida na view e sai distribuindo chamadas de includes sem critério até silenciar alertas de ferramentas locais. O segundo grupo prefere culpar a infraestrutura do banco de dados, criando migrations em série para adicionar índices compostos em qualquer coluna mencionada em cláusulas where.

Eu discordo das duas posturas. Ambas partem do mesmo vício, que é tentar resolver lentidão operando no escuro, guiando-se por sintomas superficiais em vez de evidências de execução. Em servidores virtuais com recursos contidos de memória e disco, espalhar índices cegamente ou carregar árvores inteiras de associações na memória do Ruby costuma agravar o problema em vez de trazer alívio.

A miragem do N+1 como culpado universal

A primeira postura parte da premissa de que qualquer consulta repetida representa uma falha grave de arquitetura. Não representa. Quando operamos Rails sobre PostgreSQL na mesma máquina ou em uma rede local com latência de frações de milissegundo via socket Unix, trinta consultas que buscam registros individuais pela chave primária demoram, somadas, menos de três milissegundos no banco. Se as páginas de dados já residem na memória do PostgreSQL, o impacto dessas requisições no processador do banco é quase imperceptível.

O perigo real surge na solução apressada. Para eliminar o aviso de N+1 na listagem, o desenvolvedor troca essas consultas pequenas por um eager_load amplo com vários LEFT OUTER JOIN. O PostgreSQL resolve a junção com eficiência e devolve uma matriz relacional densa, cheia de colunas duplicadas. O problema é que o ActiveRecord precisa instanciar milhares de objetos Ruby correspondentes a essas linhas repetidas. O tempo de execução da consulta cai para vinte milissegundos, mas a requisição inteira trava por meio segundo porque o interpretador Ruby entrou em coleta de lixo pesada para alocar e desalocar memória. A tentativa ingênua de poupar o banco transfere uma carga desproporcional para a CPU da aplicação.

O reflexo de criar índices sem critério

A segunda postura, comum em quem prefere ignorar o código da aplicação, trata índices como remédio sem efeitos colaterais. O raciocínio costuma ser direto: se uma busca com filtro de status ou data está demorando, basta criar um índice nessa coluna. Essa hipótese desconsidera a forma como o planejador de consultas do PostgreSQL decide percorrer uma tabela.

Índices têm custo de manutenção contínuo. Toda operação de escrita, seja uma inserção ou uma atualização, precisa reorganizar a árvore do índice. Além disso, cada índice ocupa espaço no cache compartilhado do banco. Quando você cria índices redundantes, disputa páginas de memória RAM que deveriam abrigar registros acessados com frequência. Se o planejador estimar que a condição de filtro vai retornar uma fatia relevante das linhas da tabela, ele vai descartar o índice e executar uma varredura sequencial direta. Criar um índice que o planejador rejeita apenas desperdiça disco, atrasa os inserts e consome cache útil.

Como dissecar o plano com buffers

A única forma técnica de diagnosticar o gargalo consiste em executar a consulta no console do Rails ou no cliente do banco precedida por EXPLAIN (ANALYZE, BUFFERS). O comando EXPLAIN isolado apenas exibe a estimativa teórica do planejador, baseada nas estatísticas coletadas. O modificador ANALYZE executa a consulta de verdade e registra a duração real de cada etapa. Já o modificador BUFFERS expõe de onde os dados saíram, diferenciando memória de disco.

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM invoices
WHERE account_id = 42 AND status_code = 1;

A leitura desse resultado não exige decifrar cada detalhe matemático do planejador, mas requer atenção a três pontos determinantes:

A discrepância entre o custo estimado e as linhas reais revela o estado das estatísticas. O planejador exibe algo como rows=12 na estimativa e, logo ao lado, rows=15000 na execução real. Quando a previsão difere por ordens de grandeza da realidade, o banco está calculando planos com base em informações defasadas. Nenhum índice novo vai corrigir uma escolha ruim se o catálogo estatístico acreditar que a tabela contém dez registros quando na verdade abriga centenas de milhares. Nesses casos, rodar um comando ANALYZE na tabela resolve a lentidão sem demandar nenhuma alteração no código Ruby.

A contagem de buffers compartilhados separa problemas de memória de gargalos de armazenamento. A linha de buffers divide o acesso entre shared hit e shared read. Os blocos marcados como hit foram encontrados diretamente na memória RAM reservada pelo banco. Os blocos marcados como read precisaram ser buscados no armazenamento persistente do servidor. Se uma consulta simples consome quinhentos milissegundos e apresenta milhares de blocos em read, o problema reside em saturação de leitura no disco ou em cache subdimensionado, e nenhuma reescrita de escopo no ActiveRecord vai contornar esse limite físico.

A natureza do nó de varredura contextualiza a necessidade de índices. Um nó de Seq Scan em uma tabela com milhões de registros justifica intervenção imediata, mas o mesmo nó em uma tabela pequena de configurações é a opção mais econômica que o motor possui, já que ler o bloco inteiro sequencialmente custa menos do que alternar entre a árvore do índice e os dados da tabela. Da mesma forma, identificar um Bitmap Heap Scan seguido por um descarte massivo de linhas em Rows Removed by Filter aponta exatamente qual coluna complementar está faltando em um índice composto existente.

Uma rotina prática de diagnóstico

Admito que ler planos com dezenas de junções e subconsultas pode parecer confuso no início, mas a rotina de triagem precisa ser metódica. Eu sigo um fluxo rígido antes de aprovar qualquer mudança de código ou migration em produção.

Primeiro, extraio a instrução SQL exata gerada pelo log do Rails e executo a análise com buffers diretamente no PostgreSQL. Se o banco conclui a operação em menos de dez milissegundos com leituras concentradas em shared hit, o banco está saudável. Nesse cenário, paro de procurar culpados no PostgreSQL e volto a atenção para a camada do Ruby: inspeciono o volume de objetos alocados, verifico se a serialização de dados não está pesada e avalio se parciais do ActionView não estão consumindo tempo excessivo de renderização.

Se a consulta no banco de fato concentrar o tempo de resposta, avalio os nós do plano. Só adiciono um índice se houver evidência clara de varredura sequencial em volume grande de dados ou descarte elevado após a leitura do índice atual. Caso o problema envolva N+1 genuíno, com centenas de viagens de rede acumulando latência real, prefiro usar preload para separar as buscas em duas etapas simples em vez de forçar um eager_load que incha a memória da aplicação com junções cartesianas.

O ActiveRecord tem particularidades conhecidas e o PostgreSQL obedece aos limites do hardware onde opera. Tratar problemas de desempenho como uma disputa de culpa entre o framework e o banco só produz soluções paliativas. Quem aprende a inspecionar os buffers e o custo real de execução para de criar índices inúteis e para de sacrificar a memória do servidor com carregamentos antecipados que a aplicação nunca solicitou.

PD

Sobre Pedro Dalben

CTO e arquiteto full stack especializado em Ruby on Rails, Vue.js, sistemas multi-tenant e infraestrutura distribuída.