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.

Mengatasi masalah N+1 dengan Spring Data JPA menggunakan fetch join dan EntityGraph

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.

Dampak Nyata N+1

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.

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
}

Ketika sebuah kueri mengambil pesanan lalu mengakses nama pelanggan, Hibernate mengeksekusi kueri tambahan untuk setiap pesanan.

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

Untuk 100 pesanan, kode ini mengeksekusi 101 kueri SQL. Log Hibernate mengungkap pola destruktif ini.

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

Mendeteksi 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.

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

Statistik Hibernate memberikan ringkasan berharga di akhir setiap transaksi.

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

Jumlah 101 statement JDBC untuk daftar pesanan sederhana jelas menandakan masalah N+1.

Nonaktifkan di Produksi

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.

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 mengubah N+1 kueri menjadi satu kueri yang dioptimalkan.

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

Service kini menggunakan metode yang dioptimalkan tanpa memodifikasi kode bisnis.

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

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.

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

EntityGraph bernama didefinisikan langsung pada entitas untuk digunakan kembali di beberapa repository.

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
}

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:

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
}

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.

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

Dengan @BatchSize(size = 25), Hibernate memuat item untuk 25 pesanan sekaligus, bukan 1. Untuk 100 pesanan, jumlah kueri turun dari 101 menjadi 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)

Konfigurasi global ukuran batch berlaku untuk semua koleksi di aplikasi.

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

Batch 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.

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

SQL yang dihasilkan kini menggunakan subquery untuk paginasi di level database:

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
)

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.

StrategiKasus PenggunaanKeuntunganKerugian
Fetch JoinRelasi @ManyToOneKueri SQL tunggalProduk Cartesian dengan koleksi
@EntityGraphPemuatan deklaratifDapat digunakan ulang, mudah dibacaKurang fleksibel daripada JPQL
Batch FetchingKoleksi @OneToManyMenghindari produk CartesianBanyak kueri
SubselectKoleksi yang jarang diaksesMemuat hanya saat dibutuhkanSubkueri terkorelasi

Strategi subselect memuat seluruh koleksi pada akses pertama ke elemen mana pun.

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

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.

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

Pendekatan ini menghasilkan kueri SQL optimal tanpa overhead pemetaan entitas.

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

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.

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

Pemuatan kondisional memungkinkan penerapan strategi berbeda berdasarkan konteks.

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

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.

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

Asersi pada getQueryExecutionCount() memastikan optimasi tetap berlaku selama evolusi kode.

Sumber

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 FETCH untuk relasi @ManyToOne yang sering diakses
  • Terapkan @EntityGraph untuk pemuatan deklaratif yang dapat digunakan ulang
  • Konfigurasi @BatchSize untuk koleksi @OneToMany yang 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
Tantangan harian

Bisakah kamu menemukan bug di Spring Boot?

Satu potongan kode nyata, satu bug tersembunyi, satu percobaan per hari. Tanpa akun untuk mencoba.

Anthony Fillion-Maillet

Ditulis oleh

Anthony Fillion-Maillet

Pendiri 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

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

Bagikan

Artikel terkait