Spring Data JPA N+1-Oplossingen in 2026: Fetch Join en EntityGraph

Volledige gids voor het detecteren en oplossen van het N+1-probleem in Spring Data JPA. Fetch join, @EntityGraph, batch fetching en strategieën voor queryprestaties.

Het N+1-probleem oplossen met Spring Data JPA via fetch join en EntityGraph

Het N+1-probleem vormt een van de meest voorkomende prestatievalkuilen in JPA. Een onschuldige query om 100 bestellingen op te halen kan 101 SQL-queries veroorzaken: één voor de bestellingen en daarna één voor elke bijbehorende klant. Deze stille vermenigvuldiging van queries verslechtert de prestaties en overbelast de database.

Praktische Impact van N+1

Een endpoint dat 50 artikelen met hun auteurs retourneert kan door N+1 van 10ms naar 500ms springen. Vroege detectie voorkomt kritieke productieproblemen.

Het N+1-Probleem in JPA Begrijpen

Het N+1-probleem treedt op wanneer JPA een collectie van entiteiten laadt en vervolgens een extra query uitvoert voor elke entiteit om de bijbehorende associaties te laden. Dit gedrag komt voort uit de standaard lazy loading van @OneToMany- en @ManyToMany-relaties.

Neem een klassiek model met bestellingen en klanten. Elke bestelling behoort tot een klant, en deze relatie gebruikt standaard lazy loading.

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
}

Wanneer een query bestellingen ophaalt en vervolgens de naam van de klant opvraagt, voert Hibernate voor elke bestelling een extra query uit.

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();
    }
}

Voor 100 bestellingen voert deze code 101 SQL-queries uit. De Hibernate-logs onthullen dit destructieve patroon.

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

Het N+1-Probleem Detecteren met Hibernate-Logs

De eerste stap bestaat uit het inschakelen van SQL-logs om problematische queries te identificeren. De volgende configuratie toont elke door Hibernate uitgevoerde query.

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

Hibernate-statistieken bieden een waardevolle samenvatting aan het einde van elke transactie.

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;
}

Het aantal van 101 JDBC-statements voor een eenvoudige bestellijst signaleert duidelijk een N+1-probleem.

Uitschakelen in Productie

SQL-logs en statistieken hebben invloed op de prestaties. Deze opties moeten in productie uitgeschakeld blijven en gereserveerd zijn voor ontwikkel- en testomgevingen.

Oplossing 1: Fetch Join met JPQL

Fetch join laadt associaties in één enkele SQL-query via een join. Deze expliciete aanpak lost N+1 op door alle benodigde gegevens in één keer op te halen.

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 transformeert N+1-queries in één geoptimaliseerde query.

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

De service gebruikt nu de geoptimaliseerde methode zonder de bedrijfscode aan te passen.

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();
    }
}

Klaar om je Spring Boot gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Oplossing 2: @EntityGraph voor Declaratieve Controle

De annotatie @EntityGraph biedt een declaratief alternatief voor fetch join. Deze definieert welke associaties moeten worden geladen zonder aangepaste JPQL te schrijven.

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);
}

Benoemde EntityGraphs worden direct op de entiteit gedefinieerd voor hergebruik in meerdere repositories.

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
}

De subgraph maakt het laden van geneste associaties mogelijk. Het bovenstaande voorbeeld laadt de bestellingen, hun items en het product van elk item in één query.

Oplossing 3: Batch Fetching voor Collecties

Batch fetching biedt een alternatief voor fetch join voor @OneToMany-collecties. In plaats van elke collectie afzonderlijk te laden, groepeert Hibernate de queries in batches.

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<>();
}

Met @BatchSize(size = 25) laadt Hibernate items voor 25 bestellingen tegelijk in plaats van 1. Voor 100 bestellingen daalt het aantal queries van 101 naar 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)

De globale configuratie van de batchgrootte geldt voor alle collecties in de applicatie.

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

Batch fetching past bij gevallen waarin fetch join een te groot Cartesisch product genereert. Voor een bestelling met 10 items en 5 betalingen retourneert fetch join 50 rijen. Batch fetching voert 2 afzonderlijke, efficiëntere queries uit.

Vergelijking van Laadstrategieën

Elke strategie biedt voordelen afhankelijk van de gebruikscontext. De volgende tabel vat de optimale gebruiksgevallen samen.

| Strategie | Gebruikscontext | Voordelen | Nadelen | |----------|----------|------------|---------------| | Fetch Join | @ManyToOne-relaties | Enkele SQL-query | Cartesisch product met collecties | | @EntityGraph | Declaratief laden | Herbruikbaar, leesbaar | Minder flexibel dan JPQL | | Batch Fetching | @OneToMany-collecties | Vermijdt Cartesisch product | Meerdere queries | | Subselect | Zelden geraadpleegde collecties | Laadt alleen wanneer nodig | Gecorreleerde subquery |

De subselect-strategie laadt de hele collectie bij de eerste toegang tot een element.

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 > ?)

N+1 Vermijden met DTO-Projecties

DTO-projecties bieden een radicale maar effectieve aanpak. Door alleen de noodzakelijke kolommen te selecteren, vermijden projecties volledig het laden van entiteiten en hun associaties.

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
    );
}

Deze aanpak genereert een optimale SQL-query zonder de overhead van entity-mapping.

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 > ?

Geavanceerde Configuratie met Spring Data JPA

Spring Data JPA 3.x introduceert verbeteringen voor het beheer van EntityGraph en geoptimaliseerde queries.

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
    );
}

Voorwaardelijk laden maakt het mogelijk om verschillende strategieën toe te passen afhankelijk van de context.

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");
    }
}

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Prestatietests om N+1 te Detecteren

Geautomatiseerde tests verifiëren de afwezigheid van N+1-problemen door het aantal uitgevoerde SQL-queries te tellen.

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();
    }
}

De assertie op getQueryExecutionCount() zorgt ervoor dat optimalisaties behouden blijven tijdens code-evolutie.

Conclusie

Het N+1-probleem vormt een grote prestatie-uitdaging in Spring Data JPA, maar er bestaan verschillende effectieve oplossingen. Fetch join en @EntityGraph lossen de meeste gevallen op door associaties in één query te laden. Batch fetching biedt een alternatief voor grote collecties waar fetch join een Cartesisch product genereert.

Checklist voor N+1-Preventie:

  • ✅ SQL-logs inschakelen tijdens ontwikkeling om meerdere queries te detecteren
  • JOIN FETCH gebruiken voor vaak geraadpleegde @ManyToOne-relaties
  • @EntityGraph toepassen voor herbruikbaar declaratief laden
  • @BatchSize configureren voor grote @OneToMany-collecties
  • ✅ DTO-projecties verkiezen voor alleen-lezen-bewerkingen
  • ✅ Tests schrijven die het aantal uitgevoerde queries verifiëren
  • ✅ SQL-logs en statistieken uitschakelen in productie
  • ✅ Kritieke endpoints regelmatig profileren met Hibernate-metrics

Tags

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

Delen

Gerelateerde artikelen