Em resumo, um problema de consultas N+1 ocorre quando para cada linha retornada em uma consulta inicial (à um banco de dados sql por exemplo) sua aplicação necessita realizar uma segunda consulta para recuperar dados adicionais de uma outra tabela. Vamos ver quais são suas implicações e como evitar este problema...
Introdução
Vamos pensar numa aplicação hipotética de ecommerce, onde você precise listar todos os clientes (Customers) e seus respectivos pedidos (Orders). Uma implementação ingênua (tipicamente com um ORM) poderia sair assim:
SELECT [columns...] FROM "customers"
Por enquanto temos apenas uma consulta (esta consulta é o 1 do N+1) que retorna os Customers e então para obter os dados dos pedidos, serão necessárias N consultas adicionais, sendo N o numero de itens (Customers) retornados na primeira consulta.
-- Para cada customer
SELECT [columns...] FROM "orders" WHERE "orders"."customer_id" = 1
SELECT [columns...] FROM "orders" WHERE "orders"."customer_id" = 2
SELECT [columns...] FROM "orders" WHERE "orders"."customer_id" = 3
-- ... e assim por diante ...
- Se a primeira consulta retornar 100 customers, teremos ao todo 101 consultas
- Se a primeira consulta retornar 1000 customers, teremos ao todo 1001 consultas
E assim por diante...
Mas é claro que sempre pode piorar, se por exemplo precisarmos incluir os Items/Produtos de cada pedido nos resultados...
ORM: Lazy vs Eager
Uma causa comum para este problema é o fato de muitos ORMs usarem por default uma abordagem chamada de LAZY, onde a consulta retorna apenas a entidade diretamente consultada, sem incluir entidades relacionadas.
No mundo Java por exemplo a expecificação JPA define estes defaults:
Tipo de Relacionamento | Carregamento Padrão |
---|---|
@OneToOne | EAGER |
@ManyToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
No entanto você pode mudar este comportamento, fazendo com que ele adote a estratégia chamada EAGER, nesta abordagem a consulta sql gerada pode incluir um JOIN carregando os dados adicionas.
Exemplo de codigo Java:
// ... imports
@Entity
public class Customer {
@OneToMany(fetch = FetchType.EAGER)
private List<Order> orders;
//... restante do codigo
}
No nosso exemplo de Customers e Orders, uma configuração EAGER do ORM poderia resultar em uma unica consulta semelhante à esta:
SELECT [columns...] FROM "customers" as c
LEFT JOIN "orders" ON "orders"."costumer_id" = c.id
Sim, isto retornaria os dados que precisamos em uma unica consulta. No entanto é necessário tomar cuidado com esta abordagem, pois ela pode sair muito cara.
Configurar o ORM para ter esta abordagem EAGER pode fazer com que ele sempre retorne os dados completos realizando joins, mesmo em outras partes da aplicação onde não precisamos.
Neste caso é preferivel verificarmos se o ORM em questão dispõe de mecanismos que nos permitam ativar/desativar este comportamento EAGER sob demanda em cada consulta.
Ainda usando Java/JPA como exemplo, seria possível (sem adicionar @OneToMany(fetch = FetchType.EAGER) na classe entity) criar um método de consulta customizada no Repository, nesta consulta customizada você poderia usar JPQL com a opção FETCH para adicionar o comportamento EAGER apenas nesta consulta especifica, sem afetar as demais:
// imports
public interface CustomerRepository extends Repository<Customer, Long> {
@Query("select c FROM Customer c JOIN FETCH c.orders")
List<Customer> retrieveAllCustomerWithOrdersEager();
}
Uma abordagem simples (saindo um pouco do universo dos ORMs)
Uma abordagem talvez mais simples e que vai resolver muito bem este problema é realizar duas consultas como a seguir:
-- Ainda temos nossa consulta inicial que recupera os Customers
SELECT [columns...] FROM "customers"
-- E então realizamos uma segunda consula, passando os IDs obtidos na primeira,
-- que retornará todos os pedidos relacionados
SELECT [columns...] FROM "orders"
WHERE "orders"."customer_id" IN ( 1, 2, 3 /* ... cada customer_id */)
Com esta abordagem você terá em memória após o retorno das concultas, duas listas, uma de Customers e a outra de Orders. Provelmente você precisará realizar alguma ações adicionais como um "groupBy" que agrupe as Orders por Customer.id. Isto vai depender muito do que você pretende fazer com os dados.
Consultas em API's
Você já deve estar pensando que isto poderia ocorrer também com APIs. Sim, realmente, apesar deste post ter focado mais em consultas em banco de dados. O mesmo comportamento poderia ocorrer se sua aplicação consulta os dados de APIs com uma modelagem pouco otimizada.
Existem também estratégias para resolver este tipo de problema em consultas de APIs, mas este tema ficará para outro artigo.
Concluindo
Existem muitas formas avançadas de otimização, mas antes de pensar em qualquer coisa mais extrema é essencial verificarmos se não estamos caindo em erros primários como consultas N+1.
Aqui me concentrei em consultas à base de dados relacionais, mas é valido pensarmos que os mesmos problemas podem se aplicar à cenários de APIs distribuidas por exemplo, como microsserviços onde precisamos chamar APIs secundárias para "enriquecer" os dados de nosso objeto de retorno.
Por hoje é isto pessoal...
abs!
Artus