Spring Data JPA ve Hibernate ORM kullanan servislerde N+1 sorgularını ölçülebilir biçimde teşhis edin, EntityGraph ve iki aşamalı yükleme ile düzeltin, sorgu sayısını testte regresyona karşı kilitleyin.
Spring Data JPA'da N+1 Regresyon Testi ve Fetch Planı Tasarımı
Spring Data JPA'da N+1 problemini sayı ile kanıtlamak
N+1 teşhisinde ilk adım logda çok sayıda SELECT görmek değil, aynı iş isteğinin kaç JDBC statement ürettiğini ölçmektir. Hibernate ORM istatistiklerini yalnızca test veya kontrollü bir staging profili altında açın; üretimde her session metriğini toplamak ek sayaç maliyeti ve gürültü yaratabilir. Spring Boot yapılandırmasında aşağıdaki iki ayar, entity fetch ve prepared statement sayılarını Hibernate Statistics üzerinden erişilebilir yapar.
spring:
jpa:
properties:
hibernate.generate_statistics: true
hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 100
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACEÖrneğin bir sipariş listeleme endpoint'i 50 sipariş döndürüyor ve her siparişin customer alanına lazy erişiyorsa, beklenen örüntü 1 sipariş sorgusu + en fazla 1 ilişkili müşteri sorgusu iken, kötü durumda 1 + 50 statement oluşur. Önce endpoint'i sabit bir veri setiyle çalıştırın, Hibernate Statistics içindeki prepareStatementCount değerini kaydedin; sonra fetch planını değiştirip aynı veri setiyle tekrar ölçün. Bu karşılaştırmada yalnızca süreye bakmayın: yerel makinede cache ve ağ gecikmesi sonucu maskeleyebilir, statement sayısı ise N+1 mekanizmasını doğrudan gösterir.
PostgreSQL kullanılıyorsa uygulama tarafındaki ölçümü veritabanı tarafında da doğrulayın. Test sırasında parametrelenmiş SQL'in normalize edilmiş biçimini pg_stat_statements üzerinden alın ve en pahalı sorgunun gerçekten indeks kullandığını EXPLAIN ile inceleyin.
SELECT calls, mean_exec_time, rows, query
FROM pg_stat_statements
WHERE query ILIKE '%from orders%'
ORDER BY calls DESC
LIMIT 10;
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.customer_id, o.created_at
FROM orders o
WHERE o.created_at > now() - interval '7 days'
ORDER BY o.created_at DESC
LIMIT 50;Bir fetch stratejisi statement sayısını düşürürken çok geniş bir join sonucu üretiyorsa, BUFFERS ve rows değerleri bunun bedelini görünür kılar.Hibernate ORM fetch planı: EntityGraph ne zaman doğru seçimdir?
Liste ekranında her Order için yalnızca tek bir Customer gerekiyorsa, ilişkiyi mapping seviyesinde EAGER yapmak yerine sorgu seviyesinde fetch planı tanımlayın. EAGER, entity başka bir kullanım akışında yüklendiğinde de ilişkiyi zorunlu kılar; EntityGraph ise maliyeti sadece bu repository metoduna bağlar. Aşağıdaki örnekte Order.customer bir @ManyToOne(fetch = FetchType.LAZY) olarak kalır ve yalnızca liste metodunda join ile yüklenir.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = "customer")
@Query("""
select o
from Order o
where o.status = :status
order by o.createdAt desc
""")
List<Order> findRecentByStatus(OrderStatus status);
}Buradaki mekanizma önemlidir: persistence context her Order için customer proxy'si oluşturur; serializer veya mapper proxy'nin getName metodunu çağırdığında Hibernate eksik state'i yüklemek için ayrı SELECT gönderir. EntityGraph ile customer satırları ana sorgunun fetch planına dahil edilir ve proxy başlatma çağrısı ek round-trip üretmez. Bu yaklaşım özellikle java backend geliştirme sırasında controller'da entity döndürme hatasını gizlemez; DTO'ya map etmeye devam edin ve mapping işlemini transaction sınırında tamamlayın.
Koleksiyon fetch join ile sayfalama, deneyimli ekiplerin sık düştüğü bir tuzaktır. Order.lines gibi bir @OneToMany koleksiyonunu Page<Order> sorgusunda fetch join yapmak, SQL seviyesinde satır çoğalttığı için Hibernate'in sayfalamayı bellekte uygulamasına veya eksik sayfa üretmesine yol açabilir. Bunun yerine önce sayfalı ID listesini alın, sonra ikinci sorguda ilişkileri yükleyin ve ilk sorgunun sırasını Java tarafında geri kurun.
Page<Long> page = orderRepository.findIdsByStatus(
OrderStatus.OPEN, PageRequest.of(pageNo, pageSize, Sort.by("createdAt").descending()));
List<Order> loaded = orderRepository.findWithLinesByIdIn(page.getContent());
Map<Long, Order> byId = loaded.stream()
.collect(Collectors.toMap(Order::getId, Function.identity()));
List<OrderSummary> result = page.getContent().stream()
.map(byId::get)
.map(OrderSummary::from)
.toList();Repository'deki ikinci sorgu select distinct o from Order o left join fetch o.lines where o.id in :ids olabilir. Bir entity'de iki List tabanlı bag koleksiyonunu aynı anda fetch join etmeyin; Hibernate MultipleBagFetchException atabilir ve cartesian çarpım riski oluşur.Spring Boot eğitimi için sorgu bütçesini entegrasyon testine dönüştürmek
Bir N+1 düzeltmesini kalıcı kılmanın güvenilir yolu, endpoint süresi için kırılgan bir eşik koymak değil, kritik use case için statement bütçesi koymaktır. @DataJpaTest içinde veriyi hazırladıktan sonra EntityManager'ı flush ve clear edin. Aksi halde aynı persistence context içindeki birinci seviye cache, production'da oluşacak SELECT'leri gizler. Ardından Hibernate Statistics sayacını sıfırlayıp repository metodunu çağırın.
@DataJpaTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
class OrderRepositoryTest {
@Autowired EntityManager entityManager;
@Autowired EntityManagerFactory entityManagerFactory;
@Autowired OrderRepository orderRepository;
@Test
void recent_orders_load_customer_with_one_statement() {
persistFixtureWith50OrdersAndCustomers();
entityManager.flush();
entityManager.clear();
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
Statistics statistics = sessionFactory.getStatistics();
statistics.clear();
List<Order> orders = orderRepository.findRecentByStatus(OrderStatus.OPEN);
orders.forEach(order -> order.getCustomer().getName());
assertThat(orders).hasSize(50);
assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(1);
}
}Bu testin kritik ayrıntısı, customer.getName çağrısını bilinçli olarak içermesidir. Sadece repository sonucunu assert etmek lazy proxy'leri başlatmaz ve test yanlış biçimde başarılı olur. Ayrıca H2 ile geçmek yerine, indeks davranışı, NULL sıralaması ve SQL dialect farklarını görmek için Testcontainers PostgreSQL kullanın. Maven komutunda yalnızca bu regresyon paketini CI'da ayrı çalıştırmak, kısa geri bildirim sağlar.
./mvnw -Dtest='*RepositoryTest' test
docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16Sorgu bütçesi sabit bir evrensel sayı değildir. findById gibi tek aggregate okuması için 1-2 statement makul olabilir; sayfalı iki aşamalı koleksiyon yüklemesinde bütçe çoğunlukla 2'dir. Test adında iş kuralını ve bütçeyi birlikte belirtin: loads_customer_with_one_statement. Böylece bir geliştirici yeni bir DTO alanı eklediğinde testin neden kırıldığını SQL logu açmadan anlayabilir.
Java microservices ortamında before-after profil ve üretim doğrulaması
Ölçümü iki aşamada yapın. İlk aşamada kontrollü yükte aynı endpoint'e sabit 50 öğelik istek gönderin ve Hibernate Statistics ile statement sayısını kaydedin. İkinci aşamada EntityGraph veya iki aşamalı yükleme sonrasında aynı yükü tekrarlayın. Örnek kabul kriteri şudur: statement sayısı 51'den 1'e düşecek, p95 gecikmesi ise aynı veritabanı ve aynı bağlantı havuzu ayarları altında raporlanacaktır. HTTP katmanını ölçmek için k6 ile tekrarlanabilir bir senaryo kullanın.
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 20,
duration: '60s',
thresholds: { http_req_duration: ['p(95)<250'] }
};
export default function () {
const response = http.get('http://localhost:8080/api/orders?status=OPEN');
check(response, { '200 returned': r => r.status === 200 });
}Java microservices içinde bu ölçümü tek başına uygulama metriğiyle yorumlamayın. JDBC bağlantı havuzu beklemesi, veritabanı CPU'su ve sorgu planı aynı anda değişebilir. HikariCP için active, pending ve acquire sürelerini Micrometer üzerinden izleyin; PostgreSQL tarafında pg_stat_statements calls ve mean_exec_time değerlerini aynı zaman penceresinde karşılaştırın. Statement sayısı düşerken pending bağlantı sayısı artıyorsa, sebep fetch planı değil, yetersiz maximumPoolSize veya uzun transaction olabilir.
Sorgu üretiminin çağrı zincirini görmek gerektiğinde Java Flight Recorder ile iki dakikalık profil alın; CPU örnekleri ve JDBC olayları, JSON serialization sırasında tetiklenen lazy loading'i ayırmaya yardım eder. JFR dosyasını Java Mission Control'de thread, socket ve JDBC event görünümleriyle inceleyin.
jcmd <pid> JFR.start name=order-read settings=profile duration=120s filename=/tmp/order-read.jfr
jfr summary /tmp/order-read.jfrBu profil, N+1'in bazen repository içinde değil, transaction açıkken çalışan MapStruct mapper veya Jackson serializer içinde ortaya çıktığını gösterebilir.Java eğitimi yolunda ORM sorgu okuryazarlığını konumlandırmak
Bir java eğitimi veya java programlama eğitimi kapsamında bu konuyu sadece annotation ezberi olarak ele almak eksik kalır; öğrencinin Hibernate Statistics, Testcontainers ve EXPLAIN (ANALYZE, BUFFERS) ile aynı use case'i ölçmesi gerekir. İyi bir java kursu ödevi, 50 parent ve 50 child fixture'ı üzerinde önce 51 statement üreten, sonra EntityGraph ile 1 statement üreten bir repository testi içermelidir. Bu çalışma, java fullstack eğitimi alan bir geliştiricinin arayüzdeki tablo alanının backend fetch planına maliyeti olduğunu somut olarak görmesini sağlar.
spring framework transaction sınırı, hibernate orm persistence context'i ve spring data jpa repository sorgusu birlikte değerlendirilmelidir. spring boot eğitimi içindeki pratikte endpoint DTO'su transaction dışında serialize edilecekse Open Session in View kapatılıp mapper'ın servis katmanında çalıştığı doğrulanmalıdır. Bu davranışı test etmek için spring.jpa.open-in-view=false ayarını ekleyin; eksik fetch planı varsa testte LazyInitializationException görülmesi, üretimde gizli SELECT üretilmesinden daha güvenlidir.
spring ai, spring mcp ve model context protocol kullanan ekiplerde araç çağrısı ile veri okuyan ajanlara doğrudan entity döndürmeyin. Araç metodu yalnızca ihtiyaç duyduğu DTO'yu ve limitli alanları dönmeli, örneğin findOrdersForAssistant(limit) sorgusu maksimum 20 kayıt ile sınırlandırılmalıdır. Böylece LLM'in çağırdığı bir araçta beklenmeyen ilişki gezinmesi, hem token'a çevrilen veri hacmini hem de veritabanı statement sayısını büyütmez. Bu sınırlandırma, java backend geliştirme için olduğu kadar model bağlamına veri taşıyan entegrasyonlar için de test edilebilir bir sözleşmedir.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Java Spring Boot ReactJS FullStack Eğitimi (Yıldız Teknik Üniversitesi SEM)
Sık Sorulan Sorular
Spring Data JPA N+1 sorgusu nasıl test edilir?
@DataJpaTest altında fixture kaydından sonra EntityManager.flush() ve clear() çağırın. SessionFactory Statistics sayacını clear() ile sıfırlayın, lazy ilişki alanına bilinçli erişin ve getPrepareStatementCount() için use case'e özel bir üst sınır assert edin.
Hibernate ORM EntityGraph mi fetch join mi kullanılmalı?
Tekil ilişkiyi bir liste sorgusunda yüklemek için @EntityGraph(attributePaths = "customer") okunabilir bir seçenektir. Koleksiyon içeren sayfalı sorgularda fetch join kullanmayın; önce sayfalı ID sorgusu, sonra IN ile fetch join uygulayın. Bu yöntem SQL satır çoğalmasını ve bellek içi sayfalamayı önler.
Spring Boot eğitimi sırasında Open Session in View kapatılmalı mı?
spring.jpa.open-in-view=false ile kapatıp DTO mapping işlemini servis transaction'ı içinde yapın. Eksik fetch edilmiş bir ilişki varsa LazyInitializationException testte görünür; açık OSIV ise serialization anında ek SQL göndererek N+1 problemini web katmanına taşır.
Java microservices için Hibernate sorgu performansı nasıl profillenir?
Önce Hibernate Statistics ile before-after prepare statement sayısını ölçün. Ardından aynı k6 yük senaryosunda p95 gecikmeyi karşılaştırın, PostgreSQL pg_stat_statements ile calls ve mean_exec_time değerlerini doğrulayın. Çağrı kaynağı belirsizse jcmd ile kısa bir JFR profili alıp Java Mission Control'de JDBC olaylarını inceleyin.
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.


