O problema das consultas N+1 é um gargalo comum em aplicações GraphQL. Ele surge quando a lógica do seu resolvedor executa múltiplas consultas de banco de dados para retornar um único resultado para o cliente. Um cenário típico envolve a recuperação de uma lista de itens e, em seguida, para cada item, a execução de outra consulta para buscar dados relacionados.
Por exemplo, ao consultar usuários e suas postagens associadas:
query {
users {
id
name
posts {
id
title
}
}
}
Se houver 10 usuários, cada um com 5 postagens, uma abordagem ingênua resultaria em 1 consulta para buscar os usuários e, em seguida, 10 consultas adicionais para buscar as postagens de cada usuário, totalizando 11 consultas. Com relacionamentos mais profundos, o número de consultas pode crescer exponencialmente.
Introduzindo DataLoader para Carregamento em Lote
DataLoader é uma biblioteca genérica para o carregamento de dados em lote e com chave. Ele funciona agrupando múltiplas solicitações de busca de dados que ocorrem dentro do mesmo ciclo de tick do evento (ou tick de evento simulado) e transformando-as em um único pedido de lote otimizado. Ao integrar DataLoader com Absinthe, você pode:
- Agrupar consultas de banco de dados relacionadas.
- Implementar um cache de nível de solicitação para evitar buscas duplicadas.
- Simplificar a lógica do resolvedor, mantendo o código limpo.
- Melhorar significativamente o desempenho da sua API GraphQL.
Configuração no Absinthe
1. Adicionar Dependência
Primeiro, adicione a dependência do DataLoader ao seu arquivo mix.exs:
def deps do
[
{:absinthe, "~> 1.7"},
{:dataloader, "~> 1.0"}
# Outras dependências...
]
end
2. Configurar o Contexto do Schema
No seu módulo de schema GraphQL, configure o contexto para incluir uma instância do DataLoader:
defmodule MyApp.Schema do
use Absinthe.Schema
import Absinthe.Resolution.Helpers
alias MyApp.Repo # Assumindo que você usa Ecto
alias MyApp.Blog # Módulo hipotético para busca de posts
alias MyApp.Accounts # Módulo hipotético para busca de usuários
def context(ctx) do
loader =
Dataloader.new()
|> Dataloader.add_source(Blog, Dataloader.Ecto.new(MyApp.Repo, query: &MyApp.Blog.query/2))
|> Dataloader.add_source(Accounts, Dataloader.Ecto.new(MyApp.Repo, query: &MyApp.Accounts.query/2))
# Adicione outras fontes conforme necessário
Map.put(ctx, :loader, loader)
end
def plugins do
[Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults()
end
end
A linha Absinthe.Middleware.Dataloader nos plugins garante que o middleware DataLoader seja executado durante o processamento da solicitação.
Implementando Fontes de Dados
O DataLoader requer "fontes" que definem como buscar dados para um determinado tipo. Essas fontes são mapeadas para chaves no seu contexto DataLoader.
Fonte Ecto de Exemplo
Para buscar dados do Ecto, você pode criar uma função de consulta que o DataLoader chamará:
defmodule MyApp.Blog do
import Ecto.Query
# Função principal para consulta, pode ser genérica ou específica
def query(queryable, _params) do
queryable
end
# Consulta específica para postagens de um determinado usuário
def query(Post, %{user_id: user_id}) do
from p in Post, where: p.user_id == ^user_id
end
# Consulta específica para postagens publicadas
def query(Post, %{published: true}) do
from p in Post, where: p.published == true
end
end
Fonte KV (Key-Value) de Exemplo
Para fontes de dados que não são de banco de dados, como APIs externas ou caches em memória, você pode usar Dataloader.KV:
defmodule MyApp.ExternalAPI do
@data %{
1 => %{id: 1, name: "Resource A"},
2 => %{id: 2, name: "Resource B"}
}
def data do
Dataloader.KV.new(&fetch_resources/2)
end
# O DataLoader chamará esta função com uma lista de chaves
def fetch_resources(:resources, keys) do
Enum.flat_map(keys, fn key ->
case Map.lookup(key, @data) do
nil -> [] # Retorna uma lista vazia se a chave não for encontrada
value -> [value]
end
end)
end
end
Note que a função de fetch para Dataloader.KV deve retornar uma lista de resultados, correspondendo às chaves fornecidas.
Integração com Resolvedores Absinthe
Agora, use a função dataloader em seus resolvedores para buscar dados através do DataLoader:
object :user do
field :id, :id
field :name, :string
# Busca as postagens do usuário usando a fonte 'Blog'
field :posts, list_of(:post) do
resolve dataloader(Blog, key: :user_id) # Passa o ID do pai como chave
end
# Busca o perfil do usuário usando a fonte 'Accounts'
field :profile, :profile do
resolve dataloader(Accounts, key: :profile_id) # Passa o ID do perfil como chave
end
end
No exemplo acima, key: :user_id informa ao DataLoader para usar o campo id do objeto pai (o usuário) como a chave para buscar as postagens relacionadas na fonte Blog.
Configurações Avançadas do DataLoader
A função dataloader suporta opções adicionais:
args: %{...}: Passe argumentos adicionais para a função de consulta da fonte.use_parent: false: Não passe o objeto pai como parte dos argumentos de busca.callback: fn(...) -> ... end: Execute uma função após os dados serem carregados para processamento adicinoal ou para calcular um valor derivado.on_error: {:ok, []}: Especifique um valor de fallback caso ocorra um erro durante o carregamento.timeout: 5000: Defina um tempo limite em milissegundos para a operação de carregamento.
field :recent_posts, list_of(:post) do
resolve dataloader(Blog,
args: %{published: true, limit: 10}, # Filtra postagens publicadas e limita o resultado
use_parent: false # Se a consulta for independente do pai
)
end
field :post_count, :integer do
resolve dataloader(Blog,
# Calcula o número de posts após o carregamento
callback: fn posts, _parent, _args ->
{:ok, length(posts)}
end
)
end
Estratégias de Otimização de Desempenho
1. Análise de Complexidade de Consultas
Absinthe oferece um middleware de análise de complexidade para prevenir consultas excessivamente profundas ou caras. Você pode configurá-lo no seu plugins ou middleware do schema.
def middleware(middleware, _field, _object) do
[Absinthe.Middleware.Complexity] ++ middleware
end
2. Estratégias de Cache
DataLoader fornece cache no nível da solicitação por padrão. Para cache entre solicitações ou em nível de aplicação, consideer:
- Cache de Processo (Process-local Cache): Use um cache gerenciado pelo processo do servidor.
- Cache Distribuído: Integre com soluções como Redis ou Memcached para cache compartilhado entre instâncias de aplicação.
Estudo de Caso: Otimizando um Sistema de Blog
Considere um resolvedor para obter postagens de um usuário:
# Antes do DataLoader (N+1)
field :posts, list_of(:post) do
resolve fn parent, _, _ ->
{:ok, Repo.all(from p in Post, where: p.user_id == ^parent.id)}
end
end
# Após o DataLoader (Otimizado)
field :posts, list_of(:post) do
resolve dataloader(Blog, key: :user_id)
end
Os resultados de testes de desempenho podem mostrar:
| Número de Usuários | Abordagem Tradicional (ms) | Com DataLoader (ms) | Melhoria |
|---|---|---|---|
| 10 | 120 | 45 | 62.5% |
| 50 | 580 | 85 | 85.3% |
| 100 | 1250 | 120 | 90.4% |
Técnicas Avançadas e Melhores Práticas
- Funções de Lote Personalizadas: Para lógicas de busca complexas, você pode definir funções de lote personalizadas em vez de depender apenas de consultas Ecto ou KV simples.
- Tratamento de Erros: Use a opção
on_errorpara fornecer um comportamento robusto quando o carregamento de dados falhar. - Monitoramento e Depuração: Utilize ferramentas de telemetria (como
:telemetryem Elixir) para monitorar o desempenho do DataLoader e identificar gargalos.
Solução de Problemas Comuns
- Dependências Circulares: O DataLoader pode ajudar a gerenciar dependências complexas, mas evite criar ciclos infinitos onde o carregamento de um item requer o carregamento de outro que, por sua vez, requer o primeiro.
- Consultas de Relacionamento Complexas: Para relacionamentos com múltiplos critérios de filtro, combine
argse a lógica do resolvedor de fonte apropriada. - Consultas Inter-Fontes: Ao buscar dados que exigem informações de várias fontes, encadeie chamadas
loaddo DataLoader dentro de um resolvedor principle.
A combinação de Absinthe com DataLoader oferece uma solução poderosa para otimizar o desempenho de APIs GraphQL em Elixir, especialmente em cenários com relacionamentos de dados complexos e listas extensas.