Estratégias de detecção e correção de queries N+1 em ORMs
1. Fundamentos do Problema N+1 em ORMs
O antipadrão N+1 é um dos problemas de performance mais comuns em aplicações que utilizam Object-Relational Mapping (ORM). Ele ocorre quando o sistema executa 1 consulta principal para recuperar uma lista de registros e, em seguida, N consultas adicionais para carregar dados relacionados de cada registro individualmente.
Exemplo clássico em Entity Framework (C#):
// Problema N+1
var pedidos = context.Pedidos.ToList(); // 1 consulta
foreach (var pedido in pedidos)
{
Console.WriteLine(pedido.Cliente.Nome); // N consultas (uma por pedido)
}
No Hibernate (Java):
List<Pedido> pedidos = session.createQuery("FROM Pedido").list(); // 1 consulta
for (Pedido p : pedidos) {
System.out.println(p.getCliente().getNome()); // N consultas
}
Impactos no desempenho:
- Latência cresce linearmente com o número de registros
- Carga excessiva no banco de dados (múltiplas conexões e queries)
- Degradação da escalabilidade da aplicação
- Aumento no tempo de resposta para o usuário final
2. Técnicas de Detecção Manual e Automática
Análise de logs de queries:
-- PostgreSQL com pg_stat_statements
SELECT query, calls, total_time, rows
FROM pg_stat_statements
WHERE query LIKE '%SELECT%FROM%pedido%'
ORDER BY calls DESC;
Ferramentas de detecção em tempo real:
Bullet (Ruby on Rails):
# Gemfile
gem 'bullet', group: [:development, :test]
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.console = true
end
N+1 Detector (Django):
# settings.py
INSTALLED_APPS = [
'nplusone.ext.django',
]
NPLUSONE_LOGGER = logging.getLogger('nplusone')
NPLUSONE_LOG_LEVEL = logging.WARN
APM em produção (New Relic):
# Configuração de alerta para N+1 no New Relic
SELECT count(*) FROM Transaction
WHERE transactionType = 'Web'
AND databaseCallCount > 20
SINCE 1 hour ago
3. Estratégias de Correção com Eager Loading
Entity Framework - Include:
// Correção com eager loading
var pedidos = context.Pedidos
.Include(p => p.Cliente)
.Include(p => p.Itens)
.ThenInclude(i => i.Produto)
.ToList();
foreach (var pedido in pedidos)
{
Console.WriteLine($"{pedido.Cliente.Nome} - {pedido.Itens.Count} itens");
}
// Apenas 1 consulta com JOINs
Hibernate - JOIN FETCH:
List<Pedido> pedidos = session.createQuery(
"SELECT p FROM Pedido p " +
"JOIN FETCH p.cliente " +
"JOIN FETCH p.itens i " +
"JOIN FETCH i.produto"
).list();
ActiveRecord - includes:
# Ruby on Rails
pedidos = Pedido.includes(:cliente, :itens => :produto).all
pedidos.each do |pedido|
puts "#{pedido.cliente.nome} - #{pedido.itens.size} itens"
end
Cuidados com explosão cartesiana:
// Problema: multiplicação de linhas com múltiplos JOINs
var dados = context.Pedidos
.Include(p => p.Itens) // 1:N
.Include(p => p.Pagamentos) // 1:N
.ToList();
// Resultado: pedido * itens * pagamentos linhas
// Solução: usar Select para projetar apenas dados necessários
var dados = context.Pedidos
.Select(p => new {
Pedido = p,
TotalItens = p.Itens.Sum(i => i.Quantidade),
TotalPago = p.Pagamentos.Sum(pg => pg.Valor)
})
.ToList();
4. Batch Loading e Lazy Loading Controlado
Batch loading manual com IN queries:
// Primeira consulta: buscar pedidos
var pedidos = context.Pedidos.ToList();
var clienteIds = pedidos.Select(p => p.ClienteId).Distinct();
// Segunda consulta: buscar todos os clientes de uma vez
var clientes = context.Clientes
.Where(c => clienteIds.Contains(c.Id))
.ToDictionary(c => c.Id);
// Associar manualmente
foreach (var pedido in pedidos)
{
pedido.Cliente = clientes[pedido.ClienteId];
}
DataLoader (GraphQL/Node.js):
const DataLoader = require('dataloader');
const clienteLoader = new DataLoader(async (ids) => {
const clientes = await db.Clientes.findAll({
where: { id: ids }
});
return ids.map(id => clientes.find(c => c.id === id));
});
// Uso
const pedido = await db.Pedidos.findByPk(1);
const cliente = await clienteLoader.load(pedido.ClienteId);
Configuração de thresholds de lazy loading:
# Hibernate configuration
hibernate.default_batch_fetch_size=10
hibernate.max_fetch_depth=3
# Entity Framework Core
optionsBuilder.UseSqlServer(
connectionString,
options => options.MaxBatchSize(100)
);
5. Correção com Views Materializadas e Desnormalização
Views materializadas (PostgreSQL):
-- Criar view materializada
CREATE MATERIALIZED VIEW resumo_pedidos AS
SELECT
p.id,
p.data,
c.nome AS cliente_nome,
SUM(ip.quantidade * ip.preco_unitario) AS valor_total,
COUNT(ip.id) AS quantidade_itens
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
JOIN itens_pedido ip ON ip.pedido_id = p.id
GROUP BY p.id, c.nome;
-- Atualizar periodicamente
REFRESH MATERIALIZED VIEW resumo_pedidos;
Desnormalização controlada:
-- Tabela desnormalizada para consultas frequentes
CREATE TABLE pedidos_resumo (
pedido_id INT PRIMARY KEY,
cliente_nome VARCHAR(200),
valor_total DECIMAL(10,2),
quantidade_itens INT,
ultimo_pagamento_date TIMESTAMP
);
-- Trigger para manter sincronizado
CREATE OR REPLACE FUNCTION atualizar_resumo_pedido()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO pedidos_resumo (pedido_id, cliente_nome, valor_total, quantidade_itens)
SELECT
p.id,
c.nome,
COALESCE(SUM(ip.quantidade * ip.preco_unitario), 0),
COUNT(ip.id)
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
LEFT JOIN itens_pedido ip ON ip.pedido_id = p.id
WHERE p.id = NEW.id
GROUP BY p.id, c.nome
ON CONFLICT (pedido_id) DO UPDATE
SET valor_total = EXCLUDED.valor_total,
quantidade_itens = EXCLUDED.quantidade_itens;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Cache em Redis:
# Redis para cache de agregados
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0)
def get_resumo_pedido(pedido_id):
cache_key = f"resumo:pedido:{pedido_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Consulta otimizada (sem N+1)
resumo = db.query("""
SELECT p.id, c.nome as cliente_nome,
SUM(ip.quantidade * ip.preco_unitario) as valor_total
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
LEFT JOIN itens_pedido ip ON ip.pedido_id = p.id
WHERE p.id = :id
GROUP BY p.id, c.nome
""", {"id": pedido_id}).fetchone()
# Cache por 5 minutos
r.setex(cache_key, 300, json.dumps(dict(resumo)))
return resumo
6. Padrões de Design e Boas Práticas em ORMs
Repositórios com consultas otimizadas:
public interface IPedidoRepository
{
Task<List<Pedido>> GetPedidosCompletosAsync();
Task<Pedido> GetPedidoDetalhadoAsync(int id);
}
public class PedidoRepository : IPedidoRepository
{
private readonly AppDbContext _context;
public async Task<List<Pedido>> GetPedidosCompletosAsync()
{
return await _context.Pedidos
.Include(p => p.Cliente)
.Include(p => p.Itens)
.ThenInclude(i => i.Produto)
.AsNoTracking()
.ToListAsync();
}
}
CQRS para separação de leitura/escrita:
// Query para leitura (sem lazy loading)
public class PedidoQueryHandler : IRequestHandler<GetPedidosQuery, List<PedidoDto>>
{
private readonly AppDbContext _context;
public async Task<List<PedidoDto>> Handle(GetPedidosQuery request, CancellationToken cancellationToken)
{
return await _context.Pedidos
.Select(p => new PedidoDto
{
Id = p.Id,
ClienteNome = p.Cliente.Nome,
Total = p.Itens.Sum(i => i.Quantidade * i.PrecoUnitario)
})
.ToListAsync();
}
}
// Command para escrita (com lazy loading controlado)
public class CriarPedidoCommandHandler : IRequestHandler<CriarPedidoCommand, int>
{
private readonly AppDbContext _context;
public async Task<int> Handle(CriarPedidoCommand request, CancellationToken cancellationToken)
{
var cliente = await _context.Clientes.FindAsync(request.ClienteId);
var pedido = new Pedido { Cliente = cliente };
_context.Pedidos.Add(pedido);
await _context.SaveChangesAsync();
return pedido.Id;
}
}
Interceptadores de queries:
public class NPlusOneInterceptor : DbCommandInterceptor
{
private int _queryCount = 0;
private const int MAX_QUERIES = 10;
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
_queryCount++;
if (_queryCount > MAX_QUERIES)
{
Console.WriteLine($"ALERTA: Mais de {MAX_QUERIES} queries executadas!");
Console.WriteLine($"Última query: {command.CommandText}");
}
return result;
}
}
7. Monitoramento Contínuo e Prevenção em CI/CD
Testes de performance com RSpec + Bullet:
# spec/rails_helper.rb
RSpec.configure do |config|
config.before(:each) do
Bullet.start_request
end
config.after(:each) do
Bullet.perform_out_of_channel_notifications
Bullet.end_request
end
end
# spec/models/pedido_spec.rb
RSpec.describe Pedido, type: :model do
it "não deve ter N+1 queries" do
pedidos = create_list(:pedido, 10, :com_itens)
expect {
Pedido.includes(:itens).each do |pedido|
pedido.itens.count
end
}.to_not make_database_queries(count: 20)
end
end
Alertas automáticos em produção:
# Datadog monitor para N+1
{
"name": "N+1 Query Detection",
"type": "query alert",
"query": "avg:system.database.queries.per_second{service:api} > 50",
"message": "Possível padrão N+1 detectado! Verifique logs de queries.",
"tags": ["team:backend", "priority:high"],
"options": {
"thresholds": {
"critical": 50,
"warning": 30
}
}
}
Linters específicos para ORM:
# .rubocop.yml
NPlusOneQueries/ActiveRecord:
Enabled: true
Include:
- 'app/**/*.rb'
Exclude:
- 'db/**/*.rb'
# ESLint plugin para Sequelize
// .eslintrc.js
module.exports = {
plugins: ['sequelize'],
rules: {
'sequelize/no-n-plus-one-queries': 'error'
}
};
Pipeline CI/CD com detecção de N+1:
# .github/workflows/performance.yml
name: Performance Tests
on: [pull_request]
jobs:
detect-n-plus-one:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run N+1 detection tests
run: |
bundle exec rspec --tag performance
- name: Check query count
run: |
MAX_QUERIES=20
QUERY_COUNT=$(cat log/test.log | grep "SELECT" | wc -l)
if [ $QUERY_COUNT -gt $MAX_QUERIES ]; then
echo "Falha: $QUERY_COUNT queries detectadas (limite: $MAX_QUERIES)"
exit 1
fi
Referências
- Documentação Oficial do Entity Framework Core - Carregamento de Dados Relacionados — Guia completo sobre eager loading, lazy loading e explícito no EF Core
- Hibernate Performance Tuning - Fetching Strategies — Documentação oficial sobre estratégias de fetching no Hibernate
- Bullet Gem - Detecção de N+1 em Rails — Biblioteca para detectar e alertar sobre N+1 queries em aplicações Ruby on Rails
- DataLoader - Batching e Caching em GraphQL — Biblioteca para agrupar requisições e evitar N+1 em APIs GraphQL
- PostgreSQL Materialized Views Documentation — Documentação oficial sobre criação e manutenção de views materializadas no PostgreSQL
- New Relic - Monitoramento de Performance de Banco de Dados — Guia para identificar padrões N+1 com ferramentas de APM
- RSpec + Bullet - Testes de Performance — Configuração de testes automatizados para detectar N+1 em pipelines CI/CD