Um N+1 é uma explicação comum para um endpoint de listagem lento no Rails. Carregar associações antecipadamente pode eliminar consultas repetidas, mas isso não garante que a consulta principal seja eficiente. São problemas diferentes, e eu os investigo separadamente.
Os exemplos abaixo usam uma aplicação fictícia de publicações. O domínio, os nomes de tabelas e colunas, os identificadores e o código são ilustrativos. Não reproduzem esquema de banco, planos de execução, configurações ou medições de desempenho de uma empresa.
O sintoma
Imagine uma listagem que carrega publicações e seus autores. O log da requisição mostra consultas repetidas durante a serialização da resposta. Depois de adicionar eager loading, o número de consultas cai, mas a maior parte do tempo da requisição continua na consulta que seleciona as publicações.
Nesse ponto, contar consultas já não basta. Preciso entender onde o banco está gastando tempo.
Um exemplo fictício
Neste exemplo, uma aplicação lista os posts mais recentes de autores de uma determinada publicação:
SELECT posts.*
FROM posts
WHERE author_id IN (
SELECT id FROM authors WHERE publication_id = 7
)
ORDER BY posts.id DESC
LIMIT 20;
Todos os nomes e valores deste exemplo são inventados. A pergunta é se o banco consegue encontrar os posts correspondentes sem examinar dados demais que não fazem parte do resultado.
O que observar no plano
Um índice B-tree ordenado pode ajudar a atender a um ORDER BY sem uma ordenação separada. Para uma consulta com filtro e LIMIT, o planejador pode considerar caminhos diferentes: encontrar as linhas correspondentes e ordená-las, ou ler um índice na ordem desejada e parar quando encontrar correspondências suficientes.
Ler na ordem pode funcionar bem quando as linhas correspondentes aparecem cedo. Se forem raras naquela parte do índice, porém, devolver uma página curta ainda pode exigir muito trabalho. Uma varredura por índice não significa automaticamente uma consulta barata.
Uso EXPLAIN (ANALYZE, BUFFERS) para comparar linhas estimadas e reais, tempo de execução, repetições e atividade de buffers. A pergunta útil é quanto trabalho a consulta realiza para produzir o resultado pedido, não apenas se um índice aparece no plano.
O EXPLAIN ANALYZE executa a instrução. Faço essa análise em um ambiente controlado, com dados representativos e permissão para realizar a investigação.
O planejador usa estimativas derivadas de estatísticas. Uma diferença grande entre estimado e real é motivo para investigar distribuição dos dados, estatísticas e estrutura da consulta. Sozinha, ela não prova que uma determinada reescrita seja a solução.
Uma alternativa para testar
Quando o conjunto de ids de autores é pequeno, uma opção para comparar é resolvê-lo antes de consultar os posts:
author_ids = Author.where(publication_id: 7).pluck(:id)
Post.where(author_id: author_ids).order(id: :desc).limit(20)
O filtro resultante pode ser ilustrado com estes valores fictícios:
SELECT posts.*
FROM posts
WHERE author_id IN (11, 22, 33)
ORDER BY posts.id DESC
LIMIT 20;
Esse SQL ilustra os valores selecionados; na aplicação, deixo o Active Record tratar os parâmetros da consulta, em vez de interpolar valores no SQL.
Separar as consultas muda a informação disponível quando a segunda consulta é planejada. É uma hipótese a medir, não uma promessa de plano melhor. O tratamento dos parâmetros, os índices, as estatísticas e a distribuição dos dados podem afetar o resultado.
Também há custos: uma ida a mais ao banco, memória para a lista de ids e consistência entre as duas leituras. Para um conjunto grande de ids, manter a operação no banco pode ser preferível. Comparo as alternativas antes de escolher.
Como distinguir os problemas
- Verifique se o número de consultas de associação cresce com o número de registros retornados. Esse é o padrão a investigar em um N+1.
- Identifique qual consulta domina o tempo da requisição. Reduzir a quantidade de consultas pode deixar a mais cara intacta.
- Leia o plano de execução com dados representativos. Compare o trabalho realizado com as linhas retornadas, em vez de focar no nome isolado de um nó.
- Verifique estatísticas, índices e alternativas de consulta. Uma subconsulta não é lenta por definição, e materializar ids não é uma correção universal.
- Meça novamente a requisição inteira. Considere serialização, idas ao banco e uso de memória ao avaliar uma mudança.
O que levo dessa análise
Separo o carregamento de associações do custo da consulta principal. Uso logs para identificar trabalho repetido, planos de execução para investigar o comportamento do banco e medições para decidir se uma mudança ajudou. Um N+1 pode existir sem ser a principal fonte de latência.