|

O Problema N+1 Query: Por que Sua Aplicação Java Está Mais Lenta do Que Deveria

Se você já habilitou o log de SQL do Hibernate e viu algo assim no console:

Hibernate: select * from post
Hibernate: select * from user where id=1
Hibernate: select * from user where id=2
Hibernate: select * from user where id=3
...

…repetindo dezenas ou centenas de vezes, você encontrou o N+1 query problem. É um dos bugs de performance mais silenciosos e devastadores que existem — porque a aplicação continua funcionando, as respostas chegam corretas, mas debaixo do capô o banco de dados está sendo espancado.

Este artigo explica o problema de forma profunda: o que é, por que acontece especificamente no ecossistema Java/Hibernate, como detectar, como resolver com as ferramentas certas e quanto você ganha ao resolver.


O que é o Problema N+1

O nome é direto: para carregar N registros, sua aplicação executa N+1 queries. Uma query para buscar a lista principal, e depois uma query individual para cada item dessa lista.

Imagine um sistema com posts de blog. Você quer listar os posts junto com o nome do autor de cada um. Com Hibernate, o código ingênuo faz isso:

java

// PostService.java
List<Post> posts = entityManager
    .createQuery("SELECT p FROM Post p", Post.class)
    .getResultList(); // Query 1: SELECT * FROM post

for (Post post : posts) {
    System.out.println(post.getAuthor().getName()); // Query N: SELECT * FROM user WHERE id = ?
}

Se você tem 100 posts, isso resulta em 101 queries: 1 para buscar os posts, e 1 para cada autor. Se você tem 1.000 posts, são 1.001 queries. O custo cresce linearmente com os dados — uma bomba-relógio embutida no código.

O problema é fundamentalmente um mismatch de abstração: o Hibernate foi construído para mapear objetos a registros individuais, mas bancos de dados foram construídos para operar em conjuntos. Quando você força um banco de dados a trabalhar como uma API de chave-valor, você desperdiça tudo o que ele tem de mais poderoso.


A Raiz do Problema: FetchType.LAZY

No ecossistema Java/JPA, o N+1 tem uma causa principal: o carregamento lazy de associações, configurado via FetchType.LAZY. É o padrão para @OneToMany e @ManyToMany — e por boas razões. Mas mal compreendido, ele é a origem da maioria dos N+1 que você vai encontrar em produção.

Como o Lazy Loading Funciona

Quando você configura uma relação como LAZY, o Hibernate não carrega os dados da relação imediatamente. Ele cria um proxy — um objeto substituto que parece a entidade real, mas só executa a query quando você de fato acessar seus dados.

java

@Entity
public class Post {

    @Id
    @GeneratedValue
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY) // proxy, não carrega ainda
    @JoinColumn(name = "author_id")
    private User author;

    // getters e setters...
}

O problema acontece quando você itera sobre uma lista e acessa a relação lazy dentro do loop. Cada acesso dispara uma query separada:

java

// Cada chamada a getAuthor().getName() acessa o proxy,
// que dispara SELECT * FROM user WHERE id = ?
for (Post post : posts) {
    String name = post.getAuthor().getName(); // QUERY AQUI
}

O FetchType.EAGER “Resolve” o N+1… e Cria Outro Problema

A reação imediata de muitos desenvolvedores é mudar para FetchType.EAGER:

java

@ManyToOne(fetch = FetchType.EAGER) // Tenta "resolver" o N+1
private User author;

Isso parece resolver, mas na prática troca um problema por outro. Com EAGER, o Hibernate carrega o autor junto com cada post — mas faz isso com N queries individuais quando você usa JPQL ou Criteria API sem especificar JOIN FETCH. Você continua com N+1. Além disso, EAGER é global: toda vez que você buscar um Post, mesmo quando não precisa do autor, o Hibernate vai buscá-lo. Isso introduz over-fetching permanente.

A regra prática: nunca mude FetchType para resolver N+1. Resolva na query.


Anatomia do N+1 em Java: Cenários Reais

Cenário 1: @OneToMany clássico

O cenário mais comum — um autor com múltiplos posts:

java

@Entity
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
    private List<Post> posts = new ArrayList<>();
}

java

// N+1: 1 query para usuários + N queries para posts de cada usuário
List<User> users = entityManager
    .createQuery("SELECT u FROM User u", User.class)
    .getResultList();

for (User user : users) {
    // Acessa a coleção lazy -> query por usuário
    System.out.println(user.getPosts().size());
}

SQL gerado pelo Hibernate:

sql

-- Query 1
SELECT u.id, u.name FROM user u

-- Queries 2..N+1 (uma por usuário)
SELECT p.id, p.title, p.author_id FROM post p WHERE p.author_id = 1
SELECT p.id, p.title, p.author_id FROM post p WHERE p.author_id = 2
SELECT p.id, p.title, p.author_id FROM post p WHERE p.author_id = 3
-- ...

Cenário 2: Cadeia de Lazy Loading (Nested N+1)

O N+1 pode se multiplicar em profundidade. Posts têm comentários, comentários têm autores:

java

List<Post> posts = entityManager
    .createQuery("SELECT p FROM Post p", Post.class)
    .getResultList();

for (Post post : posts) {
    for (Comment comment : post.getComments()) { // N+1 aqui
        System.out.println(comment.getAuthor().getName()); // N+1 aqui também
    }
}

Com 50 posts e 10 comentários cada, você facilmente chega a 551 queries onde poderia ser 3.

Cenário 3: N+1 em Spring Data JPA

O Spring Data JPA não imuniza você contra N+1. Ao contrário, a conveniência dos repositories pode esconder o problema ainda mais:

java

// PostRepository.java
public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findByStatus(String status); // Retorna lazy proxies
}

// PostService.java
List<Post> posts = postRepository.findByStatus("PUBLISHED");

for (Post post : posts) {
    // Spring não sabe que você vai acessar .getAuthor() aqui
    emailService.notify(post.getAuthor().getEmail()); // N+1
}

Cenário 4: N+1 no Mapeamento de DTOs

Um padrão muito comum em APIs REST — mapear entidades para DTOs dentro de um stream:

java

// Parece inofensivo, mas é um N+1 disfarçado
List<PostDTO> dtos = postRepository.findAll()
    .stream()
    .map(post -> new PostDTO(
        post.getId(),
        post.getTitle(),
        post.getAuthor().getName() // lazy access = query por post
    ))
    .collect(Collectors.toList());

As Soluções

1. JOIN FETCH no JPQL

A solução mais direta no mundo Hibernate é usar JOIN FETCH na query JPQL. Isso instrui o Hibernate a usar um SQL JOIN e carregar a relação junto com a entidade principal — tudo em uma única query.

java

// Antes: N+1 queries
List<Post> posts = entityManager
    .createQuery("SELECT p FROM Post p", Post.class)
    .getResultList();

// Depois: 1 query com JOIN
List<Post> posts = entityManager
    .createQuery(
        "SELECT p FROM Post p JOIN FETCH p.author",
        Post.class
    )
    .getResultList();

SQL gerado:

sql

SELECT p.id, p.title, p.author_id, u.id, u.name
FROM post p
INNER JOIN user u ON u.id = p.author_id

Para coleções (@OneToMany), o JOIN FETCH também funciona, mas exige atenção com duplicatas:

java

// DISTINCT instrui o Hibernate a desduplicar em memória
List<User> users = entityManager
    .createQuery(
        "SELECT DISTINCT u FROM User u JOIN FETCH u.posts",
        User.class
    )
    .getResultList();

Limitação importante com paginação: você não pode combinar JOIN FETCH em coleções com setMaxResults(). O Hibernate buscará todos os dados em memória e paginará depois — o que é perigoso em tabelas grandes. Para esse cenário, use @BatchSize (explicado a seguir).

java

// CUIDADO: Hibernate loga warning e pagina em memória
List<User> users = entityManager
    .createQuery("SELECT u FROM User u JOIN FETCH u.posts", User.class)
    .setMaxResults(20) // Perigoso com coleções!
    .getResultList();

2. @BatchSize

O @BatchSize é uma solução Hibernate-específica que muda a estratégia de carregamento lazy: em vez de uma query por entidade, o Hibernate emite queries em lote usando WHERE id IN (...).

java

@Entity
public class User {

    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
    @BatchSize(size = 25) // Carrega até 25 coleções por query
    private List<Post> posts;
}

Com 100 usuários e @BatchSize(size = 25), em vez de 100 queries, o Hibernate emite 4 queries:

sql

SELECT * FROM post WHERE author_id IN (1, 2, 3, ..., 25)
SELECT * FROM post WHERE author_id IN (26, 27, 28, ..., 50)
SELECT * FROM post WHERE author_id IN (51, 52, ..., 75)
SELECT * FROM post WHERE author_id IN (76, 77, ..., 100)

Você também pode configurar o batch size globalmente, o que funciona como uma rede de segurança para toda a aplicação:

properties

# application.properties (Spring Boot)
spring.jpa.properties.hibernate.default_batch_fetch_size=25

O @BatchSize é especialmente útil quando JOIN FETCH não é viável por conta de paginação real com setMaxResults.


3. Entity Graph

Os Entity Graphs (JPA 2.1+) permitem definir quais associações devem ser carregadas antecipadamente para uma query específica, sem alterar o mapeamento global da entidade. São mais flexíveis que mudar o FetchType porque se aplicam por query, não globalmente.

Definição via anotação:

java

@Entity
@NamedEntityGraph(
    name = "Post.withAuthor",
    attributeNodes = @NamedAttributeNode("author")
)
public class Post {
    // ...
}

Uso na query:

java

EntityGraph<?> graph = entityManager.getEntityGraph("Post.withAuthor");

List<Post> posts = entityManager
    .createQuery("SELECT p FROM Post p", Post.class)
    .setHint("jakarta.persistence.fetchgraph", graph)
    .getResultList();
// Gera JOIN e carrega author sem N+1

Entity Graph dinâmico (sem anotação):

java

EntityGraph<Post> graph = entityManager.createEntityGraph(Post.class);
graph.addAttributeNodes("author");
graph.addSubgraph("author").addAttributeNodes("department"); // Relação aninhada

List<Post> posts = entityManager
    .createQuery("SELECT p FROM Post p", Post.class)
    .setHint("jakarta.persistence.fetchgraph", graph)
    .getResultList();

No Spring Data JPA:

java

public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(attributePaths = {"author"})
    List<Post> findByStatus(String status);

    @EntityGraph(attributePaths = {"author", "tags"})
    List<Post> findAll();
}

A diferença entre fetchgraph e loadgraph:

  • fetchgraph: apenas as associações especificadas são carregadas; o restante é lazy
  • loadgraph: as associações especificadas são carregadas; o restante mantém o FetchType definido no mapeamento

4. Subselect Fetching

Outra estratégia Hibernate-específica: @Fetch(FetchMode.SUBSELECT). Em vez de IN, o Hibernate usa uma subquery para buscar as coleções de todos os pais de uma vez:

java

@Entity
public class User {

    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
    @Fetch(FetchMode.SUBSELECT)
    private List<Post> posts;
}

SQL gerado:

sql

-- Query 1: busca usuários
SELECT * FROM user

-- Query 2: busca TODOS os posts dos usuários retornados via subquery
SELECT * FROM post
WHERE author_id IN (
    SELECT id FROM user
)

O resultado são sempre 2 queries, independente do número de usuários. A desvantagem é que a subquery replica a query original, podendo ser custosa se ela for complexa.


5. Projeções e DTOs com Constructor Expression

Quando você não precisa das entidades completas, projetar diretamente para DTOs é a solução mais eficiente. Elimina o N+1 e o over-fetching ao mesmo tempo:

java

// DTO
public class PostSummaryDTO {
    private final Long id;
    private final String title;
    private final String authorName;

    public PostSummaryDTO(Long id, String title, String authorName) {
        this.id = id;
        this.title = title;
        this.authorName = authorName;
    }
    // getters...
}

java

// JPQL com Constructor Expression + JOIN
List<PostSummaryDTO> summaries = entityManager
    .createQuery(
        """
        SELECT new com.example.dto.PostSummaryDTO(
            p.id, p.title, u.name
        )
        FROM Post p
        JOIN p.author u
        """,
        PostSummaryDTO.class
    )
    .getResultList();

SQL gerado: 1 query, com apenas as colunas necessárias.

sql

SELECT p.id, p.title, u.name
FROM post p
INNER JOIN user u ON u.id = p.author_id

No Spring Data JPA com interface projection:

java

// Interface de projeção
public interface PostSummary {
    Long getId();
    String getTitle();
    String getAuthorName();
}

// Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    @Query("""
        SELECT p.id AS id, p.title AS title, u.name AS authorName
        FROM Post p JOIN p.author u
        WHERE p.status = :status
    """)
    List<PostSummary> findSummariesByStatus(@Param("status") String status);
}

6. Criteria API com JOIN FETCH

Para queries dinâmicas construídas em tempo de execução, a Criteria API também suporta fetch join:

java

CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Post> cq = cb.createQuery(Post.class);
Root<Post> post = cq.from(Post.class);

// Fetch join via Criteria API
post.fetch("author", JoinType.INNER);

// Filtros dinâmicos
List<Predicate> predicates = new ArrayList<>();
if (status != null) {
    predicates.add(cb.equal(post.get("status"), status));
}
if (authorId != null) {
    predicates.add(cb.equal(post.get("author").get("id"), authorId));
}

cq.select(post).where(predicates.toArray(new Predicate[0]));

List<Post> posts = entityManager.createQuery(cq).getResultList();

Como Detectar N+1 na Sua Aplicação Java

Habilitando o Log SQL do Hibernate

O primeiro passo é tornar as queries visíveis:

properties

# application.properties (Spring Boot)

# Mostra o SQL gerado
spring.jpa.show-sql=true

# Formata o SQL para legibilidade
spring.jpa.properties.hibernate.format_sql=true

# Mostra os parâmetros bind (valores reais, não '?')
logging.level.org.hibernate.orm.jdbc.bind=TRACE

Com isso ativo, abra o console enquanto testa um endpoint e conte as queries. Quando você vir o mesmo SELECT repetindo com IDs diferentes, é N+1.

hibernate-statistics

Para métricas programáticas — especialmente úteis em testes:

properties

spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG

Ou acesse programaticamente em um teste:

java

SessionFactory sessionFactory = entityManager
    .getEntityManagerFactory()
    .unwrap(SessionFactory.class);

Statistics stats = sessionFactory.getStatistics();
stats.setStatisticsEnabled(true);

// Execute a operação
postService.findAllWithAuthor();

long queryCount = stats.getQueryExecutionCount();
long collectionFetches = stats.getCollectionFetchCount();

// CollectionFetchCount alto = N+1 em coleções lazy
System.out.printf("Queries: %d | Collection fetches: %d%n", queryCount, collectionFetches);

Hypersistence Utils: assertSQLStatementCount

A biblioteca de Vlad Mihalcea oferece um utilitário para contar queries em testes de forma declarativa:

xml

<!-- pom.xml -->
<dependency>
    <groupId>io.hypersistence</groupId>
    <artifactId>hypersistence-utils-hibernate-63</artifactId>
    <version>3.7.0</version>
    <scope>test</scope>
</dependency>

java

@SpringBootTest
class PostServiceTest {

    @Autowired
    private PostService postService;

    @Test
    void deveCarregarPostsComAutorEm2Queries() {
        // Falha automaticamente se não for exatamente 2 queries
        assertSQLStatementCount(2, () -> {
            postService.findAllWithAuthor();
        });
    }
}

Isso transforma a detecção de N+1 em um teste que quebra o build — a melhor forma de evitar regressões.

p6spy

O p6spy é um driver JDBC proxy que intercepta todas as queries e loga com os valores reais (não ?), timestamps e duração:

xml

<!-- pom.xml -->
<dependency>
    <groupId>p6spy</groupId>
    <artifactId>p6spy</artifactId>
    <version>3.9.1</version>
</dependency>

properties

# application.properties
spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver
spring.datasource.url=jdbc:p6spy:postgresql://localhost:5432/mydb

properties

# spy.properties
appender=com.p6spy.engine.spy.appender.Slf4JLogger
customLogMessageFormat=%(executionTime)ms | %(sql)

APM em Produção

Em produção, ferramentas como Datadog, New Relic, Elastic APM e Dynatrace identificam automaticamente endpoints com alto número de queries por trace. Qualquer endpoint com 50+ queries por requisição é candidato a investigação de N+1.


Os Números: Quanto Você Ganha

Latência por Query

Uma query em banco de dados local tem overhead de 1–5ms de round-trip (parse, plan, execute, return). Em produção com banco em rede separada (típico em cloud), esse overhead sobe para 5–20ms por query.

Com N+1 e 100 registros: 100–2.000ms só de overhead de queries. Com JOIN FETCH (1 query): 1–20ms. A matemática é cruel — e linear.

Exemplo Real: Antes e Depois

Endpoint REST que lista 200 pedidos com cliente, itens e produto de cada item:

Antes (N+1 em cascata):
  1 query para pedidos
+ 200 queries para clientes (1 por pedido)
+ 200 queries para itens (1 por pedido)
+ 1.000 queries para produtos (1 por item, média 5 itens/pedido)
= 1.401 queries | ~4.8 segundos

Depois (JOIN FETCH + BatchSize):
  1 query para pedidos + clientes (JOIN FETCH)
+ 1 query para todos os itens (BatchSize IN)
+ 1 query para todos os produtos (BatchSize IN)
= 3 queries | ~35 milissegundos

Melhoria: 137x mais rápido, redução de 99.8% no número de queries.

Impacto na Carga do Banco

Com 500 usuários simultâneos cada fazendo uma requisição com N+1 de 100 queries: 50.000 queries/segundo. Com a correção: 1.500 queries/segundo. Essa diferença frequentemente determina se o banco aguenta o pico de tráfego ou entra em colapso.


Armadilhas e Casos Específicos do Hibernate

MultipleBagFetchException

Tentar fazer JOIN FETCH em duas coleções List ao mesmo tempo lança uma exceção em tempo de execução:

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

java

// ERRO: duas listas com JOIN FETCH simultâneo
List<User> users = entityManager
    .createQuery("""
        SELECT u FROM User u
        JOIN FETCH u.posts
        JOIN FETCH u.comments
    """, User.class)
    .getResultList(); // Lança MultipleBagFetchException

Soluções:

java

// Opção 1: usar Set ao invés de List em uma das coleções
@OneToMany(mappedBy = "author")
private Set<Post> posts; // Set elimina o problema do produto cartesiano

// Opção 2: @BatchSize em uma coleção + JOIN FETCH na outra
@OneToMany(mappedBy = "author")
@BatchSize(size = 25)
private List<Comment> comments;

// Opção 3: queries separadas no repository
@EntityGraph(attributePaths = {"posts"})
List<User> findAllWithPosts();

@EntityGraph(attributePaths = {"comments"})
List<User> findAllWithComments();

N+1 com @OneToOne Bidirecional

Relações @OneToOne bidirecionais têm um N+1 especialmente sorrateiro. O lado mappedBy (que não tem a FK na tabela) não consegue usar proxy lazy de forma eficiente — o Hibernate precisa verificar se existe um registro relacionado, e faz isso com uma query por entidade mesmo com FetchType.LAZY:

java

@Entity
public class User {
    @OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
    private UserProfile profile; // LAZY não funciona corretamente aqui
}

A solução mais prática é reestruturar para evitar a bidirecionalidade, ou usar bytecode enhancement do Hibernate para habilitar lazy loading real em @OneToOne.

LazyInitializationException vs. N+1

O LazyInitializationException e o N+1 são dois lados da mesma moeda. O exception acontece quando você tenta acessar uma relação lazy fora de uma sessão Hibernate ativa. A solução incorreta é usar Open Session in View — que mantém a sessão aberta durante toda a renderização, criando N+1 silenciosos na camada de view. A solução correta é fazer o carregamento antecipado na query.

Over-fetching: o Problema Inverso

Ao corrigir N+1 com JOIN FETCH agressivo, é fácil cair no extremo oposto:

java

// Carrega TUDO, mesmo que só precise do título e nome do autor
List<Post> posts = entityManager
    .createQuery("""
        SELECT p FROM Post p
        JOIN FETCH p.author
        JOIN FETCH p.comments
        JOIN FETCH p.tags
    """, Post.class)
    .getResultList();

Use projeções DTO quando não precisar das entidades completas. Isso reduz o volume de dados trafegado entre banco e aplicação:

java

// Traz apenas o necessário para a view
List<PostSummaryDTO> summaries = entityManager
    .createQuery("""
        SELECT new com.example.PostSummaryDTO(p.id, p.title, u.name)
        FROM Post p JOIN p.author u
    """, PostSummaryDTO.class)
    .getResultList();

Resumo: Qual Estratégia Usar

SituaçãoEstratégia Recomendada
Query simples, sem paginaçãoJOIN FETCH no JPQL
Query dinâmica em runtimeCriteria API com fetch()
Spring Data JPA, associações fixas@EntityGraph no repository
Com paginação real (setMaxResults)@BatchSize ou default_batch_fetch_size
Leitura apenas, sem entidade completaProjeção DTO com Constructor Expression
Múltiplas coleções (MultipleBagFetchException)@BatchSize em uma + JOIN FETCH na outra
Debug e diagnósticop6spy + hibernate.generate_statistics
Evitar regressõesassertSQLStatementCount em testes

Estratégia de Prevenção

Escreva testes que contam queries. Use assertSQLStatementCount do Hypersistence Utils ou as estatísticas do Hibernate diretamente. Se um service deve executar no máximo 3 queries, escreva um teste que falha se passar de 3. Isso evita que N+1 entre silenciosamente em um PR.

Configure default_batch_fetch_size globalmente. Mesmo que não seja a solução ideal para cada caso, configurar um batch size padrão (ex: 25) é uma rede de segurança: qualquer lazy loading que você esquecer de otimizar vai emitir no máximo N/25 queries em vez de N.

properties

spring.jpa.properties.hibernate.default_batch_fetch_size=25

Habilite show-sql durante o desenvolvimento de novas features. É muito mais fácil perceber o problema quando você está escrevendo o código do que quando ele já está em produção.

Pense em termos de conjuntos, não de loops. Sempre que ver um loop que acessa uma relação lazy dentro de si, questione: essa relação pode ser carregada junto na query original?

Code review com foco em acesso a dados. Qualquer acesso a uma relação dentro de um loop — getXxx() iterando sobre uma lista — é um sinal vermelho que merece atenção durante o review.

Monitore queries por requisição em produção. Qualquer endpoint com mais de 20–30 queries merece investigação.


Conclusão

O problema N+1 é endêmico em aplicações Java/Hibernate porque o lazy loading — que é uma feature legítima e útil — torna muito fácil escrever código que parece limpo mas se comporta de forma horrível sob a perspectiva do banco de dados. O Hibernate faz um trabalho excelente de abstrair o acesso ao banco, e é exatamente essa conveniência que torna o N+1 tão fácil de introduzir inadvertidamente.

A boa notícia é que o ecossistema Java tem ferramentas excelentes para cada cenário: JOIN FETCH para o caso base, @EntityGraph para Spring Data JPA, @BatchSize quando há paginação, e projeções DTO para leitura eficiente. O desafio não é a falta de ferramentas — é saber qual aplicar em cada situação.

O maior investimento não é técnico, é de mentalidade: desenvolver o instinto de questionar qualquer acesso a relação lazy dentro de um loop, e transformar esse instinto em testes automatizados que impedem regressões. Com assertSQLStatementCount quebrando o build e default_batch_fetch_size como rede de segurança, o N+1 passa a ser pego cedo — antes de chegar em produção e transformar um endpoint rápido em um timeout.


Se você encontrou N+1s específicos na sua aplicação e quer discutir a estratégia certa para o seu caso, deixe nos comentários.

Posts Similares

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *