O que é N+1
?
De forma simples, é quando sua aplicação busca uma lista de itens e depois, ao precisar de uma informação relacionada a cada um desses itens, acaba fazendo uma consulta extra para cada item da lista original.
Funciona assim:
- Primeiro, uma consulta (o
+1
) busca a lista principal. - Depois, para cada um dos
N
itens dessa lista, uma nova consulta é feita para buscar a informação relacionada.
Por que isso é um problema?
No começo, eu pensava: “Ah, cada consulta dessas é rápida”. Mas o problema, na verdade, é que a soma de todas elas vira uma bola de neve. Esse monte de idas e vindas no banco:
- Aumenta a latência: A página demora mais para carregar, e ninguém gosta de esperar.
- Sobrecarrega o banco: O banco acaba tendo que lidar com muito mais trabalho que o necessário, consumindo mais recursos.
- Prejudica a escalabilidade: Com poucos dados, é difícil de notar. Mas quando a aplicação cresce e a quantidade de dados aumenta, essa lentidão acaba virando um gargalo sério.
Como identificar?
- Logs:
- No ambiente de desenvolvimento, se eu via uma consulta buscando uma lista e, logo depois, várias consultas parecidas buscando dados relacionados (geralmente só mudava um
ID
noWHERE
), era quase certo que podia ter umN+1
em algum lugar.
- No ambiente de desenvolvimento, se eu via uma consulta buscando uma lista e, logo depois, várias consultas parecidas buscando dados relacionados (geralmente só mudava um
- Bibliotecas externas:
- Pesquisando sobre
N+1
, descobri duas libs a bullet e a rack-mini-profiler, vi algumas pessoas falando bem sobre elas, mas ainda não tive tempo para testar :/
- Pesquisando sobre
Como resolver?
A principal forma que eu aprendi foi resolver usando eager loading
. A ideia é pedir para o ActiveRecord buscar todos os dados relacionados de uma vez, em vez de esperar que a gente peça por eles um por um.
includes
Esse é o método que eu mais uso e, geralmente, o primeiro que eu tento.
Ele faz com que o ActiveRecord carregue as associações que eu especificar.
Exemplo:
Com N+1
# No Controller
@posts = Post.all
# Na View
<% @posts.each do |post| %>
<%# Aqui acontecia o N+1 ao acessar post.user %>
<%= post.title %> - <%= post.user.name %>
<% end %>
Isso gerava 1 consulta para Post.all
e mais N consultas para post.user
.
Post Load (0.5ms) SELECT "posts".* FROM "posts"
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = 4 LIMIT 1
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = 5 LIMIT 1
Com includes
# No Controller
@posts = Post.includes(:user).all
# Na View
<% @posts.each do |post| %>
<%= post.title %> - <%= post.user.name %>
<% end %>
Com essa mudança, o Rails passou a fazer algo como:
Post Load (0.5ms) SELECT "posts".* FROM "posts"
User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4 5)
Apenas 2 consultas, não importa quantos posts no banco eu tivesse.