Tampilan
Kenapa penting
JPA membuat akses data terasa seperti memanggil getter biasa, dan justru di situlah bahayanya: satu baris order.getCustomer().getFullName() di dalam loop bisa diam-diam menjadi ratusan query. Di laptop dengan sepuluh baris data semuanya terasa cepat; di production dengan ribuan pesanan per halaman, endpoint yang sama menghabiskan connection pool dan membuat seluruh aplikasi melambat. Masalah performa backend paling sering bukan di Java, melainkan di jumlah dan bentuk query ke database. Bab ini membahas cara melihat query yang sebenarnya dijalankan, lalu cara memperbaikinya — dari sisi JPA maupun sisi PostgreSQL.
Konsep inti
Masalah N+1
Model yang dipakai sama dengan bab sebelumnya: Order punya customer (@ManyToOne(fetch = LAZY)) dan items (@OneToMany(mappedBy = "order"), default-nya LAZY).
java
// Potongan — terlihat polos, tetapi menjalankan 1 + N query
@Transactional(readOnly = true)
public List<String> orderLabels() {
List<Order> orders = orderRepository.findAll(); // 1 query: SELECT ... FROM orders
List<String> labels = new ArrayList<>();
for (Order order : orders) {
labels.add(order.getOrderNumber() + " " + order.getCustomer().getFullName()); // 1 query per customer
}
return labels;
}findAll() hanya memuat tabel orders. Field customer berisi proxy yang baru memuat datanya saat disentuh. Satu query untuk daftar ditambah N query untuk tiap relasi — itulah N+1. Simulasi Java murni berikut menghitung query dengan dua pendekatan:
java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class NPlusOneDemo {
record Customer(long id, String name) {}
record Order(long id, long customerId) {}
static int queryCount = 0;
static final Map<Long, Customer> CUSTOMERS = Map.of(
1L, new Customer(1, "Ani"), 2L, new Customer(2, "Budi"),
3L, new Customer(3, "Citra"), 4L, new Customer(4, "Dewi"));
static final List<Order> ORDERS = List.of(
new Order(101, 1), new Order(102, 2), new Order(103, 3), new Order(104, 4));
static List<Order> findAllOrders() { // SELECT * FROM orders
queryCount++;
return ORDERS;
}
static Customer findCustomerById(long id) { // SELECT * FROM customers WHERE id = ?
queryCount++;
return CUSTOMERS.get(id);
}
static Map<Long, Customer> findCustomersByIds(List<Long> ids) { // ... WHERE id IN (?, ?, ?)
queryCount++;
Map<Long, Customer> result = new HashMap<>();
for (Long id : ids) {
result.put(id, CUSTOMERS.get(id));
}
return result;
}
public static void main(String[] args) {
for (Order order : findAllOrders()) {
Customer customer = findCustomerById(order.customerId()); // satu query per pesanan
System.out.println(order.id() + " " + customer.name());
}
System.out.println("N+1 : " + queryCount + " query");
queryCount = 0;
List<Order> orders = findAllOrders();
List<Long> ids = new ArrayList<>();
for (Order order : orders) {
if (!ids.contains(order.customerId())) {
ids.add(order.customerId());
}
}
Map<Long, Customer> byId = findCustomersByIds(ids); // satu query untuk semua
for (Order order : orders) {
System.out.println(order.id() + " " + byId.get(order.customerId()).name());
}
System.out.println("Batch: " + queryCount + " query");
}
}Output:
text
101 Ani
102 Budi
103 Citra
104 Dewi
N+1 : 5 query
101 Ani
102 Budi
103 Citra
104 Dewi
Batch: 2 queryDengan 4 pesanan bedanya 5 lawan 2. Dengan 500 pesanan per halaman, 501 lawan 2 — dan setiap query membayar ongkos round-trip jaringan ke database. Semua teknik di bab ini adalah cara agar Hibernate melakukan versi kedua.
Mendeteksi: lihat SQL yang sebenarnya
Kamu tidak bisa memperbaiki yang tidak terlihat. Untuk development, nyalakan log SQL dan statistik Hibernate:
yaml
# application-dev.yml (potongan) — jangan dipakai di production
spring:
jpa:
show-sql: true # cetak SQL ke stdout; cara cepat
properties:
hibernate:
format_sql: true
generate_statistics: true # ringkasan jumlah statement per session
logging:
level:
org.hibernate.SQL: debug # alternatif show-sql lewat logger
org.hibernate.orm.jdbc.bind: trace # nilai parameter `?`Bila log menampilkan select ... from customers c1_0 where c1_0.id=? berulang puluhan kali setelah satu query orders, itu N+1. Dengan generate_statistics, setiap session menutup dengan blok ringkasan seperti ini (angka diringkas):
text
Session Metrics {
... nanoseconds spent preparing 51 JDBC statements;
... nanoseconds spent executing 51 JDBC statements;
...
}Cara yang lebih tahan lama adalah test yang mengunci jumlah query, sehingga N+1 yang muncul lagi langsung terdeteksi di CI:
java
// Potongan — di test integrasi
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.clear();
orderQueryService.orderLabels();
assertThat(stats.getPrepareStatementCount()).isEqualTo(1);Perbaikan 1: JOIN FETCH
JOIN FETCH di JPQL memerintahkan Hibernate memuat relasi dalam query yang sama:
java
// Potongan — OrderRepository
@Query("select o from Order o join fetch o.customer where o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);Hasilnya satu query dengan JOIN customers. Untuk koleksi (join fetch o.items), satu pesanan muncul di beberapa baris SQL; sejak Hibernate 6, entity root yang sama otomatis dideduplikasi sehingga distinct tidak lagi dibutuhkan untuk itu.
Perbaikan 2: @EntityGraph
Bila tidak ingin menulis JPQL, Spring Data menerima daftar relasi yang ikut dimuat lewat @EntityGraph:
java
// Potongan — OrderRepository
@EntityGraph(attributePaths = {"customer", "items"})
Optional<Order> findWithDetailsById(Long id);Efeknya setara fetch join, tetapi dideklarasikan per method. Cocok untuk halaman detail yang butuh satu pesanan lengkap. Hindari memasang dua koleksi List sekaligus — lihat jebakan di bawah.
Perbaikan 3: batch fetching
Batch fetching tidak menggabungkan query, tetapi memuat proxy secara berkelompok dengan IN (...) — persis versi kedua di simulasi tadi:
yaml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 50Saat proxy customer pertama disentuh, Hibernate memuat sampai 50 customer yang tertunda sekaligus. N+1 menjadi kira-kira 1 + N/50 query tanpa mengubah kode repository. Untuk relasi tertentu saja, pakai @BatchSize(size = 50) dari org.hibernate.annotations. Banyak tim menyalakan setting global ini sebagai jaring pengaman.
Projection DTO untuk read-only
Entity managed membawa ongkos: snapshot untuk dirty checking dan semua kolom terbaca. Untuk tampilan read-only, ambil langsung bentuk yang dibutuhkan:
java
// Potongan
public record OrderSummary(Long id, String orderNumber, String customerName, BigDecimal total) {}
@Query("""
select new com.example.shop.order.OrderSummary(o.id, o.orderNumber, c.fullName, o.total)
from Order o join o.customer c
where o.status = :status
""")
List<OrderSummary> findSummariesByStatus(@Param("status") OrderStatus status);Perhatikan join, bukan join fetch: tidak ada entity yang dimuat, hanya empat kolom. Constructor expression perlu nama class lengkap (dengan package).
Index dan EXPLAIN ANALYZE
Query yang jumlahnya sudah benar bisa tetap lambat bila database harus membaca seluruh tabel. PostgreSQL membuat index otomatis untuk primary key dan UNIQUE, tetapi tidak untuk foreign key. Pada tabel orders berisi 300.000 baris tanpa index di customer_id:
sql
EXPLAIN ANALYZE SELECT id, status, total FROM orders WHERE customer_id = 4242;text
Gather (cost=1000.00..6012.88 rows=30 width=19) (actual time=0.502..17.202 rows=30 loops=1)
Workers Planned: 1
Workers Launched: 1
-> Parallel Seq Scan on orders (cost=0.00..5009.88 rows=18 width=19) (actual time=0.421..9.236 rows=15 loops=2)
Filter: (customer_id = 4242)
Rows Removed by Filter: 149985
Planning Time: 0.149 ms
Execution Time: 17.242 msSeq Scan dan Rows Removed by Filter yang besar menandakan seluruh tabel dibaca untuk 30 baris. Index ditambahkan sebagai migrasi, seperti di bab Flyway:
sql
-- V6__index_orders_customer_id.sql
CREATE INDEX idx_orders_customer_id ON orders (customer_id);Setelahnya, query yang sama:
text
Bitmap Heap Scan on orders (cost=4.53..115.60 rows=30 width=19) (actual time=0.053..0.088 rows=30 loops=1)
Recheck Cond: (customer_id = 4242)
Heap Blocks: exact=30
-> Bitmap Index Scan on idx_orders_customer_id (cost=0.00..4.52 rows=30 width=0) (actual time=0.043..0.043 rows=30 loops=1)
Index Cond: (customer_id = 4242)
Planning Time: 0.161 ms
Execution Time: 0.108 msDari 17 ms menjadi 0,1 ms (diukur di PostgreSQL 17 lokal). Ingat, EXPLAIN ANALYZE benar-benar menjalankan query — untuk UPDATE/DELETE, bungkus dengan BEGIN; ... ROLLBACK;. Index juga punya harga: setiap insert dan update ikut memperbarui index, jadi tambahkan berdasarkan query nyata, bukan untuk semua kolom.
Tabel besar di production
CREATE INDEX biasa mengunci penulisan ke tabel selama index dibangun. Untuk tabel besar yang sedang dipakai, PostgreSQL punya CREATE INDEX CONCURRENTLY, tetapi perintah itu tidak boleh berjalan di dalam transaksi — padahal Flyway membungkus migrasi dalam transaksi. Baca bagian PostgreSQL di dokumentasi Flyway sebelum memakainya.
Open-in-view: matikan
Secara default Spring Boot mengaktifkan Open EntityManager in View untuk aplikasi web: EntityManager tetap terbuka sampai response selesai ditulis, dan saat start Spring Boot mencatat peringatan tentang hal ini. Akibatnya, relasi lazy masih bisa dimuat di controller atau saat Jackson menyerialisasi entity — di luar transaksi, satu query per akses, tanpa terlihat di kode service. Koneksi database juga tertahan selama seluruh request, termasuk saat menunggu klien lambat.
yaml
spring:
jpa:
open-in-view: falseSetelah dimatikan, akses lazy di luar transaksi melempar LazyInitializationException. Itu bagus: kebutuhan data harus dinyatakan eksplisit di service (fetch join, entity graph, projection), dan controller mengembalikan DTO.
Jebakan pagination dengan fetch join
Kombinasi Pageable dengan fetch join koleksi tampak benar, tetapi berbahaya:
java
// Potongan — JANGAN: pagination diterapkan di memori
@Query(value = "select o from Order o join fetch o.items where o.status = :status",
countQuery = "select count(o) from Order o where o.status = :status")
Page<Order> findPageWithItems(@Param("status") OrderStatus status, Pageable pageable);LIMIT 20 di SQL akan memotong baris hasil join, bukan pesanan. Maka Hibernate mengambil semua baris yang cocok, mencatat peringatan HHH90003004 (firstResult/maxResults specified with collection fetch; applying in memory), lalu memotong halaman di Java. Dengan jutaan baris, heap habis. Pasang spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true agar kasus ini menjadi error, bukan sekadar peringatan. Solusinya dua langkah:
java
// Potongan — OrderRepository
@Query("select o.id from Order o where o.status = :status")
Page<Long> findIdPageByStatus(@Param("status") OrderStatus status, Pageable pageable);
@Query("select o from Order o join fetch o.items where o.id in :ids")
List<Order> findWithItemsByIdIn(@Param("ids") List<Long> ids);Query pertama dipaginasi di database (LIMIT/OFFSET pada ID), query kedua memuat detail untuk ID halaman itu saja. Fetch join ke relasi @ManyToOne (join fetch o.customer) tidak bermasalah dengan pagination karena tidak menggandakan baris. Alternatif paling sederhana: pagination biasa tanpa fetch join, ditambah default_batch_fetch_size.
Contoh di dunia backend
Service daftar pesanan untuk dashboard admin: halaman ID dipaginasi di database, detail dimuat dengan fetch join, urutan dikembalikan sesuai halaman, lalu dipetakan ke DTO di dalam transaksi read-only.
java
// Potongan
public record OrderRow(String orderNumber, String customerName, int itemCount, BigDecimal total) {}
@Service
public class AdminOrderQueryService {
private final OrderRepository orders;
public AdminOrderQueryService(OrderRepository orders) {
this.orders = orders;
}
@Transactional(readOnly = true)
public Page<OrderRow> paidOrders(Pageable pageable) {
Page<Long> idPage = orders.findIdPageByStatus(OrderStatus.PAID, pageable); // query 1 (+ count)
Map<Long, Order> byId = new HashMap<>();
for (Order order : orders.findWithItemsByIdIn(idPage.getContent())) { // query 2
byId.put(order.getId(), order);
}
// customer dimuat lewat default_batch_fetch_size: 1 query IN (...) untuk satu halaman
return idPage.map(id -> {
Order order = byId.get(id);
return new OrderRow(order.getOrderNumber(), order.getCustomer().getFullName(),
order.getItems().size(), order.getTotal());
});
}
}Untuk satu halaman berisi 20 pesanan, jumlah query tetap: ID + count + detail + satu batch customer — tidak tumbuh seiring ukuran halaman. Map dipakai karena IN (...) tidak menjamin urutan; urutan halaman diambil dari idPage.
Pertanyaan interview
1. Apa itu masalah N+1 dan bagaimana mendeteksinya?
Satu query memuat daftar entity, lalu setiap akses ke relasi lazy memicu satu query tambahan — total 1 + N. Deteksi lewat log SQL (org.hibernate.SQL atau show-sql) yang menampilkan query sama berulang, lewat Hibernate statistics, atau test yang mengunci jumlah statement agar regresi tertangkap di CI.
2. Kapan memakai JOIN FETCH, @EntityGraph, batch fetching, atau projection?
JOIN FETCH dan @EntityGraph saat kamu butuh entity beserta relasi tertentu dalam satu query — entity graph lebih deklaratif. Batch fetching sebagai jaring pengaman global dan untuk koleksi yang tidak cocok di-join, terutama saat pagination. Projection DTO untuk tampilan read-only yang hanya butuh beberapa kolom.
3. Kenapa spring.jpa.open-in-view sebaiknya dimatikan?
Karena query lazy bisa berjalan diam-diam di controller atau saat serialisasi JSON, di luar transaksi, sehingga N+1 tersembunyi dari kode service. Koneksi database juga tertahan sepanjang request. Dengan dimatikan, kebutuhan data harus dimuat eksplisit di service, dan controller mengembalikan DTO.
Jebakan umum
- Mengganti
LAZYkeEAGERuntuk "memperbaiki" N+1. Relasi selalu dimuat di semua query, dan untuk JPQL tetap bisa menjadi N+1. TetapLAZY, lalu fetch sesuai kebutuhan per use case. - Fetch join dua koleksi
Listsekaligus (itemsdanpayments) → Hibernate melemparMultipleBagFetchException, dan bila dipaksa denganSet, hasilnya produk kartesius. Muat satu koleksi per query atau pakai batch fetching. Pageable+ fetch join koleksi → pagination di memori (HHH90003004). Paginasi ID dulu.- Mengandalkan open-in-view → N+1 tersembunyi di controller dan serialisasi JSON.
- Lupa index pada foreign key → PostgreSQL tidak membuatnya otomatis; join dan filter jadi Seq Scan.
- Menyalakan
show-sqldan log bind parameter di production → log membengkak dan nilai sensitif ikut tercatat.
Ringkasan
- N+1 = satu query daftar + satu query per relasi lazy; deteksi lewat log SQL, statistics, dan test jumlah query.
JOIN FETCHdan@EntityGraphmemuat relasi dalam satu query;default_batch_fetch_sizememuat proxy per kelompok.- Projection DTO (
select new ...) paling ringan untuk tampilan read-only. EXPLAIN ANALYZEmenunjukkan Seq Scan vs Index Scan; index (terutama foreign key) ditambah lewat migrasi Flyway.- Matikan
spring.jpa.open-in-viewdan kembalikan DTO dari controller. - Jangan paginasi query dengan fetch join koleksi; ambil halaman ID dulu, lalu detailnya.