Spring Boot uygulamasında N+1 sorgularını tahminle değil, Hibernate istatistikleri ve PostgreSQL ölçümleriyle saptayın. Spring MVC liste uçlarında EntityGraph, iki aşamalı sayfalama ve batch fetch seçimini karşılaştırın.
Spring Boot'ta JPA N+1 Sorgularını Ölçerek ve Fetch Planıyla Çözmek
Spring Framework eğitimi bağlamında N+1'i sayarak teşhis etmek
N+1, bir liste sorgusundan sonra her satırın lazy ilişkisi için ayrı SQL üretilmesidir. Bir spring framework eğitimi veya java spring eğitimi sırasında bunun yalnızca logdaki çok sayıda select ile teşhis edilmesi yanıltıcıdır: sorgu sayısını uç nokta başına ölçün. Test profilinde Hibernate istatistiklerini açın; 50 sipariş dönen bir endpoint için hedef, ana liste ve ilişkileri yükleyen en fazla 2-3 prepared statement olmalıdır. Bu sayaç, aynı persistence context içindeki statement hazırlıklarını saydığı için regresyon testinde doğrudan assertion olarak kullanılabilir.
# application-test.yml
spring:
jpa:
properties:
hibernate:
generate_statistics: true
session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 50
@Autowired EntityManagerFactory emf;
@Test
void orders_endpoint_does_not_create_n_plus_one() {
var sessionFactory = emf.unwrap(org.hibernate.SessionFactory.class);
var statistics = sessionFactory.getStatistics();
statistics.clear();
webTestClient.get().uri("/api/orders?size=50")
.exchange().expectStatus().isOk();
assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(3);
}Yerelde `org.hibernate.SQL` logunu açmak bind parametrelerini ve SQL metinlerini gösterir, ancak production ölçümü için uygun değildir. PostgreSQL tarafında `pg_stat_statements` ile aynı endpoint yükü öncesi ve sonrası toplam çağrı sayısını, ortalama yürütme süresini ve okunan blokları karşılaştırın. Önce 100 kez aynı filtreyle endpoint'i çağırın, sonra fetch planını değiştirip aynı veri kümesi ve aynı bağlantı havuzu boyutuyla tekrar edin. `calls` sayısının düşmesi tek başına yeterli değildir; `shared_blks_read` yükseliyorsa tek büyük join, indeks dışı taramaya dönmüş olabilir.
SELECT query, calls, mean_exec_time, rows, shared_blks_hit, shared_blks_read
FROM pg_stat_statements
WHERE query LIKE '%from orders%'
ORDER BY calls DESC
LIMIT 10;spring boot eğitimi veya spring boot kursu içeriklerinde sık görülen hata, `spring.jpa.open-in-view=true` sayesinde view katmanında lazy yüklemenin çalışmasını çözüm sanmaktır. Bu ayar HTTP yanıtı serialize edilene kadar EntityManager'ı açık tutar; Jackson'ın `order.getCustomer().getName()` çağrısı transaction dışı ek sorgular üretir ve sorgu kaynağını controller dışına taşır. Bunu kapatıp eksik fetch planlarını testte görünür hale getirin.
spring:
jpa:
open-in-view: falseSpring MVC liste uçlarında EntityGraph ve sayfalama tuzağı
Bir Spring MVC liste endpoint'inde `@EntityGraph` to-one ilişkiler için çoğu zaman kontrollü bir çözümdür. Aşağıdaki repository, `Order.customer` ilişkisini aynı yükleme planına alır; `lines` gibi to-many koleksiyonunu burada eklememek bilinçli bir tercihtir. Hibernate, collection fetch join ile `Pageable` birleştirildiğinde SQL seviyesinde limit uygulayamayabilir ve sonuçları bellekte kırpabilir. Bu durum küçük test verisinde görünmez, büyük tabloda heap kullanımını ve gecikmeyi yükseltir.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = "customer")
Page<Order> findByStatusOrderByCreatedAtDesc(OrderStatus status, Pageable pageable);
}
@RestController
@RequiredArgsConstructor
class OrderController {
private final OrderRepository orders;
@GetMapping("/api/orders")
Page<OrderSummary> list(@RequestParam OrderStatus status, Pageable pageable) {
return orders.findByStatusOrderByCreatedAtDesc(status, pageable)
.map(OrderSummary::from);
}
}Liste ile birlikte bir to-many koleksiyonu zorunluysa güvenli desen iki aşamalıdır: ilk sorguda sayfalı ID listesi, ikinci sorguda sadece bu ID'lerin hydrate edilmesi. İkinci sorguda `distinct` kullanmak Hibernate'in aynı entity'yi persistence context'te birleştirmesini destekler; ancak veritabanındaki satır çoğalmasını ortadan kaldırmaz. İlk sorgunun sırasını ikinci sorgu garanti etmediğinden, sonuçları ID sırasına göre Java tarafında yeniden sıralayın. Bu ayrıntı atlanırsa API'de sayfa sırası kararsız hale gelir.
@Query("select o.id from Order o where o.status = :status order by o.createdAt desc")
Page<Long> findIdsByStatus(@Param("status") OrderStatus status, Pageable pageable);
@Query("""
select distinct o from Order o
left join fetch o.customer
left join fetch o.lines
where o.id in :ids
""")
List<Order> findHydratedByIdIn(@Param("ids") Collection<Long> ids);
public List<Order> pageWithLines(OrderStatus status, Pageable pageable) {
List<Long> ids = repository.findIdsByStatus(status, pageable).getContent();
Map<Long, Order> byId = repository.findHydratedByIdIn(ids).stream()
.collect(Collectors.toMap(Order::getId, Function.identity()));
return ids.stream().map(byId::get).toList();
}Batch fetch ayarını profil verisiyle seçmek
Her ilişkiyi fetch join yapmak doğru varsayılan değildir. Bir sipariş listesindeki müşteriler sık kullanılıyor, satırlar ise yalnızca bazı akışlarda okunuyorsa Hibernate batch fetch daha düşük satır çoğalması üretir. `default_batch_fetch_size: 64`, aynı persistence context içinde erişilen lazy proxy kimliklerini 64'lü gruplar halinde `IN (...)` sorgusuna dönüştürür. Bu mekanizma, controller her siparişi ayrı transaction içinde işliyorsa çalışmaz; proxy havuzu transaction sonunda kaybolur. Başlangıçta 16, 32, 64 ve 128 değerlerini aynı üretim benzeri cardinality ile deneyin.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 64
query.fail_on_pagination_over_collection_fetch: trueKarşılaştırmayı ölçülebilir yapın: 50 sipariş, sipariş başına ortalama 8 satır ve 20 farklı müşteri içeren sabit bir fixture kurun. Önce lazy varsayılanıyla prepared statement sayısını, p95 HTTP süresini ve PostgreSQL `EXPLAIN (ANALYZE, BUFFERS)` çıktısını kaydedin. Sonra yalnızca `@EntityGraph(customer)` ve batch fetch ayarını ekleyip aynı k6 senaryosunu çalıştırın. Son olarak iki aşamalı fetch planını deneyin. Kazanan, en düşük SQL sayısına sahip olan değil, p95 altında kalırken gereksiz satır ve blok okuması üretmeyen plandır.
// nplus1.js
import http from 'k6/http';
import { check } from 'k6';
export const options = { vus: 20, duration: '60s', thresholds: { http_req_duration: ['p(95)<300'] } };
export default function () {
const response = http.get('http://localhost:8080/api/orders?status=OPEN&size=50');
check(response, { '200': r => r.status === 200 });
}
k6 run nplus1.js
psql "$DATABASE_URL" -c "EXPLAIN (ANALYZE, BUFFERS) SELECT ..."Dikkat edilmesi gereken edge case, PostgreSQL'in büyük `IN` listelerinde plan seçiminin veri dağılımına göre değişmesidir. Ayrıca `@BatchSize(64)` bir collection üzerinde tanımlandığında, global ayarı o association için geçersiz kılar. Çok büyük batch değerleri sürücü parametre sınırına yaklaşabilir ve tek seferde hydrate edilen entity sayısını büyütür. Bu nedenle batch boyutunu 'sorgu sayısı en az' hedefiyle değil, heap profili ve `EXPLAIN` çıktısıyla belirleyin.
Spring REST API, Spring Security ve microservices mimarisi sınırı
Bir spring rest api entity döndürdüğünde, serialization sırasında erişilen her lazy alan yeni sorgu tetikleyebilir veya kapalı persistence context'te `LazyInitializationException` üretir. DTO projection, endpoint'in veri sözleşmesini SQL seçimine yaklaştırır. Spring Security yetkisini repository sorgusuna tenant koşulu olarak dahil edin; yalnızca controller'daki `@PreAuthorize` kontrolü, yanlış repository metodunun başka bir endpoint tarafından çağrılmasını engellemez.
public record OrderSummary(Long id, String customerName, BigDecimal total) {}
@Query("""
select new com.acme.api.OrderSummary(o.id, c.name, o.total)
from Order o join o.customer c
where o.tenantId = :tenantId and o.status = :status
order by o.createdAt desc
""")
Page<OrderSummary> findSummaries(UUID tenantId, OrderStatus status, Pageable pageable);
@PreAuthorize("hasAuthority('SCOPE_orders:read')")
@GetMapping("/api/orders")
Page<OrderSummary> list(@AuthenticationPrincipal Jwt jwt, OrderStatus status, Pageable pageable) {
return repository.findSummaries(UUID.fromString(jwt.getClaimAsString("tenant_id")), status, pageable);
}microservices mimarisi içinde bu sınır daha önemlidir: başka bir servisin ihtiyacı olan alanlar için entity graph'ı büyütmek yerine, sözleşmeye özel projection veya ayrı bir read endpoint yayınlayın. Spring Cloud ile servis keşfi ya da istemci yük dengelemesi kullanılması N+1'i çözmez; sorun veritabanı tur sayısıdır. Spring Security tarafında JWT'den alınan tenant bilgisini sorgu parametresine bağlamak, cache anahtarına tenant eklenmediğinde oluşabilecek çapraz kiracı veri sızıntısını da görünür kılar. Cache kullanılıyorsa anahtarı `tenantId + ':' + orderId` olarak kurun ve endpoint testiyle iki tenant için farklı sonuç döndüğünü doğrulayın.
İ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 Boot'ta N+1 sorgusu nasıl ölçülür?
Test profilinde `hibernate.generate_statistics=true` açın, çağrıdan önce `Statistics.clear()` çalıştırın ve `getPrepareStatementCount()` için üst sınır assertion'ı yazın. Production karşılaştırmasında PostgreSQL `pg_stat_statements` ile aynı yük altında `calls`, `mean_exec_time` ve blok okuma sayaçlarını önce-sonra kaydedin.
Spring MVC sayfalama ile fetch join neden sorun çıkarır?
To-many fetch join, her parent için birden çok SQL satırı üretir. Hibernate SQL `LIMIT` uyguladığında parent sayfası bozulabileceği için collection fetch join ve pagination kombinasyonunu bellekte sınırlayabilir. `hibernate.query.fail_on_pagination_over_collection_fetch=true` ile bunu hata olarak yakalayın; ID sayfalama ve ikinci hydrate sorgusuna geçin.
Spring Security kullanılan Spring REST API'de DTO projection gerekli mi?
Evet, özellikle open-in-view kapalıysa DTO projection serializer'ın lazy ilişkilere erişmesini engeller. Repository sorgusuna JWT'den türetilen `tenantId` koşulunu ekleyin, `@PreAuthorize` ile scope kontrolü yapın ve entity yerine sadece API sözleşmesindeki kolonları seçin.
Spring Cloud kullanan microservices mimarisinde batch fetch ne zaman seçilmeli?
Spring Cloud servisler arası çağrıları yönetir, JPA association yükleme planını değil. Aynı transaction içinde çok sayıda lazy ilişki okunuyor ve collection fetch join satır çoğalması yaratıyorsa `default_batch_fetch_size` için 16, 32, 64 ve 128 denemelerini k6 p95 sonucu ile `EXPLAIN (ANALYZE, BUFFERS)` çıktısına göre karşılaştırın.
AI / LLM Discovery
Bu makale Opendart Akademi Spring Framework 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.


