---
title: "O reflexo de culpar o ActiveRecord antes de abrir o EXPLAIN ANALYZE"
author: "Pedro Dalben"
date: "2026-10-02T11:03:30Z"
category: "Ruby on Rails"
reading_time: "7 min"
canonical: "https://pedrodalben.com.br/blog/o-reflexo-de-culpar-o-activerecord-antes-de-abrir-o-explain-analyze"
---

# O reflexo de culpar o ActiveRecord antes de abrir o EXPLAIN ANALYZE

> Culpar o ORM pelo tempo de resposta costuma esconder falta de leitura do plano de execução do PostgreSQL e índices mal desenhados.

**Autor:** Pedro Dalben (CTO no Integrar Plus)  
**Publicado em:** 02 de October de 2026  
**URL Canônica Oficial:** [https://pedrodalben.com.br/blog/o-reflexo-de-culpar-o-activerecord-antes-de-abrir-o-explain-analyze](https://pedrodalben.com.br/blog/o-reflexo-de-culpar-o-activerecord-antes-de-abrir-o-explain-analyze)  

---


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.

### A anatomia mínima de um plano de execução

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

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

### Seq Scan contra Index Scan: por que o banco ignora seu índice

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:

1. A coluna sofreu coerção de tipo na cláusula WHERE, como comparar uma coluna numérica com uma string passada por parâmetro, o que impede o uso de índices comuns na coluna original.
2. O índice foi criado em uma coluna que não é o prefixo de uma consulta composta, ou foi construído sem levar em conta a ordenação exigida pelo `ORDER BY`.
3. A função de busca usa operadores que não casam com a classe de operadores do índice, como buscar texto parcial sem um índice trigram (pg_trgm) apropriado.

### O filtro silencioso que consome memória

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:

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

### A fronteira entre o ActiveRecord e o banco

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.
