Consultas N+1 e Métodos de Query em Métodos de Instância de Model
Objective
A causa mais comum de uma consulta N+1 "surpresa" em uma aplicação Rails não
é um includes faltando em um controller; é um método de query do
ActiveRecord (where, find_by, joins, select, order, pluck, e
umas quinze outras) chamado de dentro de um método de instância de
model. O método parece perfeitamente inofensivo isoladamente; o problema
só aparece quando ele é chamado uma vez por elemento dentro de um loop ou
uma partial de view, momento em que dispara uma query por registro em vez
de uma query total.
Use Cases
- Revisar em code review um novo método de instância em um model
ActiveRecord que lê como
def recent_reviews; Review.where(user: self).order(created_at: :desc); end, reconhecendo esse formato como um risco de N+1 antes de ir para produção, não depois de um profiler encontrá-lo lá. - Escolher entre
find_each/in_batchese.all.eachao iterar uma tabela grande, para evitar carregar o conjunto de resultado inteiro na memória de uma vez. - Decidir quando eager loading (
includes/preload/eager_load) realmente ajuda versus quando busca demais e instancia mais objetos do que o código precisa. - Auditar um endpoint lento contando queries SQL nos logs de desenvolvimento contra um volume de dados que de fato se parece com produção, não um punhado de registros de seed.
Deep Dive
O N+1 escondido: um método de query dentro de um método de instância
plaintextclass User < ApplicationRecord has_many :reviews # Looks harmless on its own... def recent_reviews reviews.where("created_at > ?", 1.week.ago).order(created_at: :desc) end end
plaintext<% @users.each do |user| %> <%= user.recent_reviews.count %> <% end %>
Mesmo que @users tenha sido carregado com uma única query, user. recent_reviews reroda uma query WHERE/ORDER BY por usuário, porque
reviews.where(...) é uma query ActiveRecord nova, não um filtro sobre uma
associação já carregada. Nada nesse código está individualmente errado; o
método lê bem, a view lê bem; o N+1 só existe na interseção dos dois.
A correção não é necessariamente "adicionar includes(:reviews)" (isso
ainda deixaria a filtragem WHERE/ORDER BY acontecer uma vez por usuário
em Ruby, o que é tranquilo para um punhado de registros associados, mas
desperdício para milhares). Muitas vezes a correção melhor é empurrar a
lógica de filtragem para um scope em Review e chamá-lo explicitamente
do controller com eager loading, mantendo o método de instância do model
completamente livre de chamadas de query:
plaintextclass Review < ApplicationRecord scope :recent, -> { where("created_at > ?", 1.week.ago).order(created_at: :desc) } end # controller @users = User.includes(:reviews).map { |u| [u, u.reviews.select { |r| r.created_at > 1.week.ago }] }
find_each / in_batches em vez de .all.each
plaintext# Loads every row into memory before iterating at all: User.all.each { |u| u.update!(normalized_email: u.email.downcase) } # Loads and processes in batches of 1000 by default: User.find_each { |u| u.update!(normalized_email: u.email.downcase) } # Same batching, but yields an ActiveRecord::Relation per batch, good for update_all: User.in_batches { |batch| batch.update_all("normalized_email = LOWER(email)") }
.all.each materializa a tabela inteira como objetos Ruby antes de o bloco
sequer rodar uma vez. Em uma tabela de 100 mil linhas isso é a diferença
entre um script que roda em memória constante e um que incha para
centenas de megabytes antes de fazer qualquer trabalho.
select/pluck para evitar instanciar o que você não precisa
plaintextUser.select(:id, :email) # ActiveRecord objects, but only 2 attributes loaded User.pluck(:id, :email) # raw arrays, no ActiveRecord objects instantiated at all
pluck pula a instanciação de objeto ActiveRecord completamente, o que
importa quando a tabela é grande e o código só precisa dos valores crus
(construir um Hash, uma exportação CSV, um filtro where(id: ...) para
uma segunda query).
Trade-offs
select(:col1, :col2)significa que acessar qualquer outro atributo levantaActiveModel::MissingAttributeErrorem vez de retornarnilsilenciosamente; isso geralmente é uma vantagem (expõe um bug real exatamente no ponto de chamada), mas significa que você não pode selecionar parcialmente e esperar que o resto dos métodos do model continue funcionando.plaintextuser = User.select(:id).first user.email # ActiveModel::MissingAttributeError- Eager loading de associações aninhadas multiplica objetos
instanciados, não só queries:
includes(cars: { parts: :vendor })pode instanciarcars.length * parts.length * vendors.lengthobjetos para um único registro de nível superior. Dois ou três níveis de profundidade, fazer eager loading cegamente de tudo que um linter aponta pode ser mais lento do que o N+1 que pretendia corrigir; sempre meça em vez de fazer eager loading por reflexo. - Mover lógica de query dos métodos de instância para scopes é uma decisão de design com um custo real: significa que o controller (ou onde quer que o scope seja chamado) precisa saber mais sobre como montar os dados do que sabia quando o model expunha um único método conveniente; o ganho só aparece na escala de N chamadas, não em um script pontual.