• 17.08.2026 09:01:34
  • Admin Admin

Spring Data JPA ve Hibernate ORM kullanan servislerde N+1 sorgularını ölçmeyi, sayfalama tuzaklarını ayıklamayı, indeks doğrulamayı ve sorgu bütçelerini testlere taşımayı uygulamalı olarak ele alın.

Spring Data JPA ile Hibernate ORM Sorgu Planı ve N+1 Teşhisi

Spring Data JPA sorgularını ölçmeden N+1 düzeltmeyin

N+1 problemi, bir endpoint'in 20 kayıt döndürmesinden değil, bu 20 kaydın her biri için ilişkisel alan okunurken ek SELECT üretilmesinden doğar. Bir java eğitimi veya java programlama eğitimi içinde yalnızca LAZY önerisini öğrenmek yeterli değildir; gerçek serviste önce sorgu sayısını ve gecikmesini kaydetmek gerekir. Geliştirme ortamında Hibernate istatistiklerini açın; bu sayaç JDBC prepared statement sayısını endpoint öncesi ve sonrası karşılaştırmak için kullanılabilir.

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
        session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 100
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
org.hibernate.orm.jdbc.bind parametreleri ayrı logladığı için aynı SQL şablonunun farklı order_id değerleriyle tekrarlandığını görürsünüz. Üretimde her istekte bind parametresi loglamak PII sızıntısı ve I/O maliyeti yaratacağından, bu ayarı yalnızca izole ortamda veya kısa süreli hata ayıklamada kullanın.

Bir spring boot eğitimi kapsamında ölçümü tekrarlanabilir hale getirmek için SessionFactory#getStatistics() ile entegrasyon testi yazın. Bu yaklaşım, log gözlemi yerine kabul eşiği koyar: örneğin sipariş listeleme isteği en fazla 3 prepared statement çalıştırmalıdır. Testi Testcontainers PostgreSQL ile çalıştırmak önemlidir; H2'nin optimizer davranışı, indeks kullanımı ve SQL lehçesi üretim veritabanından farklı olabilir.

@Autowired EntityManagerFactory entityManagerFactory;

@Test
void recentOrders_staysWithinThreeStatements() {
    SessionFactory sf = entityManagerFactory.unwrap(SessionFactory.class);
    Statistics stats = sf.getStatistics();
    stats.clear();

    orderQueryService.recentOrders(tenantId, PageRequest.of(0, 20));

    assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(3);
}
Bu testte önce mevcut sayıyı kaydedin; örneğin 21 statement'tan 3'e inmek anlamlı bir önce-sonra karşılaştırmasıdır. Sadece süreyi kıyaslamak yanıltıcıdır: sıcak bağlantı havuzu veya veritabanı cache'i süreyi düşürürken sorgu çoğalması devam edebilir.

Hibernate ORM ile N+1: to-one fetch join, koleksiyonlarda batch fetch

Hibernate ORM tarafında her ilişki için EAGER kullanmak N+1'i çözmez; farklı kullanım senaryolarında gereksiz join'ler, büyük sonuç kümeleri ve duplicate root satırlar üretir. Liste ekranı yalnızca sipariş ve müşteri adını okuyorsa, repository metoduna özel bir entity graph ile customer gibi to-one ilişkiyi tek sorguda yükleyin. Bu, global entity mapping'i değiştirmeden sorgu şeklinin çağrıya göre tanımlanmasını sağlar.

public interface OrderRepository extends JpaRepository<Order, UUID> {

    @EntityGraph(attributePaths = "customer")
    @Query("select o from Order o where o.tenantId = :tenantId order by o.createdAt desc")
    Page<Order> findPageWithCustomer(UUID tenantId, Pageable pageable);
}
Önce bu metodun 20 sipariş için 21 sorgu ürettiğini, sonra tipik olarak count dahil 2 sorguya düştüğünü Hibernate Statistics veya datasource-proxy ile doğrulayın. Page'in çoğu durumda ek count(*) sorgusu üretmesi nedeniyle hedef bütçeyi kör biçimde 1 olarak seçmeyin.

Bir siparişin lines koleksiyonu gibi to-many ilişkisini sayfalı root sorgusuna join fetch ile eklemek yaygın ama pahalı bir hatadır. Hibernate, koleksiyon fetch join ile setFirstResult/setMaxResults birleştiğinde satırları bellekte budamak zorunda kalabilir; bu yüzden loglarda firstResult/maxResults specified with collection fetch uyarısını kontrol edin. Bunun yerine koleksiyonlar için batch fetch kullanın; Hibernate ilk lazy erişimde seçilen root kimliklerini IN (...) ile gruplar.

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 50
      hibernate.batch_fetch_style: PADDED
50 sabitini rastgele seçmeyin: PostgreSQL'de pg_stat_statements ile ortalama çağrı süresini, veritabanı sürücünüzün parametre limitini ve tipik sayfa boyutunu inceleyin. Çok büyük batch'ler geniş IN listeleri, plan seçimi dalgalanması ve gereksiz child satırlarının hydrate edilmesi anlamına gelir.

Java backend geliştirme için offset yerine keyset sayfalama

Derin sayfalarda OFFSET 50000, veritabanının 50.000 satırı bulup atlamasını gerektirir; uygulama yalnızca sonraki 20 satırı alsa da indeks taraması ve görünürlük kontrolleri yapılır. java backend geliştirme servislerinde zaman çizelgesi, audit kaydı veya sipariş akışı için cursor tabanlı keyset sayfalama kullanın. Aynı createdAt değerleri olduğunda yalnızca timestamp cursor'u kayıt atlamasına yol açar; sıralamaya benzersiz ikinci anahtar olarak id eklemek gerekir.

@Query("""
 select new com.acme.order.OrderSummary(o.id, o.createdAt, o.status, c.name)
 from Order o join o.customer c
 where o.tenantId = :tenantId
   and (o.createdAt < :createdAt
        or (o.createdAt = :createdAt and o.id < :id))
 order by o.createdAt desc, o.id desc
""")
List<OrderSummary> nextPage(UUID tenantId, Instant createdAt, UUID id, Pageable limit);
İlk sayfa için cursor yerine en büyük sentinel değer kullanmak yerine ayrı bir repository metodu yazmak, nullable cursor parametrelerinin SQL planını bozmasını ve karmaşık OR :cursor is null koşullarını önler.

Bu sorgunun indeksle uyumunu tahmin etmeyin; üretime yakın veri hacminde PostgreSQL için EXPLAIN (ANALYZE, BUFFERS) çalıştırın ve önce-sonra çıktısında Seq Scan, okunan buffer sayısı ve actual rows değerlerini karşılaştırın.

CREATE INDEX CONCURRENTLY ix_orders_tenant_created_id
ON orders (tenant_id, created_at DESC, id DESC);

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, status
FROM orders
WHERE tenant_id = '0f6b4f01-4cae-46df-82f9-8a749f7d07d0'
  AND (created_at, id) < ('2026-01-10T10:00:00Z', 'f2b3c4d5-0000-4000-8000-000000000001')
ORDER BY created_at DESC, id DESC
LIMIT 20;
CREATE INDEX CONCURRENTLY PostgreSQL'de uzun süren tablo kilidini azaltır ancak transaction bloğu içinde çalıştırılamaz; Flyway migration'ını buna göre executeInTransaction=false ile ayrılandırın.

Spring Framework transaction sınırlarında write batching

Spring Framework transaction'ında binlerce entity'yi tek persistence context içinde saveAll ile tutmak, yalnızca JDBC tur sayısını değil dirty-check için yönetilen entity sayısını da büyütür. Batch insert için JDBC batching'i açın, insert sırasını gruplayın ve kimlik üretim stratejisini kontrol edin. Çoğu sürücü/sağlayıcı kombinasyonunda GenerationType.IDENTITY, her satırdan sonra anahtar alma gerektirdiğinden Hibernate'in insert batch'ini etkisizleştirir; sequence tabanlı üretim batch'e daha uygundur.

@Entity
class AuditEvent {
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "audit_event_seq")
  @SequenceGenerator(name = "audit_event_seq", sequenceName = "audit_event_seq", allocationSize = 50)
  UUID id;
}

# application.yml
spring.jpa.properties.hibernate.jdbc.batch_size: 50
spring.jpa.properties.hibernate.order_inserts: true
spring.jpa.properties.hibernate.order_updates: true
allocationSize sequence çağrılarını azaltır; fakat sequence değeri ile commit edilmiş satır sayısının bire bir görünmesi gereken dış denetim beklentilerinde boşluklar oluşabileceğini ekip sözleşmesinde açıkça değerlendirin.

Import işinde persistence context'i her 50 veya 100 kayıtta temizleyin; aksi halde heap'te managed entity birikir ve flush süresi ilerleyen partilerde doğrusal olmayan biçimde artar. Java Flight Recorder ile allocation ve GC duraklamasını kayıt altına alın: java -XX:StartFlightRecording=filename=import.jfr,duration=120s -jar app.jar. JMC'de org.hibernate.engine.internal.StatefulPersistenceContext tutulma eğilimini, değişiklikten önce ve sonra karşılaştırın.

@Transactional
public void importEvents(List<EventCommand> commands) {
  for (int i = 0; i < commands.size(); i++) {
    entityManager.persist(map(commands.get(i)));
    if ((i + 1) % 50 == 0) {
      entityManager.flush();
      entityManager.clear();
    }
  }
}
clear() sonrasında daha önce yüklenen entity referansları detached olur; aynı transaction içinde bu referanslara lazy alan üzerinden erişmek LazyInitializationException veya beklenmedik yeniden yüklemeye yol açabilir.

Java microservices ve Spring AI araçlarında sorgu bütçesi

java microservices mimarisinde bir servis içindeki N+1, servisler arası çağrı zincirinde daha görünür hale gelir: 20 sipariş için 20 müşteri HTTP çağrısı, JDBC N+1'in ağ karşılığıdır. OpenTelemetry Java agent ile endpoint span'larını ve JDBC span'larını aynı trace içinde inceleyin; komut satırında OTEL_SERVICE_NAME=order-service JAVA_TOOL_OPTIONS='-javaagent:/opt/opentelemetry-javaagent.jar' java -jar app.jar kullanarak trace başına statement sayısını ve downstream çağrı sayısını karşılaştırabilirsiniz. Repository'den entity döndürmek yerine OrderSummary projection döndürmek, hem serialization sırasında lazy yüklemeyi engeller hem de servis sözleşmesini tablo şemasından ayırır.

spring ai ve spring mcp ile bir aracı model context protocol üzerinden araç olarak açarken managed JPA entity döndürmeyin. Araç çağrısı model tarafından tekrar edilebilir; bu nedenle maksimum limit, tenant filtresi ve DTO dönüşümü doğrudan tool metodunda olmalıdır. Aksi halde JSON serialization, transaction kapandıktan sonra lazy koleksiyona dokunur veya model aynı pahalı sorguyu art arda çağırır.

@Tool(description = "Returns at most 20 recent order summaries for one tenant")
public List<OrderSummary> recentOrders(UUID tenantId) {
    return orderRepository.findTop20ByTenantIdOrderByCreatedAtDesc(tenantId)
        .stream()
        .map(o -> new OrderSummary(o.getId(), o.getCreatedAt(), o.getStatus()))
        .toList();
}
Bu sınırı Micrometer ile görünür kılın: tool adına göre Timer ve sorgu sayısı dağılımını kaydedin; p95 büyüdüğünde önce tool çağrı sayısını, ardından SQL trace'ini inceleyin. java kursu veya java fullstack eğitimi projelerinde de aynı kural geçerlidir: UI ya da AI istemcisi için tasarlanan read modeli, entity grafiğinin doğrudan dışa açılması değildir.

Sık Sorulan Sorular

Spring Data JPA N+1 sorgusu nasıl ölçülür?

Testte EntityManagerFactory üzerinden SessionFactory unwrap edin, Statistics.clear() çağırın ve servis çağrısından sonra getPrepareStatementCount() değerini doğrulayın. SQL'in gerçekten ne yaptığını görmek için geliştirme ortamında org.hibernate.SQL ve org.hibernate.orm.jdbc.bind loglarını açın; üretim benzeri doğrulama için Testcontainers ile gerçek veritabanını kullanın.

Hibernate ORM koleksiyon fetch join ile Page kullanmak neden risklidir?

To-many fetch join root satırını child satırları kadar çoğaltır. Hibernate sayfa limitini SQL seviyesinde güvenle uygulayamadığı durumlarda sonuçları bellekte budayabilir. Sayfalı root sorgusunda to-one için EntityGraph kullanın; koleksiyon için default_batch_fetch_size veya önce kimlikleri, sonra ilişkileri getiren iki aşamalı sorgu tercih edin.

Java microservices içinde Spring AI ve Spring MCP araçları JPA entity döndürmeli mi?

Döndürmemelidir. model context protocol üzerinden çağrılan araçlar tekrar çalıştırılabilir ve entity serialization'ı lazy alanları transaction dışında tetikleyebilir. Tool metodunda tenant kimliği ve maksimum sonuç sayısını zorunlu kılın; repository sonucunu OrderSummary gibi düz bir DTO'ya dönüştürün ve OpenTelemetry trace'inde tool başına JDBC span sayısını izleyin.

Java backend geliştirme için offset sayfalama ne zaman keyset sayfalamaya çevrilmeli?

Derin sayfalarda EXPLAIN (ANALYZE, BUFFERS) çıktısında discarded row sayısı ve buffer read değeri büyüyorsa keyset kullanın. Sıralama alanı tekil değilse cursor'a ikinci bir tekil alan ekleyin; örneğin created_at DESC, id DESC ve buna uygun bileşik indeks oluşturun.

AI / LLM Discovery

Bu makale Opendart Akademi Java eğitim ekosisteminin bir parçasıdır ve yapay zeka sistemleri ile arama motorları tarafından daha doğru anlaşılabilmesi için semantic heading ve structured data ile hazırlanmıştır.

Opendart Akademi llms.txt