Pular para o conteúdo
blog

O problema N+1 no JPA/Hibernate: por que sua tela lenta faz 101 consultas

Uma lista de 100 pedidos que dispara 101 SELECTs no banco. Entenda de onde vem o problema N+1, como enxergá-lo no log e como resolver com JOIN FETCH, EntityGraph, batch fetching e projeções.

Prof. José Rui

5 min de leitura

Neste artigo
  1. O cenário
  2. Onde nascem as N consultas
  3. ”Então é só trocar para EAGER?”
  4. Como enxergar o problema
  5. Solução 1: JOIN FETCH
  6. Solução 2: @EntityGraph
  7. Solução 3: batch fetching
  8. Solução 4: buscar só o que a tela mostra
  9. Qual escolher?
  10. Conclusão

Quase todo mundo que trabalha com Java e banco de dados já viveu esta cena:

“A tela funciona perfeitamente no meu computador. Em produção ela leva quinze segundos para abrir.”

No computador do desenvolvedor, o banco tem cinco pedidos de teste. Em produção, tem milhares. E a tela, que lista os pedidos com a quantidade de itens de cada um, está disparando uma consulta para buscar os pedidos e mais uma consulta para cada pedido, só para contar os itens.

Esse é o problema N+1, provavelmente o erro de desempenho mais comum em aplicações que usam JPA/Hibernate. E o mais traiçoeiro é que o código que o causa parece perfeitamente inocente.

O cenário

Imagine duas entidades bem comuns:

@Entity
public class Pedido {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Cliente cliente;

    @OneToMany(mappedBy = "pedido")
    private List<ItemPedido> itens = new ArrayList<>();

    // getters...
}

@Entity
public class ItemPedido {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Pedido pedido;

    private String produto;
    private int quantidade;
}

Por padrão, uma coleção @OneToMany é LAZY: quando você carrega um pedido, os itens não vêm junto. O Hibernate coloca no lugar da lista um objeto “procurador” (um proxy), que só vai ao banco quando alguém de fato usar a lista.

Isso é ótimo. Imagine carregar todos os itens de todos os pedidos só para mostrar o número do pedido.

Onde nascem as N consultas

Agora o código da tela:

List<Pedido> pedidos = em.createQuery("select p from Pedido p", Pedido.class)
                         .getResultList();

for (Pedido p : pedidos) {
    System.out.println(p.getId() + ": " + p.getItens().size() + " itens");
}

Parece uma consulta só, certo? Ligue o log de SQL e veja o que realmente acontece:

select p.id, p.cliente_id from pedido p;              -- 1 consulta

select i.* from item_pedido i where i.pedido_id = 1;  -- pedido 1
select i.* from item_pedido i where i.pedido_id = 2;  -- pedido 2
select i.* from item_pedido i where i.pedido_id = 3;  -- pedido 3
-- ... uma para cada pedido

Cada p.getItens().size() acorda o proxy daquele pedido, e o proxy faz a sua própria consulta. Uma consulta para a lista + N consultas para os itens: N+1. Com 100 pedidos, 101 idas ao banco. Com 10 mil, 10.001.

E não é o banco que é lento. Cada consulta individual é rápida. O que mata é a soma das viagens: cada ida e volta pela rede custa tempo, e esse tempo é multiplicado por N.

”Então é só trocar para EAGER?”

Essa é a primeira ideia de quase todo mundo, e não resolve. FetchType.EAGER só diz que os dados devem vir junto, não diz como. Numa consulta JPQL como a acima, o Hibernate primeiro executa o seu select e depois sai buscando o que é EAGER, muitas vezes com uma consulta por registro. Você troca o N+1 que aparecia no laço por um N+1 que acontece sempre, até nas telas que nem precisam dos itens.

Inclusive, @ManyToOne é EAGER por padrão no JPA. É por isso que, no exemplo, eu marquei fetch = FetchType.LAZY explicitamente no cliente. Sem isso, a lista de pedidos já sairia fazendo uma consulta por cliente.

Como enxergar o problema

O N+1 é invisível no código e gritante no log. Em desenvolvimento, deixe sempre o SQL visível. Com Spring Boot:

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

A regra de bolso que eu passo para os alunos: abriu uma tela e o log rolou uma cascata de SELECTs quase iguais? É N+1. Para uma medição mais objetiva, a propriedade hibernate.generate_statistics=true mostra quantas consultas cada operação executou.

Solução 1: JOIN FETCH

A solução mais direta é dizer ao Hibernate, na própria consulta, o que você vai usar:

List<Pedido> pedidos = em.createQuery(
        "select distinct p from Pedido p left join fetch p.itens", Pedido.class)
    .getResultList();

O join fetch traz os pedidos e os itens numa consulta só, com um JOIN no banco, e já preenche as listas. O distinct evita pedidos repetidos na lista (o JOIN devolve uma linha por item). No Hibernate 6 essa duplicação já é removida automaticamente, mas o distinct não atrapalha e deixa a intenção clara.

Cuidado com a paginação. join fetch em coleção com setFirstResult/setMaxResults não pode ser paginado pelo banco, porque cada pedido ocupa várias linhas. O Hibernate então traz tudo e pagina em memória, e avisa isso no log. Se a tela é paginada, prefira as soluções 3 ou 4.

Solução 2: @EntityGraph

Se você usa Spring Data JPA e não quer escrever JPQL, o @EntityGraph faz o mesmo papel de forma declarativa:

public interface PedidoRepository extends JpaRepository<Pedido, Long> {

    @EntityGraph(attributePaths = "itens")
    List<Pedido> findAll();
}

Repare na vantagem: o mapeamento continua LAZY, e cada consulta decide o que precisa trazer junto. A tela de lista pede os itens; a tela que só mostra o número do pedido, não.

Solução 3: batch fetching

Às vezes você não controla a consulta inicial, ou precisa paginar. Aí entra o carregamento em lote:

@OneToMany(mappedBy = "pedido")
@BatchSize(size = 50)
private List<ItemPedido> itens = new ArrayList<>();

O Hibernate continua carregando os itens sob demanda, mas, quando o primeiro proxy acorda, ele aproveita a viagem e busca os itens de até 50 pedidos de uma vez, com um where pedido_id in (...). Os 100 pedidos passam a custar 1 + 2 consultas em vez de 101.

Também dá para ligar isso para o projeto inteiro com a propriedade hibernate.default_batch_fetch_size. É uma rede de proteção muito boa para sistemas que já nasceram com N+1 espalhado.

Solução 4: buscar só o que a tela mostra

A tela do nosso exemplo não precisa dos itens. Precisa da quantidade de itens. Para isso, carregar entidades é desperdício: uma projeção resolve tudo no banco.

public record ResumoPedido(Long id, long quantidadeItens) {}

List<ResumoPedido> resumo = em.createQuery("""
        select new com.loja.ResumoPedido(p.id, count(i))
        from Pedido p left join p.itens i
        group by p.id
        """, ResumoPedido.class)
    .getResultList();

Uma consulta, uma linha por pedido, nenhum proxy, nenhuma entidade gerenciada. E paginável sem restrição. Para telas de listagem e relatórios, costuma ser a solução mais rápida de todas.

Qual escolher?

SituaçãoMelhor caminho
Tela usa a entidade e as coleções, sem paginaçãoJOIN FETCH ou @EntityGraph
Spring Data, sem querer escrever JPQL@EntityGraph
Consulta paginada que precisa das coleções@BatchSize / default_batch_fetch_size
Listagem ou relatório que mostra só alguns camposProjeção (DTO / record)

E uma regra que vale para todos os casos: mapeie tudo como LAZY e busque explicitamente o que cada tela usa. O mapeamento descreve o domínio; a consulta descreve a necessidade da tela. Misturar as duas coisas, colocando EAGER no mapeamento para “resolver” uma tela, é o caminho mais curto para um sistema lento por inteiro.

Conclusão

O N+1 não é defeito do Hibernate. É o resultado de usar uma ferramenta poderosa sem entender o que ela faz por baixo. O ORM esconde o SQL para você escrever menos código, não para você parar de pensar no banco.

  • LAZY é o padrão saudável, mas cada acesso a um proxy pode ser uma ida ao banco;
  • EAGER não resolve: só muda o lugar do problema;
  • JOIN FETCH, @EntityGraph, @BatchSize e projeções resolvem, cada um no seu cenário;
  • e o log de SQL ligado em desenvolvimento é o seu melhor amigo.

Esse tipo de decisão é o que separa quem “usa JPA” de quem domina persistência de verdade. No Intensivão JPA/Hibernate eu cubro o ciclo de vida do EntityManager, as estratégias de carregamento e as consultas eficientes com esse mesmo cuidado. Mas, com ou sem curso, leve uma coisa daqui: programar é um ato de pensar, inclusive no que acontece do outro lado da conexão com o banco.

# continue lendo

Mais artigos para você