N + 1 trágicos e onde habitam

Artigos - 12/Mai/2021 - por Vivi Poit

A sua aplicação Rails parece lenta? As vezes, quando você navega para uma tela, parece que ela demora mais do que deveria para carregar? Você já se pegou pensando: "Se esta tela é só uma lista simples, porque demora tanto?"

Dúvidas assim são comuns quando a gente está desenvolvendo aplicações Rails e ainda não aprendeu sobre os trágicos N + 1, que o Rails – sem más intenções – praticamente nos convida a colocar por todo lado no início.

O que é N + 1?

N + 1 é como a gente se refere às situações em que para fazer algo, o nosso código precisa consultar "só mais uma coisinha" lá no banco de dados. O exemplo mais comum é quando chamamos o método .all sozinho e, na verdade, precisamos de informações associadas também.

Na prática: vamos imaginar que temos uma aplicação Rails para organizar nossos amigos e os bichinhos de estimação de cada um.

Provavelmente teríamos models assim:

# app/models/friend.rb

class Friend < ApplicationRecord
  has_many :pets
end

# app/models/pet.rb

class Pet < ApplicationRecord
  belongs_to :friend
end

Se quisermos criar uma tela que lista estes bichinhos, faríamos algo assim na action index da PetsController:

# app/controllers/pets_controller.erb

class PetsController < ApplicationController
  def index
    @pets = Pet.all
  end
end

E assim na view:

<!-- app/views/pets/index.html.erb →

<h1> Bichinhos de Estimação</h1>

<table class=”table>
  <tr>
      <th>Nome</th>
      <th>Nome do Dono</th>
  </tr>
  <% @pets.each do |pet| %>
    <tr>
      <td><%= pet.name %></td>
      <td><%= pet.friend.name %></td>
  <% end %>
</table>

É aqui que mora o perigo!

Veja que estamos chamando o nome do bichinho e o nome do nosso amigo, mas na controller, o método .all só consulta Pet. Isso quer dizer que para cada linha desta lista, nossa aplicação Rails está voltando lá no banco de dados para "pegar mais uma coisinha". Neste caso a "coisinha" é o nome do nosso amigo, um por um.

Se a gente rodar rails server e abrir esta view no navegador, veremos algo assim:

E no terminal, veremos o output mais ou menos assim:

Veja que a tela fez um SELECT inicial na tabela pets e depois, para cada linha que construiu, fez mais um SELECT na tabela friends. Isso é o N + 1.

Este é um N + 1 trágico e é aqui que ele habita!

Ele pode deixar a nossa tela lenta. Imagina se for uma lista com 100 itens? Ou se for uma lista com várias associações?

Cada vez que a view precisa de algo que não venha diretamente da tabela pets, o Rails vai lá no banco perguntar mais alguma coisa. Isso pode demorar, principalmente em ambientes de produção, onde o mais comum é que o banco de dados nem esteja na mesma máquina que a aplicação.

Vamos ressaltar aqui informações importantíssimas da última linha:

Views: 51.9ms | ActiveRecord: 1.2ms

Isso nos fala do tempo de carregamento, incluindo as consultas ao banco de dados.

Para vermos isso com mais clareza, vou popular mais o nosso banco de dados e chamar a nossa view novamente. Desta vez, ao invés de 4 pets e friends, teremos 250.

Ao atualizar a tela lá no meu navegador, vejo um monte de SELECT tomar conta do meu terminal e a última linha informar:

Views: 303.4ms | ActiveRecord: 21.6ms

Demorou bem mais! Segue o print:

Agora que já sabemos o que é o N + 1 e onde ele habita…

Como evitar esta tragédia?

Vamos usar algo chamado eager loading. No Rails fazemos isso com o método .includes. Para mais detalhes e informações técnicas desta parte, veja este link no Rails Guides.

O .includes vai nos permitir já trazer os dados associados de uma vez. Com ele, ao invés do Rails ficar batendo no banco de dados várias vezes, ele vai de uma vez só pegar tudo que dissermos ser necessário.

Neste nosso exemplo, o necessário é a tabela friends. Nossa controller, então, vai especificar que ao carregar os pets, também queremos informações dos friends. Assim:

# app/controllers/pets_controller.erb

class PetsController < ApplicationController
  def index
    # ANTES
    @pets = Pet.all

    # DEPOIS
    @pets = Pet.includes(:friend).all
  end
end

Quando atualizamos a tela agora, nosso terminal fica assim:

Percebemos que ao invés de chamar o banco de dados a cada linha, o Rails fez uma grande chamada logo no início e já carregou tudo que ele precisava.

Olhando a última linha, temos:

Views: 14.1ms | ActiveRecord: 1.5ms

Lembra como estava antes?

Views: 303.4ms | ActiveRecord: 21.6ms

Fazendo as contas aqui, temos uma melhoria de praticamente 20x na parte de views e 14x na parte do ActiveRecord!

É isso aí. Esta é a lição do N + 1, porque ele é trágico, onde habita, e como evitá-lo na sua aplicação.

Bônus Round

Se tudo isso fez sentido demais e você já está imaginando que suas aplicações Rails estão precisando de uma limpeza geral, dê uma olhada na gem Bullet, que pode te ajudar a encontrar todo e qualquer N + 1 escondido nas sombras e nos becos do seu código.

Foto de perfil do autor
Vivi Poit

Dev