Soluções N+1 no Spring Data JPA: Fetch Join, EntityGraph e Hibernate 7.4

Guia completo para detectar e corrigir o problema N+1 no Spring Data JPA. Fetch join, @EntityGraph, batch fetching e melhorias de paginação no Hibernate 7.4.

Resolvendo o problema N+1 com Spring Data JPA usando fetch join e EntityGraph

O problema N+1 representa uma das armadilhas de performance mais comuns no JPA. Uma consulta inocente para recuperar 100 pedidos pode disparar 101 consultas SQL: uma para os pedidos, depois uma para cada cliente associado. Essa multiplicação silenciosa de consultas degrada a performance e sobrecarrega o banco de dados.

Impacto Real do N+1

Um endpoint que retorna 50 artigos com seus autores pode saltar de 10ms para 500ms por causa do N+1. A detecção precoce evita problemas críticos em produção.

Entendendo o Problema N+1 no JPA

O problema N+1 ocorre quando o JPA carrega uma coleção de entidades e depois executa uma consulta adicional para cada entidade carregar suas associações. Esse comportamento decorre do carregamento lazy padrão dos relacionamentos @OneToMany e @ManyToMany.

Considere um modelo clássico com pedidos e clientes. Cada pedido pertence a um cliente, e esse relacionamento usa carregamento lazy por padrão.

Order.javajava
@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String orderNumber;

    private LocalDateTime createdAt;

    // ManyToOne relationship is lazy by default since JPA 2.0
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    // OneToMany relationship lazy by default
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();

    // Getters and setters omitted
}
Customer.javajava
@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String email;

    // Getters and setters omitted
}

Quando uma consulta recupera pedidos e em seguida acessa o nome do cliente, o Hibernate executa uma consulta adicional para cada pedido.

OrderService.java - Problematic codejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // 1 query: SELECT * FROM orders
        List<Order> orders = orderRepository.findAll();

        // For each order, accessing customer triggers a query
        return orders.stream()
            .map(order -> new OrderDto(
                order.getId(),
                order.getOrderNumber(),
                // N queries: SELECT * FROM customers WHERE id = ?
                order.getCustomer().getName()
            ))
            .toList();
    }
}

Para 100 pedidos, esse código executa 101 consultas SQL. Os logs do Hibernate revelam esse padrão destrutivo.

sql
-- Query 1: fetch orders
SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o

-- Queries 2-101: fetch each customer
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3
-- ... 97 more identical queries

Detectando o Problema N+1 com Logs do Hibernate

O primeiro passo envolve ativar os logs SQL para identificar consultas problemáticas. A configuração a seguir exibe cada consulta executada pelo Hibernate.

yaml
# application.yml
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        # Format SQL for better readability
        format_sql: true
        # Display session statistics (queries, time)
        generate_statistics: true

logging:
  level:
    # Detailed SQL query logging
    org.hibernate.SQL: DEBUG
    # Display prepared statement parameters
    org.hibernate.orm.jdbc.bind: TRACE

As estatísticas do Hibernate fornecem um resumo valioso ao final de cada transação.

text
Session Metrics {
    23421 nanoseconds spent acquiring 1 JDBC connection;
    0 nanoseconds spent releasing 0 JDBC connections;
    1254789 nanoseconds spent preparing 101 JDBC statements;
    15478963 nanoseconds spent executing 101 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
}

A contagem de 101 statements JDBC para uma simples lista de pedidos sinaliza claramente um problema N+1.

Desativar em Produção

Logs SQL e estatísticas impactam a performance. Essas opções devem permanecer desativadas em produção e reservadas para ambientes de desenvolvimento e teste.

Solução 1: Fetch Join com JPQL

Fetch join carrega as associações em uma única consulta SQL através de um join. Essa abordagem explícita resolve o N+1 recuperando todos os dados necessários de uma só vez.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Explicit fetch join to load customers
    @Query("SELECT o FROM Order o JOIN FETCH o.customer")
    List<Order> findAllWithCustomer();

    // Multiple fetch join for several associations
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "JOIN FETCH o.items i")
    List<Order> findAllWithCustomerAndItems();

    // Fetch join with WHERE condition
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "WHERE o.createdAt > :since")
    List<Order> findRecentOrdersWithCustomer(
        @Param("since") LocalDateTime since
    );
}

Fetch join transforma N+1 consultas em uma única consulta otimizada.

sql
-- Single query with join
SELECT o.id, o.order_number, o.created_at, o.customer_id,
       c.id, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id

O serviço agora utiliza o método otimizado sem modificar o código de negócio.

OrderService.java - Optimized codejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // Single query with join
        List<Order> orders = orderRepository.findAllWithCustomer();

        // No additional queries
        return orders.stream()
            .map(order -> new OrderDto(
                order.getId(),
                order.getOrderNumber(),
                order.getCustomer().getName() // Already loaded
            ))
            .toList();
    }
}

Pronto para mandar bem nas entrevistas de Spring Boot?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Solução 2: @EntityGraph para Controle Declarativo

A anotação @EntityGraph oferece uma alternativa declarativa ao fetch join. Ela define quais associações carregar sem escrever JPQL personalizado. Para uma visão mais ampla das melhores práticas de JPA, consulte o módulo de conceitos básicos do Spring Data JPA.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Inline EntityGraph with attributePaths
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll();

    // EntityGraph with multiple attributes
    @EntityGraph(attributePaths = {"customer", "items"})
    List<Order> findByCreatedAtAfter(LocalDateTime since);

    // Named EntityGraph referencing entity definition
    @EntityGraph(value = "Order.withCustomerAndItems")
    List<Order> findByCustomerId(Long customerId);

    // Combination with custom query
    @EntityGraph(attributePaths = {"customer"})
    @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
    List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}

EntityGraphs nomeados são definidos diretamente na entidade para reúso em vários repositórios.

Order.javajava
@Entity
@Table(name = "orders")
@NamedEntityGraph(
    name = "Order.withCustomer",
    attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
    name = "Order.withCustomerAndItems",
    attributeNodes = {
        @NamedAttributeNode("customer"),
        @NamedAttributeNode("items")
    }
)
@NamedEntityGraph(
    name = "Order.full",
    attributeNodes = {
        @NamedAttributeNode("customer"),
        @NamedAttributeNode(value = "items", subgraph = "items-product")
    },
    subgraphs = @NamedSubgraph(
        name = "items-product",
        attributeNodes = @NamedAttributeNode("product")
    )
)
public class Order {
    // Fields unchanged
}

O subgraph permite carregar associações aninhadas. O exemplo acima carrega os pedidos, seus itens e o produto de cada item em uma única consulta.

O Hibernate 7 introduz uma sintaxe baseada em texto simplificada para @NamedEntityGraph que reduz a verbosidade das anotações:

Order.java - Hibernate 7 text-based syntaxjava
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
    name = "Order.full",
    graph = "customer, items(product)"
)
public class Order {
    // Fields unchanged
}

Essa definição de uma única linha substitui as anotações aninhadas @NamedAttributeNode e @NamedSubgraph.

Solução 3: Batch Fetching para Coleções

Batch fetching oferece uma alternativa ao fetch join para coleções @OneToMany. Em vez de carregar cada coleção individualmente, o Hibernate agrupa as consultas em lotes.

Order.javajava
@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String orderNumber;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    // Batch fetching on the collection
    @OneToMany(mappedBy = "order")
    @BatchSize(size = 25)
    private List<OrderItem> items = new ArrayList<>();
}

Com @BatchSize(size = 25), o Hibernate carrega itens de 25 pedidos por vez em vez de 1. Para 100 pedidos, a contagem de consultas cai de 101 para 5.

sql
-- Without batch fetching: 100 queries
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
-- ... 98 more queries

-- With @BatchSize(size = 25): 4 queries
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50)
SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75)
SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100)

A configuração global do tamanho de lote se aplica a todas as coleções da aplicação.

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch vs Fetch Join

Batch fetching é adequado para casos onde fetch join gera um produto cartesiano excessivamente grande. Para um pedido com 10 itens e 5 pagamentos, fetch join retorna 50 linhas. Batch fetching executa 2 consultas separadas mais eficientes.

Paginação com Fetch Join no Hibernate 7.4

Uma limitação histórica dos fetch joins era sua incompatibilidade com a paginação. Combinar setMaxResults() com fetch joins em coleções forçava o Hibernate a carregar todas as linhas correspondentes, para depois aplicar os limites em memória. O aviso firstResult/maxResults specified with collection fetch; applying in memory indicava esse problema de performance.

O Hibernate 7.4 resolve esse problema. A paginação com fetch joins em coleções agora funciona no nível do banco de dados usando consultas aninhadas. O Hibernate primeiro determina o conjunto limitado de identificadores da entidade pai, e então recupera as coleções associadas apenas para essas linhas pai.

OrderRepository.java - Safe pagination with Hibernate 7.4java
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.items")
    Page<Order> findAllWithItems(Pageable pageable);

    @Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items")
    Slice<Order> findAllWithCustomerAndItems(Pageable pageable);
}

O SQL gerado agora usa uma subconsulta para paginar no nível do banco de dados:

sql
-- Hibernate 7.4: database-level pagination with fetch join
SELECT o.*, i.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
WHERE o.id IN (
    SELECT id FROM orders
    ORDER BY created_at DESC
    LIMIT 10 OFFSET 0
)

Essa melhoria reduz drasticamente o uso de memória para endpoints paginados que exibem relacionamentos pai-filho. O Spring Boot 4.1 inclui Hibernate 7.4.5, tornando essa otimização disponível por padrão.

Para aplicações ainda em versões anteriores do Hibernate, o padrão de recuperação em duas fases continua sendo a abordagem mais segura: primeiro recuperar os IDs pai com paginação, e depois carregar as associações para esses IDs específicos.

Comparando Estratégias de Carregamento

Cada estratégia oferece vantagens dependendo do contexto de uso. A tabela a seguir resume os casos de uso ideais.

EstratégiaCaso de UsoVantagensDesvantagens
Fetch JoinRelacionamentos @ManyToOneConsulta SQL únicaProduto cartesiano com coleções
@EntityGraphCarregamento declarativoReutilizável, legívelMenos flexível que JPQL
Batch FetchingColeções @OneToManyEvita produto cartesianoMúltiplas consultas
SubselectColeções raramente acessadasCarrega apenas quando necessárioSubconsulta correlacionada

A estratégia subselect carrega toda a coleção no primeiro acesso a qualquer elemento.

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();
sql
-- Generated subselect query
SELECT * FROM order_items
WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?)

Evitando o N+1 com Projeções DTO

Projeções DTO oferecem uma abordagem radical mas eficaz. Ao selecionar apenas as colunas necessárias, as projeções evitam completamente o carregamento de entidades e suas associações. Esse padrão é particularmente relevante para endpoints de leitura, conforme detalhado no módulo de consultas JPA.

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // DTO projection with constructor
    @Query("SELECT new com.example.dto.OrderSummaryDto(" +
           "o.id, o.orderNumber, c.name, c.email) " +
           "FROM Order o JOIN o.customer c")
    List<OrderSummaryDto> findAllOrderSummaries();

    // DTO projection with condition
    @Query("SELECT new com.example.dto.OrderSummaryDto(" +
           "o.id, o.orderNumber, c.name, c.email) " +
           "FROM Order o JOIN o.customer c " +
           "WHERE o.createdAt > :since")
    List<OrderSummaryDto> findRecentOrderSummaries(
        @Param("since") LocalDateTime since
    );
}

Essa abordagem gera uma consulta SQL ideal sem o overhead do mapeamento de entidades.

sql
SELECT o.id, o.order_number, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > ?

Configuração Avançada com Spring Data JPA

O Spring Data JPA no Spring Boot 4.x fornece gerenciamento aprimorado de EntityGraphs e otimização de consultas. Compreender a propagação de transações se torna essencial ao combinar essas estratégias de carregamento com lógica de negócio complexa.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Dynamic EntityGraph with Specification
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll(Specification<Order> spec);

    // Pagination with EntityGraph
    @EntityGraph(attributePaths = {"customer"})
    Page<Order> findByCustomerNameContaining(
        String name,
        Pageable pageable
    );

    // Slice for efficient pagination
    @EntityGraph(attributePaths = {"customer"})
    Slice<Order> findByCreatedAtBefore(
        LocalDateTime date,
        Pageable pageable
    );
}

O carregamento condicional permite aplicar diferentes estratégias dependendo do contexto.

OrderService.javajava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final EntityManager entityManager;

    public List<Order> getOrdersWithGraph(String graphName) {
        // Dynamic EntityGraph retrieval
        EntityGraph<?> graph = entityManager
            .getEntityGraph(graphName);

        return entityManager
            .createQuery("SELECT o FROM Order o", Order.class)
            .setHint("jakarta.persistence.loadgraph", graph)
            .getResultList();
    }

    public List<Order> getOrdersForListing() {
        // Minimal graph for listing
        return getOrdersWithGraph("Order.withCustomer");
    }

    public List<Order> getOrdersForDetail() {
        // Full graph for detail view
        return getOrdersWithGraph("Order.full");
    }
}

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Testes de Performance para Detectar N+1

Testes automatizados verificam a ausência de problemas N+1 contando as consultas SQL executadas. Para estratégias de teste abrangentes, consulte o guia de Testcontainers para Spring Boot.

OrderRepositoryPerformanceTest.javajava
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryPerformanceTest {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private EntityManager entityManager;

    @PersistenceContext
    private EntityManager em;

    private Statistics statistics;

    @BeforeEach
    void setUp() {
        // Enable Hibernate statistics
        Session session = entityManager.unwrap(Session.class);
        SessionFactory factory = session.getSessionFactory();
        statistics = factory.getStatistics();
        statistics.setStatisticsEnabled(true);
        statistics.clear();
    }

    @Test
    void findAllWithCustomer_shouldExecuteSingleQuery() {
        // Given: 50 orders in database
        createTestOrders(50);
        entityManager.clear();
        statistics.clear();

        // When: fetch with fetch join
        List<Order> orders = orderRepository.findAllWithCustomer();

        // Then: single query executed
        assertThat(orders).hasSize(50);
        assertThat(statistics.getQueryExecutionCount())
            .as("Should execute only 1 query with fetch join")
            .isEqualTo(1);
    }

    @Test
    void findAll_withoutOptimization_triggersNPlus1() {
        // Given: 50 orders in database
        createTestOrders(50);
        entityManager.clear();
        statistics.clear();

        // When: fetch without optimization
        List<Order> orders = orderRepository.findAll();

        // Access customers to trigger lazy loading
        orders.forEach(o -> o.getCustomer().getName());

        // Then: N+1 queries (51 instead of 1)
        assertThat(statistics.getQueryExecutionCount())
            .as("Should detect N+1 problem")
            .isGreaterThan(1);
    }

    private void createTestOrders(int count) {
        for (int i = 0; i < count; i++) {
            Customer customer = new Customer();
            customer.setName("Customer " + i);
            customer.setEmail("customer" + i + "@test.com");
            entityManager.persist(customer);

            Order order = new Order();
            order.setOrderNumber("ORD-" + i);
            order.setCustomer(customer);
            order.setCreatedAt(LocalDateTime.now());
            entityManager.persist(order);
        }
        entityManager.flush();
    }
}

A asserção sobre getQueryExecutionCount() garante que as otimizações permaneçam em vigor durante a evolução do código.

Fontes

Checklist de Prevenção do N+1 para Spring Data JPA

O problema N+1 representa um grande desafio de performance no Spring Data JPA, mas existem várias soluções eficazes. Fetch join e @EntityGraph resolvem a maioria dos casos carregando as associações em uma única consulta. Batch fetching oferece uma alternativa para grandes coleções onde fetch join gera um produto cartesiano. Com o Hibernate 7.4, a paginação com fetch joins finalmente funciona no nível do banco de dados.

O que aplicar em produção:

  • Ativar logs SQL em desenvolvimento para detectar consultas múltiplas
  • Usar JOIN FETCH para relacionamentos @ManyToOne acessados frequentemente
  • Aplicar @EntityGraph para carregamento declarativo reutilizável
  • Configurar @BatchSize para coleções @OneToMany grandes
  • Preferir projeções DTO para operações somente leitura
  • Escrever testes que verifiquem o número de consultas executadas
  • Desativar logs SQL e estatísticas em produção
  • Perfilar regularmente endpoints críticos com métricas do Hibernate
  • Atualizar para Spring Boot 4.1+ para aproveitar as correções de paginação do Hibernate 7.4
Desafio do dia

Você saberia encontrar o bug em Spring Boot?

Um trecho real, um bug escondido, uma tentativa por dia. Sem conta para testar.

Anthony Fillion-Maillet

Escrito por

Anthony Fillion-Maillet

Fundador da SharpSkill

Desenvolvedor fullstack há mais de 10 anos. Dirige a SharpSkill e responde por tudo o que é publicado aqui.

Atualizado em 2 de setembro de 2026

Tags

#spring data jpa
#n+1 problem
#fetch join
#entitygraph
#performance

Compartilhar

Artigos relacionados