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