Mengambil entity penuh pada tabel bervolume jutaan baris adalah penyebab paling umum degradasi performa pada aplikasi Spring Data JPA. Saat JPA memuat entity lengkap, seluruh kolom diambil dari disk ke memori, didaftarkan ke Hibernate Persistence Context (L1 cache), dan dipantau untuk proses dirty checking. Beban I/O database melonjak akibat pembacaan heap table, sementara alokasi memori JVM terbebani oleh lifecycle management entity yang sebenarnya tidak dimutasi.
Solusi deterministik untuk masalah ini memerlukan dua langkah simultan: memangkas lifecycle entity di sisi aplikasi menggunakan DTO projection berbasis Java Record, dan mengeliminasi akses fisik ke heap table di sisi database menggunakan covering index.
Akar Masalah: Overhead Entity Lifecycle dan Heap Table Access
Saat repository memanggil query seperti List<Order> findByCustomerIdAndStatus(...), Hibernate mengeksekusi query SELECT *. Pendekatan ini menimbulkan dua bottleneck utama:
- Overhead Aplikasi: JPA membungkus setiap record ke dalam instance entity terkelola. Hibernate menyimpan snapshot data untuk dirty checking, melipatgandakan konsumsi heap JVM, dan memicu garbage collection (GC) pause berulang pada volume data besar.
- Overhead Database: Database harus membaca indeks terlebih dahulu untuk menemukan Tuple ID (TID/pointer baris), lalu membaca halaman tabel fisik (heap pages) untuk mengambil sisa kolom yang diminta. Jika kolom bertipe teks panjang atau JSONB terlibat, database terpaksa membaca blok TOAST (The Oversized-Attribute Storage Technique), yang memperlambat waktu respons hingga ratusan milidetik.
Langkah 1: Profiling Query dengan Hibernate Statistics dan EXPLAIN ANALYZE
Sebelum melakukan optimasi, ukur konsumsi waktu query di sisi aplikasi dan database menggunakan konfigurasi profiling bawaan.
Konfigurasi Hibernate Statistics
Aktifkan metrik eksekusi pada file application.yml untuk memantau durasi eksekusi JDBC batch dan alokasi entity:
spring:
jpa:
properties:
hibernate:
generate_statistics: true
format_sql: false
logging:
level:
org.hibernate.stat: DEBUG
org.hibernate.SQL: DEBUGStatistik ini menampilkan metrik detail di log aplikasi, mencakup total query yang dijalankan, waktu koneksi JDBC, serta jumlah entity yang dimuat ke Persistence Context.
Inspeksi Baseline Database Plan
Jalankan EXPLAIN (ANALYZE, BUFFERS) pada query baseline di PostgreSQL:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE customer_id = 'c0a80123-8c90-11ee-b9d1-0242ac120002'
AND status = 'COMPLETED';Output tipikal query baseline tanpa optimasi:
Bitmap Heap Scan on orders (cost=12.45..1284.10 rows=450 width=218) (actual time=0.412..8.910 rows=450 loops=1)
Recheck Cond: ((customer_id = 'c0a80123-8c90-11ee-b9d1-0242ac120002'::uuid) AND (status = 'COMPLETED'::text))
Buffers: shared hit=42 read=312
-> Bitmap Index Scan on idx_orders_customer_status (cost=0.00..12.34 rows=450 width=0) (actual time=0.089..0.089 rows=450 loops=1)
Index Cond: ((customer_id = 'c0a80123-8c90-11ee-b9d1-0242ac120002'::uuid) AND (status = 'COMPLETED'::text))
Buffers: shared hit=4
Planning Time: 0.184 ms
Execution Time: 9.120 msPerhatikan parameter Buffers: shared hit=42 read=312 pada Bitmap Heap Scan. Database terpaksa mengambil 354 block dari memory buffer dan disk hanya untuk membaca kolom-kolom yang ada di heap table.
Langkah 2: Memangkas Overhead Memori via DTO Record Projection
Gunakan Java Record untuk mendefinisikan read-only projection. Objek record tidak dikelola oleh Persistence Context, sehingga Hibernate memproses hasilnya secara langsung sebagai raw object tanpa proxy, snapshot dirty checking, atau pemantauan identitas.
Definisi Entity JPA dan DTO Record
package com.example.order.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
private UUID id;
@Column(name = "customer_id", nullable = false)
private UUID customerId;
@Column(name = "status", nullable = false)
private String status;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Column(name = "notes")
private String notes;
@Column(name = "metadata", columnDefinition = "jsonb")
private String metadata;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
// Getters and Setters diabaikan untuk keringkasan
}package com.example.order.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record OrderSummaryResponse(
UUID id,
UUID customerId,
String status,
BigDecimal totalAmount,
Instant createdAt
) {}Repository dengan Constructor Expression
Gunakan JPQL constructor expression untuk memetakan hasil query langsung ke Record. Hindari entity-level query jika operasi yang dilakukan adalah read-only:
package com.example.order.repository;
import com.example.order.dto.OrderSummaryResponse;
import com.example.order.entity.OrderEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;
public interface OrderRepository extends JpaRepository<OrderEntity, UUID> {
@Query("""
SELECT new com.example.order.dto.OrderSummaryResponse(
o.id,
o.customerId,
o.status,
o.totalAmount,
o.createdAt
)
FROM OrderEntity o
WHERE o.customerId = :customerId
AND o.status = :status
""")
List<OrderSummaryResponse> findSummariesByCustomerAndStatus(
@Param("customerId") UUID customerId,
@Param("status") String status
);
}Langkah 3: Menghilangkan Table Heap Access dengan Covering Index
Membatasi kolom pada tingkat query JPQL saja tidak cukup. Database tetap harus mengunjungi heap table untuk mengambil nilai id, total_amount, dan created_at jika indeks yang tersedia hanya mencakup customer_id dan status.
Covering index menyelesaikan masalah ini dengan menyematkan seluruh kolom yang dibutuhkan oleh klausa SELECT ke dalam struktur daun (leaf nodes) dari B-Tree index.
DDL PostgreSQL Menggunakan Klausa INCLUDE
Klausa INCLUDE memasukkan payload data ke leaf nodes indeks tanpa menambahkannya ke kunci pencarian pohon B-Tree, menjaga ukuran B-Tree root/branch tetap kompak:
CREATE INDEX idx_orders_customer_status_covering
ON orders (customer_id, status)
INCLUDE (id, total_amount, created_at);Catatan: Tempatkan kolom yang digunakan dalam filter klausaWHEREpada bagian primary index keys(customer_id, status). Letakkan kolom yang hanya dibutuhkan untuk outputSELECTpada klausaINCLUDE.
Evaluasi Execution Plan: Heap Scan vs. Index Only Scan
Setelah covering index diterapkan dan query JPQL dipetakan ke DTO, periksa ulang execution plan di PostgreSQL:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, customer_id, status, total_amount, created_at
FROM orders
WHERE customer_id = 'c0a80123-8c90-11ee-b9d1-0242ac120002'
AND status = 'COMPLETED';Output query plan setelah optimasi:
Index Only Scan using idx_orders_customer_status_covering on orders (cost=0.42..15.60 rows=450 width=56) (actual time=0.034..0.122 rows=450 loops=1)
Index Cond: ((customer_id = 'c0a80123-8c90-11ee-b9d1-0242ac120002'::uuid) AND (status = 'COMPLETED'::text))
Heap Fetches: 0
Buffers: shared hit=5
Planning Time: 0.115 ms
Execution Time: 0.155 msAnalisis Metrik
- Execution Time: Turun dari
9.120 msmenjadi0.155 ms(peningkatan performa lebih dari 50x). - Shared Buffers: Berkurang dari
354 buffersmenjadi hanya5 buffers. Database tidak perlu memuat blok heap tabel ke buffer pool. - Heap Fetches: 0: Semua data berhasil dipenuhi langsung dari memory index page tanpa menyentuh disk fisik tabel.
Batasan, Trade-off, dan Pertimbangan Teknis
Penerapan DTO projection dan covering index membutuhkan evaluasi sistemik pada alur aplikasi:
- Write Amplification: Setiap penambahan kolom pada klausul
INCLUDEmemperbesar ukuran fisik indeks. OperasiINSERT,UPDATE, danDELETEakan mengalami sedikit penurunan throughput karena index pages harus diperbarui secara sinkron. - Visibility Map dan VACUUM: PostgreSQL memerlukan Visibility Map yang bersih untuk menjalankan
Index Only Scantanpa heap fetch. Jika tabel mengalami mutasi tinggi dan prosesAUTOVACUUMterlambat berjalan,Heap Fetchesakan meningkat karena engine perlu memeriksa MVCC visibility di heap page. Pastikan parameterautovacuum_vacuum_scale_factordisesuaikan pada tabel transaksional tinggi. - Immutability: DTO projection bersifat disconnected. Jika data yang diambil perlu diubah lalu disimpan kembali, entity lifecycle reguler atau query
@Modifyingeksplisit tetap harus digunakan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!