Solusi N+1 di Spring Data JPA: Fetch Join, EntityGraph dan Hibernate 7.4
Panduan lengkap untuk mendeteksi dan memperbaiki masalah N+1 di Spring Data JPA. Fetch join, @EntityGraph, batch fetching, dan strategi performa kueri.

Masalah N+1 merupakan salah satu jebakan performa paling umum di JPA. Sebuah kueri sederhana untuk mengambil 100 pesanan dapat memicu 101 kueri SQL: satu untuk pesanan, kemudian satu untuk setiap pelanggan terkait. Penggandaan kueri yang senyap ini menurunkan performa dan membebani database.
Sebuah endpoint yang mengembalikan 50 artikel beserta penulisnya dapat melonjak dari 10ms ke 500ms karena N+1. Deteksi dini mencegah masalah kritis di produksi.
Memahami Masalah N+1 di JPA
Masalah N+1 terjadi ketika JPA memuat koleksi entitas, lalu mengeksekusi kueri tambahan untuk setiap entitas guna memuat asosiasinya. Perilaku ini berasal dari lazy loading default pada relasi @OneToMany dan @ManyToMany.
Perhatikan model klasik dengan pesanan dan pelanggan. Setiap pesanan dimiliki oleh seorang pelanggan, dan relasi ini menggunakan lazy loading secara default.
@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
}Ketika sebuah kueri mengambil pesanan lalu mengakses nama pelanggan, Hibernate mengeksekusi kueri tambahan untuk setiap pesanan.
@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();
}
}Untuk 100 pesanan, kode ini mengeksekusi 101 kueri SQL. Log Hibernate mengungkap pola destruktif ini.
-- 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 queriesMendeteksi Masalah N+1 dengan Log Hibernate
Langkah pertama adalah mengaktifkan log SQL untuk mengidentifikasi kueri bermasalah. Konfigurasi berikut menampilkan setiap kueri yang dieksekusi oleh Hibernate.
# 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: TRACEStatistik Hibernate memberikan ringkasan berharga di akhir setiap transaksi.
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;
}Jumlah 101 statement JDBC untuk daftar pesanan sederhana jelas menandakan masalah N+1.
Log SQL dan statistik berdampak pada performa. Opsi-opsi ini harus tetap dinonaktifkan di produksi dan hanya digunakan di lingkungan pengembangan dan pengujian.
Solusi 1: Fetch Join dengan JPQL
Fetch join memuat asosiasi dalam satu kueri SQL melalui sebuah join. Pendekatan eksplisit ini menyelesaikan N+1 dengan mengambil semua data yang diperlukan sekaligus.
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 mengubah N+1 kueri menjadi satu kueri yang dioptimalkan.
-- 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.idService kini menggunakan metode yang dioptimalkan tanpa memodifikasi kode bisnis.
@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();
}
}Siap menguasai wawancara Spring Boot Anda?
Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.
Solusi 2: @EntityGraph untuk Kontrol Deklaratif
Anotasi @EntityGraph menawarkan alternatif deklaratif untuk fetch join. Anotasi ini mendefinisikan asosiasi mana yang harus dimuat tanpa menulis JPQL kustom. Untuk gambaran yang lebih luas tentang praktik terbaik JPA, lihat modul dasar-dasar Spring Data JPA.
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);
}EntityGraph bernama didefinisikan langsung pada entitas untuk digunakan kembali di beberapa repository.
@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
}Subgraph memungkinkan pemuatan asosiasi bertingkat. Contoh di atas memuat pesanan, item-itemnya, dan produk dari setiap item dalam satu kueri.
Hibernate 7 memperkenalkan sintaks @NamedEntityGraph berbasis teks yang disederhanakan yang mengurangi verbositas anotasi:
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
name = "Order.full",
graph = "customer, items(product)"
)
public class Order {
// Fields unchanged
}Definisi satu baris ini menggantikan anotasi @NamedAttributeNode dan @NamedSubgraph yang bertele-tele.
Solusi 3: Batch Fetching untuk Koleksi
Batch fetching menawarkan alternatif untuk fetch join pada koleksi @OneToMany. Alih-alih memuat setiap koleksi secara individual, Hibernate mengelompokkan kueri dalam batch.
@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<>();
}Dengan @BatchSize(size = 25), Hibernate memuat item untuk 25 pesanan sekaligus, bukan 1. Untuk 100 pesanan, jumlah kueri turun dari 101 menjadi 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)Konfigurasi global ukuran batch berlaku untuk semua koleksi di aplikasi.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch fetching cocok untuk kasus di mana fetch join menghasilkan produk Cartesian yang terlalu besar. Untuk pesanan dengan 10 item dan 5 pembayaran, fetch join mengembalikan 50 baris. Batch fetching mengeksekusi 2 kueri terpisah yang lebih efisien.
Paginasi dengan Fetch Join di Hibernate 7.4
Keterbatasan lama fetch join adalah ketidakkompatibilitasnya dengan paginasi. Menggabungkan setMaxResults() dengan collection fetch join memaksa Hibernate memuat semua baris yang cocok, lalu menerapkan limit di memori. Peringatan firstResult/maxResults specified with collection fetch; applying in memory menunjukkan masalah performa ini.
Hibernate 7.4 menyelesaikan masalah ini. Paginasi dengan collection fetch join kini bekerja di level database menggunakan nested query. Hibernate pertama menentukan set terbatas dari identifier entitas induk, lalu mengambil koleksi terkait hanya untuk baris induk tersebut.
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);
}SQL yang dihasilkan kini menggunakan subquery untuk paginasi di level database:
-- 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
)Perbaikan ini secara drastis mengurangi penggunaan memori untuk endpoint yang dipaginasi yang menampilkan relasi parent-child. Spring Boot 4.1 menyertakan Hibernate 7.4.5, sehingga optimasi ini tersedia secara default.
Untuk aplikasi yang masih menggunakan versi Hibernate sebelumnya, pola two-phase fetching tetap menjadi pendekatan paling aman: ambil ID induk terlebih dahulu dengan paginasi, lalu muat asosiasi untuk ID spesifik tersebut.
Membandingkan Strategi Pemuatan
Setiap strategi menawarkan keuntungan tergantung pada konteks penggunaan. Tabel berikut merangkum kasus penggunaan yang optimal.
| Strategi | Kasus Penggunaan | Keuntungan | Kerugian |
|---|---|---|---|
| Fetch Join | Relasi @ManyToOne | Kueri SQL tunggal | Produk Cartesian dengan koleksi |
| @EntityGraph | Pemuatan deklaratif | Dapat digunakan ulang, mudah dibaca | Kurang fleksibel daripada JPQL |
| Batch Fetching | Koleksi @OneToMany | Menghindari produk Cartesian | Banyak kueri |
| Subselect | Koleksi yang jarang diakses | Memuat hanya saat dibutuhkan | Subkueri terkorelasi |
Strategi subselect memuat seluruh koleksi pada akses pertama ke elemen mana pun.
@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 > ?)Menghindari N+1 dengan Proyeksi DTO
Proyeksi DTO menawarkan pendekatan radikal namun efektif. Dengan memilih hanya kolom yang diperlukan, proyeksi sepenuhnya menghindari pemuatan entitas dan asosiasinya. Pola ini sangat relevan untuk endpoint yang intensif baca, seperti yang dibahas dalam modul kueri JPA.
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
);
}Pendekatan ini menghasilkan kueri SQL optimal tanpa overhead pemetaan entitas.
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 > ?Konfigurasi Lanjutan dengan Spring Data JPA
Spring Data JPA di Spring Boot 4.x menyediakan manajemen EntityGraph yang ditingkatkan dan optimasi kueri. Memahami propagasi transaksi menjadi penting saat menggabungkan strategi fetch ini dengan logika bisnis yang kompleks.
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
);
}Pemuatan kondisional memungkinkan penerapan strategi berbeda berdasarkan konteks.
@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");
}
}Mulai berlatih!
Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.
Tes Performa untuk Mendeteksi N+1
Tes otomatis memverifikasi tidak adanya masalah N+1 dengan menghitung kueri SQL yang dieksekusi. Untuk strategi pengujian yang komprehensif, lihat panduan Testcontainers untuk 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();
}
}Asersi pada getQueryExecutionCount() memastikan optimasi tetap berlaku selama evolusi kode.
Sumber
- Hibernate 7.4 What's New - paginasi dengan fetch join kini aman di level database
- Spring Boot 4.1 Release Notes - menyertakan Hibernate 7.4.5.Final
- Hibernate ORM Documentation - referensi resmi untuk strategi fetch dan EntityGraph
Daftar Periksa Pencegahan N+1 untuk Spring Data JPA
Masalah N+1 merupakan tantangan performa besar di Spring Data JPA, namun beberapa solusi efektif tersedia. Fetch join dan @EntityGraph menyelesaikan sebagian besar kasus dengan memuat asosiasi dalam satu kueri. Batch fetching menawarkan alternatif untuk koleksi besar di mana fetch join menghasilkan produk Cartesian. Dengan Hibernate 7.4, paginasi dengan fetch join akhirnya bekerja di level database.
Yang harus diterapkan di produksi:
- Aktifkan log SQL di pengembangan untuk mendeteksi kueri ganda
- Gunakan
JOIN FETCHuntuk relasi@ManyToOneyang sering diakses - Terapkan
@EntityGraphuntuk pemuatan deklaratif yang dapat digunakan ulang - Konfigurasi
@BatchSizeuntuk koleksi@OneToManyyang besar - Utamakan proyeksi DTO untuk operasi hanya-baca
- Tulis tes yang memverifikasi jumlah kueri yang dieksekusi
- Nonaktifkan log SQL dan statistik di produksi
- Profil endpoint kritis secara teratur dengan metrik Hibernate
- Upgrade ke Spring Boot 4.1+ untuk memanfaatkan perbaikan paginasi Hibernate 7.4
Bisakah kamu menemukan bug di Spring Boot?
Satu potongan kode nyata, satu bug tersembunyi, satu percobaan per hari. Tanpa akun untuk mencoba.

Ditulis oleh
Anthony Fillion-MailletPendiri SharpSkill
Developer fullstack selama lebih dari 10 tahun. Ia menjalankan SharpSkill dan bertanggung jawab atas semua yang diterbitkan di sini.
Diperbarui 2 September 2026
Tag
Bagikan
Artikel terkait

Wawancara Spring GraphQL: Resolver, DataLoader, dan Solusi Masalah N+1
Persiapkan diri untuk wawancara Spring GraphQL dengan panduan lengkap ini. Resolver, DataLoader, penanganan masalah N+1, mutation, dan praktik terbaik untuk pertanyaan teknis.

Spring Boot 3.4 Virtual Threads: Pertanyaan Wawancara dan Benchmark Performa
Kuasai Java 21 Virtual Threads dengan Spring Boot 3.4: 15 pertanyaan wawancara, benchmark performa, dan pola migrasi untuk lulus wawancara teknis.

Observability Spring Boot 2026: OpenTelemetry, Distributed Tracing, dan Pertanyaan Interview
Pelajari observability Spring Boot dengan OpenTelemetry dan Micrometer Tracing. Panduan lengkap konfigurasi distributed tracing, Observation API, ekspor OTLP, serta persiapan interview teknis.