@EntityGraph & JOIN FETCH — Fetch Stratejileri
Giriş — Neden Bu Konu Önemli?
Önceki derste N+1 problemini öğrendik ve çözüm olarak JOIN FETCH, @EntityGraph, batch fetching gibi stratejilerden bahsettik. Şimdi bu stratejilerin her birini derinlemesine inceleyeceğiz — ne zaman hangisini kullanmalı, trade-off'ları neler, gerçek projelerde nasıl uygulanır?
Doğru fetch stratejisi seçimi, uygulamanızın performansını 10x-100x iyileştirebilir. 1000 sorguyu 2 sorguya düşürmek mümkündür. Ancak her stratejinin kendi tuzakları vardır — kartezyen çarpım, bellek tüketimi, MultipleBagFetchException gibi sorunları bilmeden kullanmak başka problemler yaratır.
Gerçek Hayat Analojisi: Bir market alışverişi düşünün. JOIN FETCH, alışveriş listenizdeki tüm ürünleri tek seferde toplayıp kasaya gitmektir. @BatchSize, her koridordan 20'şer ürün almaktır. SUBSELECT, "hangi ürünlere ihtiyacınız var?" diye sorup hepsini tek sepette getirmektir. Her stratejinin kendine göre avantajı var — kasayı kaç kez ziyaret edeceğiniz (sorgu sayısı) vs sepetinizin büyüklüğü (bellek kullanımı).
JOIN FETCH ile JPQL
JOIN FETCH, JPQL'de ilişkili entity'leri tek bir SQL sorgusunda yüklemek için kullanılır. Normal JOIN'den farklı olarak, JOIN FETCH ilişkili entity'leri persistence context'e yükler:
public interface AuthorRepository extends JpaRepository<Author, Long> {
// ❌ Normal JOIN — sadece filtreleme amaçlı, kitaplar YÜKLENMEZ
@Query("SELECT a FROM Author a JOIN a.books b WHERE b.title LIKE %:keyword%")
List<Author> findByBookTitle(@Param("keyword") String keyword);
// → author.getBooks() → LazyInitializationException veya N+1!
// ✅ JOIN FETCH — kitaplar da yüklenir (N+1 çözümü)
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
// → SELECT a.*, b.* FROM author a INNER JOIN book b ON a.id = b.author_id
// ✅ LEFT JOIN FETCH — kitabı olmayan author'lar da gelir
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
List<Author> findAllWithBooksIncludingEmpty();
// → SELECT DISTINCT a.*, b.* FROM author a LEFT JOIN book b ON a.id = b.author_id
}JOIN vs LEFT JOIN FETCH
| Tür | Kitabı olmayan author | Kullanım |
|---|---|---|
JOIN FETCH | ❌ Gelmez | Sadece ilişkisi olan entity'ler gerektiğinde |
LEFT JOIN FETCH | ✅ Gelir (books boş liste) | Tüm entity'ler gerektiğinde (yaygın) |
DISTINCT Kullanımı
JOIN FETCH ile Kartezyen çarpım oluşabilir. Bir author'ın 3 kitabı varsa, o author sonuç kümesinde 3 kez görünür:
-- DISTINCT olmadan:
| author_id | author_name | book_id | book_title |
|-----------|------------|---------|------------|
| 1 | Orhan | 10 | Kar |
| 1 | Orhan | 11 | İstanbul |
| 1 | Orhan | 12 | Masumiyet |
-- → 3 Author nesnesi oluşur (aynı author!)
-- DISTINCT ile:
-- → 1 Author nesnesi, 3 kitaplı liste💡 Hibernate 6+ ile
DISTINCT, SQL'e yansımaz — Hibernate bunu in-memory yapar. Eski sürümlerde@QueryHints(@QueryHint(name = HINT_PASS_DISTINCT_THROUGH, value = "false"))gerekebilirdi.
Çoklu JOIN FETCH ve MultipleBagFetchException
Birden fazla List koleksiyonu aynı anda JOIN FETCH etmek hatasına neden olur:
// ❌ İki List koleksiyonu — MultipleBagFetchException!
@Query("SELECT a FROM Author a JOIN FETCH a.books JOIN FETCH a.articles")
List<Author> findAllWithBooksAndArticles();Çözüm 1: Koleksiyonlardan birini Set yapın:
@OneToMany(mappedBy = "author")
private Set<Book> books; // Set
@OneToMany(mappedBy = "author")
private List<Article> articles; // List
// Artık çalışır!
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books LEFT JOIN FETCH a.articles")
List<Author> findAllWithBooksAndArticles();Çözüm 2: İki ayrı sorgu yapın:
// 1. sorgu: author + books
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
List<Author> findAllWithBooks();
// 2. sorgu: author + articles (Hibernate cache'den author'ları bulur)
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.articles WHERE a IN :authors")
List<Author> findAllWithArticles(@Param("authors") List<Author> authors);
// Service'te birleştirme
@Transactional(readOnly = true)
public List<Author> findAllWithAllRelations() {
List<Author> authors = authorRepo.findAllWithBooks();
authorRepo.findAllWithArticles(authors); // Same session → cache merge
return authors; // Her author'ın books VE articles'ı dolu
}@EntityGraph
@EntityGraph, fetch planını annotation ile tanımlamanızı sağlar. JPQL yazmadan fetch stratejisini değiştirebilirsiniz:
public interface AuthorRepository extends JpaRepository<Author, Long> {
// attributePaths ile inline tanımlama
@EntityGraph(attributePaths = {"books"})
List<Author> findAll();
// Birden fazla ilişki
@EntityGraph(attributePaths = {"books", "publisher"})
List<Author> findByNameContaining(String name);
// Nested ilişkiler — kitapların yayınevlerini de çek
@EntityGraph(attributePaths = {"books", "books.publisher"})
Optional<Author> findById(Long id);
}@EntityGraph'ın Avantajları
JPQL yazmaya gerek yok — Spring Data'nın method name query'leriyle çalışır
Daha okunabilir — hangi ilişkilerin yükleneceği açıkça bellidir
Dinamik — aynı repository metodu farklı EntityGraph'larla kullanılabilir
@NamedEntityGraph
Entity sınıfında tanımlanan, yeniden kullanılabilir fetch planları:
@Entity
@NamedEntityGraph(
name = "Author.withBooksAndPublisher",
attributeNodes = {
@NamedAttributeNode(value = "books", subgraph = "books-subgraph")
},
subgraphs = {
@NamedSubgraph(
name = "books-subgraph",
attributeNodes = {
@NamedAttributeNode("publisher"),
@NamedAttributeNode("reviews")
}
)
}
)
public class Author {
@Id
private Long id;
private String name;
@OneToMany(mappedBy = "author")
private List<Book> books;
}
// Repository'de referans
@EntityGraph(value = "Author.withBooksAndPublisher", type = EntityGraph.EntityGraphType.LOAD)
Optional<Author> findById(Long id);EntityGraphType: FETCH vs LOAD
@EntityGraph'ın iki tipi vardır:
// FETCH (default):
// - attributePaths'te belirtilen ilişkiler → EAGER
// - Belirtilmeyen TÜM ilişkiler → LAZY (entity annotation'ı yok sayılır)
@EntityGraph(attributePaths = {"books"}, type = EntityGraph.EntityGraphType.FETCH)
List<Author> findAll();
// LOAD:
// - attributePaths'te belirtilen ilişkiler → EAGER
// - Belirtilmeyenler → entity'deki FetchType annotation'ına göre
@EntityGraph(attributePaths = {"books"}, type = EntityGraph.EntityGraphType.LOAD)
List<Author> findAll();| Tip | Belirtilen ilişkiler | Belirtilmeyen ilişkiler |
|---|---|---|
| FETCH | EAGER | LAZY (her zaman) |
| LOAD | EAGER | Entity annotation'a göre |
💡 Hangisi? Çoğu durumda FETCH (default) tercih edilir — sadece belirttiğiniz ilişkiler yüklenir, diğer her şey lazy kalır. Bu, beklenmeyen eager loading'i önler.
Hibernate Batch Fetching
@BatchSize annotation'ı, lazy koleksiyonları gruplar halinde yükler. N+1'i tamamen çözmez ama sorgu sayısını dramatik şekilde azaltır:
@Entity
public class Author {
@OneToMany(mappedBy = "author")
@BatchSize(size = 25)
private List<Book> books;
}100 author varsa:
Batch olmadan: 1 + 100 = 101 sorgu
@BatchSize(size = 25): 1 + 4 = 5 sorgu (ceil(100/25) = 4 batch)
Üretilen SQL:
-- İlk author'ın books'una erişildiğinde:
SELECT * FROM book WHERE author_id IN (?, ?, ?, ..., ?)
-- 25 author_id parametresi ile tek sorguGlobal Batch Fetching
Her entity'ye ayrı ayrı @BatchSize eklemek yerine global ayar kullanabilirsiniz:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25Bu ayar, tüm lazy koleksiyonlar ve @ManyToOne lazy ilişkiler için geçerlidir. Her projede aktif olmalıdır — "ücretsiz" performans iyileştirmesidir.
💡 Batch size seçimi: 10-50 arası idealdir. Çok küçük batch verimsiz (çok sorgu), çok büyük batch IN clause limitlerini aşabilir (bazı veritabanlarında 1000 parametre limiti). 16 veya 25 iyi başlangıç değerleridir.
Subselect Fetching
@Fetch(FetchMode.SUBSELECT) stratejisi, tüm lazy koleksiyonları tek bir subselect sorgusu ile yükler:
@Entity
public class Author {
@OneToMany(mappedBy = "author")
@Fetch(FetchMode.SUBSELECT)
private List<Book> books;
}Üretilen SQL:
-- 1. sorgu: Author'ları getir
SELECT * FROM author WHERE active = true
-- 2. sorgu: Herhangi bir author'ın books'una erişildiğinde
-- TÜM author'ların kitapları tek seferde yüklenir
SELECT * FROM book
WHERE author_id IN (SELECT id FROM author WHERE active = true)Toplam 2 sorgu! Orijinal sorgu subselect olarak tekrar kullanılır.
Subselect vs BatchSize
| Özellik | @BatchSize | @Fetch(SUBSELECT) |
|---|---|---|
| Sorgu sayısı | 1 + ceil(N/size) | 2 (her zaman) |
| Bellek | Düşük (parçalı yükleme) | Yüksek (tümünü yükler) |
| Esneklik | Size ayarlanabilir | Ayar yok |
| Kısmi erişim | Sadece erişilen batch yüklenir | Tüm veri yüklenir |
Programmatic EntityGraph
Runtime'da dinamik fetch planı oluşturmak için:
@Service
@Transactional(readOnly = true)
public class AuthorService {
@PersistenceContext
private EntityManager em;
public Author findWithDynamicGraph(Long id, String... relations) {
EntityGraph<Author> graph = em.createEntityGraph(Author.class);
for (String relation : relations) {
if (relation.contains(".")) {
// Nested: "books.publisher" → subgraph
String[] parts = relation.split("\\.");
Subgraph<?> subgraph = graph.addSubgraph(parts[0]);
subgraph.addAttributeNodes(parts[1]);
} else {
graph.addAttributeNodes(relation);
}
}
Map<String, Object> hints = Map.of("jakarta.persistence.fetchgraph", graph);
return em.find(Author.class, id, hints);
}
}
// Kullanım
Author author = authorService.findWithDynamicGraph(1L, "books", "books.publisher");Gerçek Dünya Örneği: Blog Platformu
Farklı sayfalarda farklı veri ihtiyaçları olan bir blog platformu:
public interface PostRepository extends JpaRepository<Post, Long> {
// Ana sayfa — sadece post + author adı
@EntityGraph(attributePaths = {"author"})
Page<Post> findByPublishedTrue(Pageable pageable);
// Post detay — post + author + comments + tags
@Query("""
SELECT DISTINCT p FROM Post p
LEFT JOIN FETCH p.author
LEFT JOIN FETCH p.tags
WHERE p.id = :id
""")
Optional<Post> findByIdWithAuthorAndTags(@Param("id") Long id);
// Admin paneli — post + author + comment count (DTO projection)
@Query("""
SELECT new com.example.dto.PostAdminDto(
p.id, p.title, p.status, a.name, SIZE(p.comments)
)
FROM Post p JOIN p.author a
ORDER BY p.createdAt DESC
""")
Page<PostAdminDto> findPostsForAdmin(Pageable pageable);
}
@Service
@Transactional(readOnly = true)
public class PostService {
// Post detay — comments ayrı yüklenir (sayfalama ile)
public PostDetailDto getPostDetail(Long postId) {
Post post = postRepo.findByIdWithAuthorAndTags(postId)
.orElseThrow(() -> new ResourceNotFoundException("Post not found"));
// Comments ayrı sorguda, sayfalama ile
Page<Comment> comments = commentRepo.findByPostId(
postId, PageRequest.of(0, 20, Sort.by("createdAt").descending()));
return PostDetailDto.from(post, comments);
}
}Hangi Stratejiyi Ne Zaman Kullanmalı?
| Senaryo | Önerilen Strateji |
|---|---|
| Tek entity + ilişkili veriler | JOIN FETCH veya @EntityGraph |
| Liste + ilişkili veriler (sayfalama yok) | JOIN FETCH |
| Liste + ilişkili veriler (sayfalama var) | İki adımlı (ID page + JOIN FETCH) |
| Sadece belirli alanlar gerekli | DTO Projection |
| Genel performans iyileştirmesi | Global @BatchSize (16-25) |
| Tüm koleksiyonlar aynı anda gerekli | SUBSELECT |
| Birden fazla koleksiyon | Set kullanın veya iki ayrı sorgu |
| API yanıtı | DTO Projection (en iyi) |
JOIN FETCH ile Sayfalama Problemi
JOIN FETCH ve Pageable birlikte kullanıldığında Hibernate in-memory sayfalama yapar:
// ⚠️ Bu çalışır AMA performans sorunu!
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
Page<Author> findAllWithBooks(Pageable pageable);
// Hibernate UYARISI:
// HHH90003004: firstResult/maxResults specified with collection fetch;
// applying in memory!Hibernate, tüm veriyi çeker (100.000 satır bile olsa), bellekte sayfalama yapar. Bu, büyük veri setlerinde OutOfMemoryError riski taşır.
Çözüm: İki Adımlı Yaklaşım
public interface AuthorRepository extends JpaRepository<Author, Long> {
// 1. Adım: Sayfalama ile sadece ID'leri çek (hafif sorgu)
@Query("SELECT a.id FROM Author a WHERE a.active = true ORDER BY a.name")
Page<Long> findActiveAuthorIds(Pageable pageable);
// 2. Adım: ID'lere göre detaylı veriyi çek (JOIN FETCH, sayfalama yok)
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books WHERE a.id IN :ids")
List<Author> findWithBooksByIds(@Param("ids") List<Long> ids);
}
@Service
@Transactional(readOnly = true)
public class AuthorService {
public Page<AuthorDto> findActiveAuthors(Pageable pageable) {
// Adım 1: ID'leri sayfalama ile çek
Page<Long> idPage = authorRepo.findActiveAuthorIds(pageable);
if (idPage.isEmpty()) {
return Page.empty(pageable);
}
// Adım 2: Detaylı veriyi çek
List<Author> authors = authorRepo.findWithBooksByIds(idPage.getContent());
// Sıralamayı koru (ID sırası pageable ile aynı olmalı)
Map<Long, Author> authorMap = authors.stream()
.collect(Collectors.toMap(Author::getId, a -> a));
List<AuthorDto> dtos = idPage.getContent().stream()
.map(id -> AuthorDto.from(authorMap.get(id)))
.toList();
return new PageImpl<>(dtos, pageable, idPage.getTotalElements());
}
}Bu yaklaşımda:
1. sorgu:
SELECT id FROM author WHERE active = true ORDER BY name LIMIT 20 OFFSET 40— hafif, sayfalama veritabanında2. sorgu:
SELECT a.*, b.* FROM author a LEFT JOIN book b ON ... WHERE a.id IN (?, ?, ..., ?)— 20 ID ile detaylı veri
Toplam 2 sorgu, sayfalama doğru ve bellek güvenli.
@EntityGraph ile Spring Data Specification Entegrasyonu
public interface ProductRepository extends
JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
// @EntityGraph + Specification birlikte kullanılabilir
@EntityGraph(attributePaths = {"category", "brand"})
Page<Product> findAll(Specification<Product> spec, Pageable pageable);
}
// Kullanım
Specification<Product> spec = ProductSpecs.hasCategory("elektronik")
.and(ProductSpecs.priceBetween(100, 500));
Page<Product> products = productRepo.findAll(spec, pageable);
// Tek sorgu: category ve brand LEFT JOIN FETCH ile gelirFetch Profilleri — Farklı Senaryolar İçin Farklı Planlar
Aynı entity'yi farklı ekranlar için farklı şekillerde yüklemek isteyebilirsiniz:
@Entity
@NamedEntityGraphs({
@NamedEntityGraph(
name = "Author.summary",
attributeNodes = {} // Sadece author alanları — ilişki yok
),
@NamedEntityGraph(
name = "Author.withBooks",
attributeNodes = @NamedAttributeNode("books")
),
@NamedEntityGraph(
name = "Author.full",
attributeNodes = {
@NamedAttributeNode(value = "books", subgraph = "books-sub"),
@NamedAttributeNode("awards")
},
subgraphs = @NamedSubgraph(
name = "books-sub",
attributeNodes = {
@NamedAttributeNode("publisher"),
@NamedAttributeNode("reviews")
}
)
)
})
public class Author { ... }
// Repository'de farklı ekranlar için farklı graph
public interface AuthorRepository extends JpaRepository<Author, Long> {
// Liste görünümü — sadece author
@EntityGraph("Author.summary")
Page<Author> findByActiveTrue(Pageable pageable);
// Detay sayfası — author + books
@EntityGraph("Author.withBooks")
Optional<Author> findDetailById(Long id);
// Admin paneli — her şey
@EntityGraph("Author.full")
Optional<Author> findFullById(Long id);
}Performans Ölçme ve Monitoring
Hangi stratejinin daha iyi çalıştığını ölçmek için:
@Component
@Slf4j
public class QueryCountInterceptor implements HandlerInterceptor {
private static final ThreadLocal<Long> queryCount = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
queryCount.set(0L);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
Long count = queryCount.get();
if (count != null && count > 10) {
log.warn("⚠️ High query count: {} queries for {} {}",
count, request.getMethod(), request.getRequestURI());
}
queryCount.remove();
}
public static void increment() {
Long current = queryCount.get();
if (current != null) {
queryCount.set(current + 1);
}
}
}# p6spy ile tüm SQL'leri loglama (test/development)
# build.gradle: implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
spring:
datasource:
url: jdbc:p6spy:postgresql://localhost:5432/mydb
driver-class-name: com.p6spy.engine.spy.P6SpyDriverÖzet
JOIN FETCH en yaygın N+1 çözümüdür — tek sorgu ile ilişkili veriler yüklenir
@EntityGraph JPQL yazmadan fetch planı tanımlar — Spring Data query method'larıyla uyumludur
DISTINCT JOIN FETCH ile kartezyen çarpımı önlemek için gereklidir
MultipleBagFetchException birden fazla List fetch edildiğinde oluşur — Set kullanın veya ayrı sorgular yazın
@BatchSize global olarak aktif olmalıdır (default_batch_fetch_size: 16) — "ücretsiz" optimizasyon
SUBSELECT tüm veriyi 2 sorguda çeker ama büyük veri setlerinde bellek tüketimi yüksektir
İlk tercihiniz DTO projection olsun — sadece ihtiyacınız olan alanları çekin, entity yönetim maliyeti yoktur
AI Asistan
Sorularını yanıtlamaya hazır