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.
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:
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!