CTO no Integrar Plus • Publicado em 02 de October de 2026
Toda vez que um endpoint em Rails passa de dois segundos para responder em produção, a conversa no time costuma se dividir em dois campos previsíveis. De um lado, estão os defensores de que o ActiveRecord é lento por natureza, gera consultas cheias de colunas desnecessárias e que a solução definitiva é abandonar o ORM naquele ponto crítico, reescrevendo tudo em SQL puro. Do outro lado, estão os que tratam qualquer gargalo como um problema de N+1 não resolvido, sugerindo salpicar .includes em todas as relações da controller e criar índices cegamente em qualquer coluna terminada em _id.
Eu discordo das duas posturas. Ambas tentam resolver o sintoma pelo código Ruby sem olhar para o que realmente acontece dentro do processo do PostgreSQL. Quando você reescreve uma consulta do ActiveRecord em SQL puro sem mexer na estrutura de dados ou no plano de execução, o banco continua executando exatamente os mesmos passos, percorrendo as mesmas páginas de disco e consumindo os mesmos ciclos de CPU. A única diferença é que você trocou dez milissegundos de instanciação de objetos Ruby por uma string SQL estática que ninguém mais vai querer refatorar no futuro.
Da mesma forma, tratar N+1 como uma entidade maligna que precisa ser eliminada a qualquer custo gera aberrações silenciosas. O Rails resolve .includes de duas maneiras: fazendo duas ou mais consultas separadas com WHERE id IN (...) ou montando um LEFT OUTER JOIN gigantesco quando forçado por um .references ou por um .eager_load. Em tabelas com dezenas de milhares de registros associados, esse join carrega uma quantidade enorme de dados duplicados pela rede, inunda a memória do processo Ruby instanciando milhares de modelos intermediários e pode ser ordens de magnitude mais lento do que as duas consultas originais que o desenvolvedor tentou evitar.
Para parar de adivinhar, a única ferramenta que encerra discussões teóricas é o comando EXPLAIN (ANALYZE, BUFFERS). O problema é que muita gente abre o plano, vê um bloco indecifrável de texto com números entre parênteses e volta correndo para o código.
Executar apenas EXPLAIN pede para o otimizador do PostgreSQL estimar o que ele faria com base nas estatísticas das tabelas. Isso serve de pista, mas não resolve investigações de produção. Nós precisamos da execução real, que vem com o ANALYZE, e precisamos obrigatoriamente do parâmetro BUFFERS.
EXPLAIN (ANALYZE, BUFFERS)
SELECT "patients".* FROM "patients"
WHERE "patients"."clinic_id" = 42
AND "patients"."status" = "active"
ORDER BY "patients"."created_at" DESC
LIMIT 20;
A saída desse comando organiza a execução em uma árvore hierárquica lida de dentro para fora, de baixo para cima. O nó mais aninhado roda primeiro e alimenta os nós superiores. Ao ler esse resultado, existem três números que entregam quase todo o diagnóstico.
Primeiro, a diferença entre cost e actual time. O custo inicial e final, representado por cost=0.00..125.40, é uma unidade interna do planejador. O que importa para a sua investigação de latência é actual time=0.045..18.230, que mede milissegundos reais de execução naquele nó.
Segundo, a comparação entre rows e actual rows. O primeiro valor é quantas linhas o otimizador achava que encontraria; o segundo é quantas linhas ele realmente processou. Se o PostgreSQL esperava achar duas linhas e achou duzentas mil, as estatísticas da tabela estão desatualizadas (o que pede um ANALYZE na tabela) ou os custos de seletividade da cláusula WHERE foram calculados com base em uma distribuição irregular de dados.
Terceiro, e mais revelador, é a contagem de blocos em Buffers. Uma linha com shared hit=1200 read=450 diz que o banco encontrou 1200 páginas na memória compartilhada e precisou buscar 450 páginas diretamente no disco ou no cache do sistema operacional. Se uma consulta que retorna vinte registros exibe dezenas de milhares de buffers lidos, ela está forçando o banco a percorrer megabytes de dados irrelevantes para entregar um punhado de linhas.
Um dos erros mais comuns de interpretação é achar que todo Seq Scan (varredura sequencial) representa um bug e que todo Index Scan representa eficiência.
O PostgreSQL avalia o custo de saltar pelo arquivo de índice e depois buscar as páginas da tabela (chamadas de heap) contra o custo de ler a tabela inteira do início ao fim de forma contínua. Em tabelas pequenas ou quando uma consulta precisa retornar mais de dez a quinze por cento das linhas da tabela, a varredura sequencial é intencionalmente mais rápida porque evita leituras aleatórias no disco.
Por outro lado, quando a tabela tem centenas de milhares de linhas e o nó ainda assim exibe um Seq Scan, o problema raramente é o ActiveRecord. Os motivos reais costumam ser técnicos e específicos:
ORDER BY.Existe um nó intermediário que engana quem bate o olho rapidamente no plano de execução: o Index Scan acompanhado de uma linha que diz Rows Removed by Filter.
Imagine que você criou um índice simples na coluna clinic_id. Você roda a consulta filtrando por clinic_id e status = "active". O PostgreSQL consulta o índice, localiza todas as linhas da clínica e vai até a tabela buscar os registros. Ao chegar lá, ele avalia cada uma das linhas para checar se o status é de fato ativo, descartando as que não atendem ao critério.
Se a clínica tiver meio milhão de pacientes inativos e apenas cinquenta ativos, o nó vai exibir um tempo alto de execução, milhares de buffers lidos e uma anotação informando que 499.950 linhas foram removidas pelo filtro. O índice foi usado, mas foi praticamente inútil para reduzir o trabalho pesado de I/O.
A solução aqui não tem relação com o Rails nem com trocar o código por uma stored procedure. A solução é criar um índice composto adequado que cubra a combinação de filtros, ou, se o volume de inativos for permanente e irrelevante para a rotina diária do sistema, criar um índice parcial:
CREATE INDEX idx_patients_active_by_clinic
ON patients (clinic_id, created_at DESC)
WHERE status = "active";
Com esse índice, o plano muda instantaneamente para uma varredura direta nas páginas pré-filtradas, derrubando os buffers acessados de milhares para uma dúzia, sem que você precise alterar uma única linha do modelo Rails.
O ActiveRecord gera consultas que atendem às regras que nós mesmos declaramos nas associações e nos escopos. Quando a consulta fica lenta, o impulso de abrir o arquivo da controller e tentar reescrever o fluxo na intuição costuma criar débitos técnicos difíceis de desfazer.
A única abordagem que se sustenta no longo prazo ao gerenciar sistemas em servidores próprios, onde recursos de I/O e memória não são infinitos, é isolar a instrução SQL que aparece nos logs do Rails, colar no console do banco e rodar com EXPLAIN (ANALYZE, BUFFERS). O plano de execução não esconde segredos: ele aponta exatamente quantas páginas foram lidas, onde as linhas foram descartadas e quanto tempo o banco perdeu tentando compensar a falta de um índice correto.
CTO e arquiteto full stack especializado em Ruby on Rails, Vue.js, sistemas multi-tenant e infraestrutura distribuída.