JPA Query Optimization
JPA ve Hibernate kullanırken en büyük performans sorunları yanlış sorgu stratejilerinden kaynaklanır. N+1 problemi, gereksiz veri çekme, eksik index'ler — bunlar uygulamanızı dramatik şekilde yavaşlatır. Bu derste JPA'nın en kritik performans konularını problemi göster → çözümü göster yaklaşımıyla derinlemesine inceliyoruz.
Hibernate Statistics — Önce Ölçün
Optimizasyon yapabilmek için önce sorununuzu görmeniz gerekir. Hibernate Statistics ile kaç sorgu çalıştığını, ne kadar sürdüğünü görebilirsiniz:
# application.properties — Development ortamında aktif edin
spring.jpa.properties.hibernate.generate_statistics=true
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# Log seviyesini ayarlayın
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.stat=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACEHibernate Statistics çıktısı:
Session Metrics {
1234567 nanoseconds spent acquiring 12 JDBC connections;
987654 nanoseconds spent releasing 12 JDBC connections;
12345678 nanoseconds spent preparing 156 JDBC statements;
23456789 nanoseconds spent executing 156 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
0 nanoseconds spent performing 0 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
}💡 Eğer bir endpoint için 50+ JDBC statement görüyorsanız, büyük olasılıkla N+1 probleminiz var.
N+1 Problemi — Detaylı Senaryo
N+1, JPA'nın en yaygın ve en zararlı performans sorunudur. Bir ana sorgu (1) ve her sonuç için ayrı bir alt sorgu (N) çalıştırılır.
Senaryo: Sipariş listesini ürünleriyle birlikte göstermek istiyorsunuz.
// Entity'ler
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private LocalDateTime orderDate;
private OrderStatus status;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
private int quantity;
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
@ManyToOne
private Order order;
}// ❌ N+1 PROBLEM — 101 sorgu çalışır!
List<Order> orders = orderRepository.findAll(); // 1 sorgu: SELECT * FROM orders
for (Order order : orders) {
// Her order için 1 sorgu: SELECT * FROM order_items WHERE order_id = ?
List<OrderItem> items = order.getItems(); // N sorgu!
for (OrderItem item : items) {
// Her item için 1 sorgu: SELECT * FROM products WHERE id = ?
String productName = item.getProduct().getName(); // N×M sorgu!
}
}
// 100 sipariş × 5 ürün = 1 + 100 + 500 = 601 SQL sorgusu!SQL log'da göreceğiniz:
-- 1 sorgu
SELECT * FROM orders;
-- 100 sorgu (her order için)
SELECT * FROM order_items WHERE order_id = 1;
SELECT * FROM order_items WHERE order_id = 2;
SELECT * FROM order_items WHERE order_id = 3;
-- ... 97 tane daha
-- 500 sorgu (her item için)
SELECT * FROM products WHERE id = 1;
SELECT * FROM products WHERE id = 2;
-- ... 498 tane dahaN+1 Çözüm 1: JOIN FETCH
JPQL'de JOIN FETCH ile ilişkili entity'leri tek sorguda çekersiniz:
public interface OrderRepository extends JpaRepository<Order, Long> {
// ✅ Tek sorgu ile orders + items
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.items " +
"WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
// ✅ Birden fazla ilişki fetch
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.items i " +
"JOIN FETCH i.product " +
"JOIN FETCH o.customer " +
"WHERE o.status = :status")
List<Order> findByStatusWithDetails(@Param("status") OrderStatus status);
}Üretilen SQL:
SELECT DISTINCT o.*, i.*, p.*, c.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
JOIN products p ON i.product_id = p.id
JOIN customers c ON o.customer_id = c.id
WHERE o.status = ?
-- TEK SORGU!⚠️ Dikkat: Birden fazla
@OneToManycollection'ı aynı anda JOIN FETCH yapamazsınız — HibernateMultipleBagFetchExceptionfırlatır. Bu durumdaSetkullanın veya ayrı sorgularda çekin.
N+1 Çözüm 2: @EntityGraph
Spring Data JPA'nın declarative fetch stratejisi:
public interface OrderRepository extends JpaRepository<Order, Long> {
// Attribute paths ile
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(OrderStatus status);
// Named entity graph ile
@EntityGraph(value = "Order.withItemsAndCustomer")
List<Order> findAllByStatus(OrderStatus status);
}
// Named Entity Graph tanımı
@Entity
@NamedEntityGraph(
name = "Order.withItemsAndCustomer",
attributeNodes = {
@NamedAttributeNode(value = "items", subgraph = "items-product"),
@NamedAttributeNode("customer")
},
subgraphs = {
@NamedSubgraph(name = "items-product",
attributeNodes = @NamedAttributeNode("product"))
}
)
public class Order { ... }N+1 Çözüm 3: @BatchSize
Hibernate'e "lazy load yaparken tek tek değil, batch halinde yükle" dersiniz:
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 25) // 25'erli gruplar halinde yükle
private List<OrderItem> items;
}
// veya global ayar (tüm entity'ler için)
// application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25Batch size=25 ile, 100 order için:
-- Batch yok: 100 ayrı SELECT
SELECT * FROM order_items WHERE order_id = 1;
SELECT * FROM order_items WHERE order_id = 2;
...
-- Batch size=25 ile: 4 SELECT (100/25 = 4)
SELECT * FROM order_items WHERE order_id IN (1,2,3,...,25);
SELECT * FROM order_items WHERE order_id IN (26,27,...,50);
SELECT * FROM order_items WHERE order_id IN (51,52,...,75);
SELECT * FROM order_items WHERE order_id IN (76,77,...,100);Hangi çözümü ne zaman kullanmalı?
| Çözüm | Ne zaman | Avantaj | Dezavantaj |
|---|---|---|---|
| JOIN FETCH | İlişkili veriyi her zaman kullanacaksanız | En hızlı, tek sorgu | MultipleBag hatası, kartezyen çarpım |
| @EntityGraph | Farklı endpoint'lerde farklı fetch stratejisi | Declarative, esnek | Karmaşık subgraph tanımları |
| @BatchSize | Lazy load'u tamamen kaldıramıyorsanız | Global uygulanabilir, basit | Yine birden fazla sorgu (ama çok daha az) |
Projection — Sadece İhtiyacınız Olan Veriyi Çekin
Tüm entity'yi çekmek yerine sadece gerekli alanları çekmek büyük performans kazancı sağlar:
1. Interface-based Projection (en yaygın):
// Sadece gerekli alanları tanımlayın
public interface ProductSummary {
Long getId();
String getName();
BigDecimal getPrice();
}
// Spring Data otomatik olarak sadece bu 3 kolonu seçer
public interface ProductRepository extends JpaRepository<Product, Long> {
List<ProductSummary> findByCategory(String category);
// Üretilen SQL: SELECT id, name, price FROM products WHERE category = ?
// (Tüm kolonlar değil, sadece 3 kolon!)
}2. Class-based (DTO) Projection:
// DTO tanımlayın
public record ProductDTO(Long id, String name, BigDecimal price) {}
// JPQL ile DTO projection
@Query("SELECT new com.example.dto.ProductDTO(p.id, p.name, p.price) " +
"FROM Product p WHERE p.category = :category")
List<ProductDTO> findProductDTOsByCategory(@Param("category") String category);3. Dynamic Projection:
// Aynı metodu farklı projection'larla çağırın
public interface ProductRepository extends JpaRepository<Product, Long> {
<T> List<T> findByCategory(String category, Class<T> type);
}
// Kullanım
List<ProductSummary> summaries = repo.findByCategory("electronics", ProductSummary.class);
List<ProductDTO> dtos = repo.findByCategory("electronics", ProductDTO.class);💡 Performans etkisi: 20 kolonlu bir tabloda 3 kolon projection kullanmak, network transfer'ini %85 azaltabilir. Özellikle liste sorguları için projection kullanın.
Batch Operations — Toplu İşlemler
Batch Insert Konfigürasyonu:
# application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true@Service
@RequiredArgsConstructor
public class ProductBatchService {
private final EntityManager entityManager;
// ❌ YAVAŞ: Tek tek kaydetme
public void saveProductsSlow(List<Product> products) {
products.forEach(entityManager::persist); // Her biri ayrı INSERT
}
// ✅ HIZLI: Batch insert
@Transactional
public void saveProductsBatch(List<Product> products) {
int batchSize = 50;
for (int i = 0; i < products.size(); i++) {
entityManager.persist(products.get(i));
if (i > 0 && i % batchSize == 0) {
entityManager.flush(); // Batch'i gönder
entityManager.clear(); // Persistence context'i temizle (bellek)
}
}
entityManager.flush();
entityManager.clear();
}
}⚠️ Dikkat:
GenerationType.IDENTITYbatch insert'i devre dışı bırakır çünkü Hibernate her INSERT sonrası generated ID'yi almalıdır. Batch insert istiyorsanızSEQUENCEveyaTABLEstrategy kullanın.
// ❌ IDENTITY — batch insert çalışmaz
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ✅ SEQUENCE — batch insert çalışır
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "product_seq")
@SequenceGenerator(name = "product_seq", sequenceName = "product_seq",
allocationSize = 50)
private Long id;Second Level Cache (L2 Cache)
Hibernate, iki seviyeli cache yapısı sunar:
L1 Cache (First Level): Session/EntityManager seviyesinde, otomatik aktif, kapatılamaz.
L2 Cache (Second Level): SessionFactory seviyesinde, tüm session'lar paylaşır, konfigürasyon gerekir.
# L2 Cache aktifleştirme
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
spring.jpa.properties.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider
# Query cache (JPQL sonuçları için)
spring.jpa.properties.hibernate.cache.use_query_cache=true// Entity'yi cacheable olarak işaretle
@Entity
@Cacheable // JPA standard
@org.hibernate.annotations.Cache(
usage = CacheConcurrencyStrategy.READ_WRITE, // Hibernate specific
region = "products"
)
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
@OneToMany(mappedBy = "product")
@org.hibernate.annotations.Cache(
usage = CacheConcurrencyStrategy.READ_WRITE)
private List<Review> reviews;
}
// Query cache kullanımı
@Query("SELECT p FROM Product p WHERE p.category = :cat")
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Product> findByCategoryCached(@Param("cat") String category);CacheConcurrencyStrategy seçenekleri:
| Strateji | Açıklama | Kullanım |
|---|---|---|
| READ_ONLY | Hiç güncellenmez | Sabit veri (ülke listesi) |
| READ_WRITE | Okuma ağırlıklı, bazen güncelleme | Ürün katalog |
| NONSTRICT_READ_WRITE | Eventual consistency tolere edilir | Performans öncelikli |
| TRANSACTIONAL | JTA transaction'larda tam tutarlılık | Kritik iş verileri |
Read-Only Transactions
@Transactional(readOnly = true) kullanmak performansı artırır:
@Service
@RequiredArgsConstructor
public class ProductService {
// ✅ readOnly = true ile performans optimizasyonu
@Transactional(readOnly = true)
public List<Product> getAllProducts() {
return productRepository.findAll();
}
@Transactional(readOnly = true)
public ProductDTO getProduct(Long id) {
return productRepository.findById(id)
.map(this::toDTO)
.orElseThrow();
}
}readOnly = true ne yapar:
Hibernate dirty checking kapanır: Entity değişiklik takibi yapılmaz → CPU tasarrufu
Flush mode MANUAL olur: Otomatik flush yapılmaz → gereksiz UPDATE önlenir
DB replica'ya yönlendirilir: DataSource routing yapılandırıldıysa okuma replica'dan yapılır
JDBC hint gönderilir: Bazı DB driver'ları bunu optimize eder
💡 Okuma ağırlıklı uygulamalarda metotların %80+'ı readOnly olabilir. Default olarak readOnly tanımlayıp, sadece yazma metotlarını
readOnly = falseyapın.
Pagination Best Practices
Offset Pagination (geleneksel):
// Spring Data ile
Page<Product> page = productRepository.findAll(
PageRequest.of(pageNumber, 20, Sort.by("createdAt").descending()));
// Üretilen SQL
// SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 1000;Offset'in sorunu: Sayfa numarası arttıkça yavaşlar. OFFSET 100000 demek, veritabanının 100.000 satırı tarayıp atlaması demektir.
Sayfa 1: OFFSET 0 → Hızlı (0.5ms)
Sayfa 100: OFFSET 2000 → Kabul edilebilir (5ms)
Sayfa 5000: OFFSET 100000 → ÇOK YAVAŞ (500ms+)Keyset Pagination (Cursor-based) — Daha hızlı:
public interface ProductRepository extends JpaRepository<Product, Long> {
// İlk sayfa
@Query("SELECT p FROM Product p ORDER BY p.createdAt DESC, p.id DESC")
List<Product> findFirstPage(Pageable pageable);
// Sonraki sayfalar: cursor'dan sonrası
@Query("SELECT p FROM Product p " +
"WHERE (p.createdAt < :lastDate) " +
" OR (p.createdAt = :lastDate AND p.id < :lastId) " +
"ORDER BY p.createdAt DESC, p.id DESC")
List<Product> findNextPage(
@Param("lastDate") LocalDateTime lastDate,
@Param("lastId") Long lastId,
Pageable pageable);
}
// Kullanım
@GetMapping("/products")
public CursorPage<ProductDTO> getProducts(
@RequestParam(required = false) LocalDateTime cursor,
@RequestParam(required = false) Long cursorId,
@RequestParam(defaultValue = "20") int size) {
List<Product> products;
if (cursor == null) {
products = repo.findFirstPage(PageRequest.of(0, size + 1));
} else {
products = repo.findNextPage(cursor, cursorId, PageRequest.of(0, size + 1));
}
boolean hasNext = products.size() > size;
if (hasNext) products = products.subList(0, size);
Product last = products.get(products.size() - 1);
return new CursorPage<>(
products.stream().map(this::toDTO).toList(),
hasNext ? last.getCreatedAt() : null,
hasNext ? last.getId() : null
);
}Karşılaştırma:
| Özellik | Offset Pagination | Keyset Pagination |
|---|---|---|
| Performans | Sayfa arttıkça yavaşlar | Her zaman sabit hız |
| "Sayfa X'e git" | ✅ Destekler | ❌ Desteklemez |
| Veri tutarlılığı | Ekleme/silme sırasında kayıp/tekrar | Tutarlı |
| Uygulama | Basit | Daha karmaşık |
| Kullanım | Admin panel, az veri | Infinite scroll, mobil, API |
Index Stratejileri
-- WHERE koşullarında kullanılan kolonlara index ekleyin
CREATE INDEX idx_product_category ON products(category);
CREATE INDEX idx_product_status_date ON products(status, created_at DESC);
-- Composite index: kolon sırası önemli!
-- WHERE status = ? AND created_at > ? için:
CREATE INDEX idx_status_date ON orders(status, created_at);
-- (status önce çünkü eşitlik koşulu, created_at sonra çünkü aralık koşulu)// JPA ile index tanımlama
@Entity
@Table(name = "products", indexes = {
@Index(name = "idx_product_category", columnList = "category"),
@Index(name = "idx_product_status_date", columnList = "status, createdAt DESC"),
@Index(name = "idx_product_name", columnList = "name")
})
public class Product { ... }Yaygın Hatalar
❌ FetchType.EAGER kullanmak: Kullanılmayan ilişkiler bile yüklenir. Her zaman
LAZYkullanın.❌ SELECT * ile tüm kolonları çekmek: Projection kullanın, sadece gerekli kolonları seçin.
❌ Hibernate statistics'i kapamak: Kaç sorgu çalıştığını bilmeden optimize edemezsiniz.
❌ IDENTITY generation ile batch insert: Batch çalışmaz. SEQUENCE kullanın.
❌ Index olmadan büyük tabloda sorgulama: Full table scan → dakikalarca süren sorgular.
❌ Offset pagination ile derin sayfalama: Keyset pagination kullanın.
💡 Özet: N+1'i JOIN FETCH, @EntityGraph veya @BatchSize ile çözün. Projection ile sadece gerekli veriyi çekin. Batch insert için SEQUENCE strategy kullanın. L2 Cache ile sık okunan entity'leri cache'leyin.
readOnly = trueile okuma performansını artırın. Büyük listelerde Keyset pagination tercih edin.
AI Asistan
Sorularını yanıtlamaya hazır