Spring Data JPA N+1-Oplossingen: Fetch Join, EntityGraph en Hibernate 7.4
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 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.
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.
@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
}@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.
@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.
-- 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 queriesHet 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.
# 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: TRACEHibernate-statistieken bieden een waardevolle samenvatting aan het einde van elke transactie.
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.
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.
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.
-- 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.idDe service gebruikt nu de geoptimaliseerde methode zonder de bedrijfscode aan te passen.
@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. Voor een breder overzicht van JPA-best practices, zie de Spring Data JPA-basismodule.
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.
@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.
Hibernate 7 introduceert een vereenvoudigde tekstgebaseerde @NamedEntityGraph-syntax die de annotatieverbositeit vermindert:
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
name = "Order.full",
graph = "customer, items(product)"
)
public class Order {
// Fields unchanged
}Deze enkele-regel definitie vervangt de uitgebreide geneste @NamedAttributeNode- en @NamedSubgraph-annotaties.
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.
@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.
-- 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.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch 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.
Paginering met Fetch Join in Hibernate 7.4
Een langdurige beperking van fetch joins was hun incompatibiliteit met paginering. Het combineren van setMaxResults() met collection fetch joins dwong Hibernate om alle overeenkomende rijen te laden en daarna de limieten in het geheugen toe te passen. De waarschuwing firstResult/maxResults specified with collection fetch; applying in memory wees op dit prestatieprobleem.
Hibernate 7.4 lost dit probleem op. Paginering met collection fetch joins werkt nu op databaseniveau met behulp van geneste queries. Hibernate bepaalt eerst de beperkte set van parent-entiteit-identifiers en haalt vervolgens de bijbehorende collecties alleen op voor die parent-rijen.
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);
}De gegenereerde SQL gebruikt nu een subquery voor paginering op databaseniveau:
-- 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
)Deze verbetering vermindert het geheugengebruik voor gepagineerde endpoints die parent-child relaties weergeven drastisch. Spring Boot 4.1 bevat Hibernate 7.4.5, waardoor deze optimalisatie standaard beschikbaar is.
Voor applicaties die nog oudere Hibernate-versies gebruiken, blijft het tweefasige ophaalpatroon de veiligste aanpak: haal eerst parent-ID's op met paginering, laad dan associaties voor die specifieke ID's.
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.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- 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. Dit patroon is bijzonder relevant voor lees-intensieve endpoints, zoals behandeld in de JPA-queries module.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}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.
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 in Spring Boot 4.x biedt verbeterd EntityGraph-beheer en query-optimalisatie. Begrip van transactiepropagatie wordt essentieel bij het combineren van deze fetch-strategieën met complexe bedrijfslogica.
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.
@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. Uitgebreide teststrategieën worden behandeld in de Testcontainers-gids voor Spring Boot.
@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.
Sources
- Hibernate 7.4 What's New – paginering met fetch joins nu veilig op databaseniveau
- Spring Boot 4.1 Release Notes – bevat Hibernate 7.4.5.Final
- Hibernate ORM Documentation – officiële referentie voor fetch-strategieën en EntityGraph
N+1-Preventiechecklist voor Spring Data JPA
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. Met Hibernate 7.4 werkt paginering met fetch joins eindelijk op databaseniveau.
Aanbevelingen voor productie:
- SQL-logs inschakelen tijdens ontwikkeling om meerdere queries te detecteren
JOIN FETCHgebruiken voor vaak geraadpleegde@ManyToOne-relaties@EntityGraphtoepassen voor herbruikbaar declaratief laden@BatchSizeconfigureren 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
- Upgraden naar Spring Boot 4.1+ om te profiteren van de Hibernate 7.4-pagineringsverbeteringen
Zie jij de bug in Spring Boot?
Een echt codefragment, een verborgen bug, één poging per dag. Zonder account uit te proberen.

Geschreven door
Anthony Fillion-MailletOprichter van SharpSkill
Al meer dan 10 jaar fullstack-ontwikkelaar. Hij leidt SharpSkill en staat in voor alles wat hier verschijnt.
Bijgewerkt op 2 september 2026
Tags
Delen
Gerelateerde artikelen

Spring GraphQL Sollicitatiegesprek: Resolvers, DataLoaders en Oplossingen voor het N+1-probleem
Voorbereiding op Spring GraphQL sollicitatiegesprekken met deze complete gids. Resolvers, DataLoaders, omgaan met het N+1-probleem, mutaties en best practices voor technische vragen.

Spring Boot 3.4 Virtual Threads: Interviewvragen en Performance-Benchmarks
Beheers Java 21 Virtual Threads met Spring Boot 3.4: 15 interviewvragen, performance-benchmarks en migratiepatronen om technische gesprekken te slagen.

Spring Boot Observability in 2026: OpenTelemetry, Distributed Tracing en Sollicitatievragen
Beheers Spring Boot observability met OpenTelemetry en Micrometer Tracing. Configureer OTLP-export, gebruik de Observation API en bereid je voor op technische sollicitatiegesprekken.