Utilizando o ActiveRecord de forma eficiente com Ruby on Rails

Caio Ergos
Red Academy Journal
6 min readAug 22, 2017

--

Sua aplicação está lenta? Ao carregar uma página você vê em seus logs que sua aplicação está fazendo 547 consultas ao banco de dados, e isso em uma tela que deveria simplesmente listar alguns objetos?

Neste artigo iremos tratar de alguns assuntos como scopes, queries n+1 e algumas boas práticas de consultas ao banco de dados utilizando Rails.

Deixe o banco de dados trabalhar

Nunca utilize o Ruby para fazer consultas. O banco de dados foi feito justamente para isso e exerce essa função de forma infinitamente mais eficaz.

class User < ApplicationRecord
# CÓDIGO RUIM
def self.active
all.select { |user| user.active == true }
end
# CÓDIGO BOM
def self.active
where(active: true)
end
end

O primeiro método acima irá carregar todos os usuários de seu banco de dados, carregá-los em objetos e então usar o Ruby para filtrar esse Array.

SELECT "users".* FROM "users"

O segundo método, no entanto, está apenas construindo um objeto que irá realizar uma consulta: conhecido como proxy. Ou seja, ele só irá carregar os objetos referentes a consulta quando eles realmente precisarem ser acessados.

SELECT "users".* FROM "users" WHERE "users"."active" = 't'

Outro problema associado com o primeiro método é que não será possível encadear outros métodos do ActiveRecord. Você acabará de perder todo o poder do AR.

Escrevi um pequeno e simples benchmark apenas para que você tenha noção da diferença.

Benchmark.bm do |bm|
bm.report("Using arrays") do
1000.times do
users = User.all.select { |user| user.active == true }
end
end
bm.report("Using AR") do
1000.times { users = User.where(active: true) }
end
end

O Resultado foi o seguinte:

             user        system       total     real
Using arrays 16.780000 0.160000 16.940000 (18.124898)
Using AR 0.060000 0.000000 0.060000 (0.070095)

Usar apenas o ActiveRecord para carregar os usuários se mostrou 279 vezes mais rápido.

Se formos fazer uma consulta um pouco mais complexa a diferença será ainda mais gritante. Abaixo, a consulta deverá retornar os usuários cuja a média das avaliações seja maior que 8.

Benchmark.bm do |bm|
bm.report("Using arrays") do
500.times do
users = User.all.select { |user| (user.ratings.sum(&:rate) / user.ratings.size) > 8 }
end
end
bm.report("Using AR") do
500.times do
users = User
.select("users.*, AVG(ratings.rate) AS rating")
.where(active: true)
.joins(:ratings)
.group("users.id")
.having("AVG(ratings.rate) > 8")
end
end
end

O resultado será o seguinte:

             user         system     total       real
Using arrays 1191.840000 25.240000 1217.080000 (1334.905246)
Using AR 0.050000 0.000000 0.050000 (0.045837)

Desta vez, utilizar apenas o ActiveRecord foi 23,836x mais rápido.

Scopes

São uma feature do ActiveRecord e, como o próprio nome já deixa bem claro, são usados para definir escopos. Eles devem:

  • Retornar apenas proxies
  • Filtrar dados
  • Ser reusáveis

Eles não devem:

  • Ordenar dados
  • Utilizar o Ruby para filtrar os dados
  • Instanciar os objetos referentes a consulta
class User < ApplicationRecord
scope :active, -> { where(active: true, registration_completed: true) }
scope :search, -> (name) { where(name: name) if name.present? }
end

E você poderá chamar pegar os usuários ativos de seu banco apenas utilizando User.active.

Qual a diferença, então, entre você adicionar um scope ou um método de classe? A diferença está no simples fato de que scopes são sempre encadeáveis.

# Utilizando método de classe
User.search(nil).active
#=> SELECT users.* FROM users WHERE active = 't' AND name IS NULL

Não era bem essa a consulta que você queria, não é? Utilizando scopes ficará assim (veja acima que verificamos se o argumento está presente):

# Utilizando escopo
User.search(nil).active
#=> SELECT users.* FROM users WHERE active = 't'

Se fizéssemos essa comparação no método de classes, o método iria retornar nil.

Não ordene escopos. Como o objetivo dos scopes é o reuso de código, não faz muito sentido ordená-los, já que cada endpoint poderá utilizar uma ordenação diferente. Salvo exceção no caso de ordenações não triviais:

class User < ApplicationRecord
has_many :ratings

scope :high_rated, -> do
select("users.*, AVG(ratings.rate)")
.joins(:ratings)
.group("users.id")
.having("AVG(ratings.rate) > 8.5")
end
end
class Rating < ApplicationRecord
belongs_to :user
end

Evite utilizar SQL puro

Vamos complicar um pouco mais o exemplo anterior. Agora, somos capazes de saber quem realizou a avaliação.

class Rating < ApplicationRecord
belongs_to :user
belongs_to :rater, class_name: "User", foreign_key: "rater_id"
end

Tente pensar em como fazer a seguinte consulta: vamos achar aqueles usuários das quais pelo menos metade das avaliações feitas são um 8 e possua avaliação média de 7.

Em SQL, ficaria algo assim:

SELECT
name,
email,
user_rating
FROM (
SELECT
users.name,
users.email,
AVG(ratings.rate) AS user_rating,
(SELECT COUNT(*) FROM ratings WHERE ratings.user_id = users.id) AS ratings_number,
(SELECT COUNT(*) FROM ratings WHERE ratings.user_id = users.id AND ratings.rate >= 8) AS high_ratings_number
FROM users
INNER JOIN ratings
ON ratings.user_id = users.id
GROUP BY users.id) AS t1
WHERE t1.ratings_number/t1.high_ratings_number >= 0.5
AND user_rating >= 7

Nada realmente lhe impede de fazer a consulta da seguinte forma:

class User < ApplicationRecord
scope :high_rated, -> do
find_by_sql <<-SQL
# SQL Escrito acima.
SQL
end
end

Você terá os usuários que deseja carregados no objeto. O problema desta approach é que você não será capaz de encadear consultas.

2.3.3 :004 > User.high_rated.where(active: true)
User Load (3.7ms) SELECT * FROM users
NoMethodError: undefined method `where' for #<Array:0x007ffa282d5d00>
from (irb):4

Para que seja possível encadear consultas, precisamos utilizar os métodos do ActiveRecord. A consulta acima ficaria assim:

Assim, podemos facilmente encaixar outros métodos.

User.high_rated.where(active: true).count(:all)

Lembrando que não é proibido escrever SQL puro e cabe a você decidir se o tradeoff praticidade/reuso vale a pena. Se a sua consulta com ActiveRecord não ficar nem um pouco legível a outros desenvolvedores, esse será um indício de que utilizar apenas o SQL seja uma melhor alternativa.

Nunca utilizar default_scope

Já herdei projetos que faziam uso do famoso default_scope e ele apenas me rendeu inúmeros problemas.

O model User agora terá um escopo padrão.

class User < ApplicationRecord
default_scope -> { where(active: true) }
end

Vou listar alguns pontos que tornam minhas horas de programação não muito agradáveis.

  • Não é possível sobrepor condições de pesquisa
2.3.3 :066 > User.where(active: false)
User Load (0.8ms) SELECT "users".* FROM "users" WHERE "users"."active" = $1 AND "users"."active" = $2 [["active", "t"], ["active", "f"]]

Bem, até hoje eu nunca ouvi falar de um booleano que seja verdadeiro e falso ao mesmo tempo.

Sempre que quiser pesquisar algo fora do padrão, você deverá realizar suas consultas utilizando unscoped, o que vai se tornar algo bem incoveniente.

2.3.3 :067 > User.unscoped.where(active: false)
User Load (0.7ms) SELECT "users".* FROM "users" WHERE "users"."active" = $1 [["active", "f"]]
  • Ele afeta a inicialização do seu model
2.3.3 :063 > User.new
=> #<User id: nil, name: nil, email: nil, active: true, created_at: nil, updated_at: nil>

Dê adeus às queries N+1

Esse é um problema bastante comum para quem está começando. Já herdei projetos nos quais vários dos problemas de performance estavam atribuídos a essa causa.

Imagine que temos uma tela na qual devemos mostrar todas as avalições de alguns usuários. No controller carregaríamos todos os usuários e, na view, teríamos de listar as avaliações de cada usuário.

@users = User.limit(3)@users.each do |user|
user.ratings.each { |rating| puts rating.rate }
end
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."active" = $1 LIMIT $2 [["active", "t"], ["LIMIT", 3]] Rating Load (0.7ms) SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" = $1 [["user_id", 2001]]

Rating Load (0.2ms) SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" = $1 [["user_id", 2002]]

Rating Load (0.3ms) SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" = $1 [["user_id", 2003]]

Veja que fizemos 4 consultas ao banco de dados listando 3 usuários (n+1).

Isso ocorreu porque para cada usuário, fazemos uma consulta para achar as avaliações dele. Acontece que podemos carregar carregar todos esses objetos de antemão e deixar o acesso bem mais rápido.

Para isso, basta utilizarmos o métodoincludes do ActiveRecord.

@users = User.limit(3).includes(:ratings)@users.each do |user|
user.ratings.each { |rating| puts rating.rate }
end
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."active" = $1 LIMIT $2 [["active", "t"], ["LIMIT", 3]]
Rating Load (0.4ms) SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" IN (2001, 2002, 2003)

Veja que fizemos apenas 2 consultas. Mesmo se tivéssemos carregado 100 usuários, haveriam apenas 2 consultas.

Espero que esse artigo lhe ajude de alguma forma! Tudo o que está aqui é um compilado (com vários incrementos, claro) de vários textos estudados, somados à experiência prática!

Obrigado por ler!

Se você gostou deste artigo, por favor recomende e compartilhe.

--

--