Solucionando o Problema N+1 em Rails

Isabelle Samways
January 12, 2024

Rails escala bem? Bom, podemos observar o Github que, sim, escala muito bem. Mas como eles fazem? Como se resolve um problema de N+1 para que não afete a performance de pesquisas no banco de dados?

Neste post, vamos começar explicando o que é o problema N+1. Depois, veremos como podemos resolvê-lo com SQL puro e, finalmente, como implementamos a solução em Rails.

Estávamos fazendo um web app para um fórum online e nos deparamos com este problema.

Nosso app era formado por um model Post que continha um relacionamento com o model Comment conforme o código a seguir:

A página principal da aplicação listava o título do post junto com a quantidade de comentários de cada um.

Print do webapp, na tela principal, onde mostra 5 posts alinhados em formato de tabela, cada linha tendo o título do post, o link para detalhes e ao lado o número de cometários daquele post.

Em um primeiro momento, resolvemos o problema de trazer o número de comentários fazendo uma chamada ao relacionamento diretamente a partir da view (o V do MVC):

No controller, o código para listar todos os posts era um simples Post.all, assim:

Este código gera os seguintes statements SQL:

~~~

// selecionando todos os posts, esta consulta é disparada no controller

SELECT "posts".* FROM "posts"

// como estamos dentro de um loop, selecionamos os comentários de cada post e contamos.

// esta consulta é disparada na view.

SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1

// esta linha se repete para cada um dos posts

~~~

Para abrir essa página de index dos posts (localmente): Completed 200 OK in 462ms (Views: 426.4ms | ActiveRecord: 22.2ms | Allocations: 229529)

Desta forma, o número de queries necessárias para carregar o número de comentários dos posts será sempre igual ao número de posts (N) mais a query necessária para carregar todos os posts (1). Por isso, este cenário é conhecido como “N+1” e é uma das maiores ciladas em aplicações Rails, e um dos maiores obstáculos para a escalabilidade.

O ideal é que cada objeto ActiveRecord do tipo Post carregue seus respectivos comentários diretamente do controller e com o menor número de consultas SQL possível.

Uma solução em SQL puro seria assim:

// aqui selecionamos os posts

SELECT * FROM "posts"

// aqui selecionamos os comentários desses posts, perceba que estamos usando

WHERE INSELECT * FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3, 4, 5)

// dentro do parênteses do IN colocamos todos os ids de post. Utilizei de 1 a 5 apenas como exemplo.

Wow! Observe que em apenas dois statements SQL conseguimos carregar todos os posts e também seus comentários. Estes mesmos dois statements solucionam nosso problema para qualquer quantidade de posts. Ao invés de “N + 1”, agora temos um constante… 2 :)

Momento documentação:

Quando usamos o WHERE IN nós podemos passar um array de ids, por exemplo, e ele se encarrega de fazer o nosso “for each”. Veja mais informações aqui.

Agora que sabemos o SQL que precisamos gerar, como faremos isso a partir do Rails? A resposta é simples: utilizando o método includes.

Momento documentação:

O includes é um “eager loader”, ou seja, é um mecanismo pelo qual uma associação, coleção ou atributo é carregado imediatamente quando o objeto principal é carregado. Desta forma, todas as relações de uma entidade serão carregadas no mesmo momento em que esta entidade é carregada. É o contrário do Lazy Loading, que estávamos fazendo, que é o mecanismo utilizado pelos frameworks de persistência para carregar informações sob demanda. Este mecanismo torna as entidades mais leves, pois suas associações são carregadas apenas no momento em que o método que disponibiliza o dado associativo é chamado.

Nosso código final agora fica assim:

Com o includes também poderíamos carregar outros relacionamentos dos comentários, se existissem. Por exemplo, caso tivesse autenticação nesse fórum e tivéssemos usuários, poderíamos ter um belongs_to :user nos comentários. Ficaria mais ou menos assim: .includes(comments: [:user]) e assim ele iria carregar os comentários junto com o user de cada um.

Além de incluirmos o .includes, incluímos também o .size no lugar de .count. Você deve estar se perguntando o porquê desta mudança.

Momento documentação:

  • post.comments.count: determina o número de elementos com uma consulta SQL COUNT. Se você configurar um cache de contador na associação, count retorna esse valor em cache em vez de executar uma nova consulta. (veja mais sobre o COUNT aqui)
  • post.comments.length: isso sempre carrega o conteúdo da associação na memória e retorna o número de elementos carregados. Isso não forçará uma atualização se a associação tiver sido carregada anteriormente e, em seguida, novos comentários forem criados de outra maneira (por exemplo, Comment.create(…) em vez de post.comments.create(…)).
  • post.comments.size: funciona como uma combinação das duas opções anteriores. Se a coleção já tiver sido carregada, ela retornará seu tamanho exatamente como se chamasse o length. Se ainda não foi carregado, é como chamar o count.

Com estas explicações, espero que nossas escolhas tenham ficado claras.

Depois destas mudanças, nossa página agora carrega assim: Completed 200 OK in 225ms (Views: 198.5ms | ActiveRecord: 13.6ms | Allocations: 231732).

Praticamente a metade do tempo!

Se quiser ver esse web app funcionando, você pode acessar por aqui.

Esperamos que você consiga aplicar este conceito em seus projetos e evite cair no problema do N+1!