Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #17

EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query

Mert Özen 5 Kas 2026 9 dk 2 görüntülenme
EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query

N+1 sorgularını komut sayarak yakalıyor, projection ile DTO'ya geçiyor, tracking'in, index'in, split query'nin ve compiled query'nin etkisini SQLite üzerinde ölçüyor ve TagWith ile LogTo kullanarak sorguları izliyoruz.

Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.

Önce Tahmin Et: Kaç SQL Komutu?

Veritabanında 100 yazar ve her yazarın 20 kitabı var. Her yazarın adını ve kitap sayısını listeleyen şu kod veritabanına kaç SQL komutu gönderir?

var authors = await db.Authors.ToListAsync();

foreach (var author in authors)
{
    var bookCount = await db.Books.CountAsync(b => b.AuthorId == author.Id);
    // ... ekrana yaz
}

Console.WriteLine($"Commands: {BookstoreContext.CommandCount}");

Kod masum görünüyor; iki satır sorgu, bir döngü. Bu yazıda EF Core uygulamalarını yavaşlatan en yaygın hataları tek tek ölçerek göreceğiz. Model şimdiye kadarki Bookstore modelinin biraz genişletilmiş hâli; kitapların uzun bir açıklaması, yorumları ve yazarların ödülleri var:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Book> Books { get; set; } = new();
    public List<Award> Awards { get; set; } = new();
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string Description { get; set; } = "";
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!;
    public List<Review> Reviews { get; set; } = new();
}

public class Review
{
    public int Id { get; set; }
    public int Rating { get; set; }
    public string Text { get; set; } = "";
    public int BookId { get; set; }
}

public class Award
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public int AuthorId { get; set; }
}

Ölçmeden Optimize Etme: Komutları Saymak ve Loglamak

Performans konusunda ilk kural: tahmin etme, ölç. Bu yazıdaki ölçümler için context'e çok basit bir sayaç ekledik. LogTo'yu yalnız CommandExecuted event'ine bağlayıp her SQL komutunda bir sayaç artırıyoruz:

public static int CommandCount;
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
    options.UseSqlite("Data Source=bookstore.db")
        .LogTo(message => CommandCount++, new[] { RelationalEventId.CommandExecuted });
}

Gerçek bir uygulamada sayaç yerine log'a bakarsın. Aynı event'i konsola yazdırmak, her komutu çalışma süresiyle birlikte gösterir:

options.UseSqlite("Data Source=bookstore.db")
    .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted });

Logda hangi satırın hangi koddan geldiğini bulmak zor olabilir. TagWith, sorgunun başına bir SQL yorumu ekler:

var soldOut = await db.Books
    .TagWith("Stock report: sold out books")
    .Where(b => b.Stock == 0)
    .Select(b => b.Title)
    .ToListAsync();
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Stock report: sold out books
      
      SELECT "b"."Title"
      FROM "Books" AS "b"
      WHERE "b"."Stock" = 0

Executed DbCommand (0ms) kısmı komutun veritabanında ne kadar sürdüğünü söylüyor. Yavaş bir sayfa için logda arama yaptığında bu etiketi doğrudan bulursun; yorum SQL metninin parçası olduğu için veritabanı tarafındaki izleme araçlarında da görünür. Parametre değerlerini de görmek istersen EnableSensitiveDataLogging() var, ama adından da anlaşılacağı gibi production'da açık bırakılacak bir ayar değil.

N+1 ve Projection ile Çözüm

Açılıştaki sorunun cevabı:

Commands: 101

Bir komut yazarlar için, sonra her yazar için bir komut daha. Bu kalıbın adı N+1: bir sorgu listeyi getiriyor, ardından listedeki N element için N sorgu daha gidiyor. SQLite aynı makinede bir dosya olduğu için 101 komut fark edilmeyecek kadar hızlı biter. Veritabanı ağın öbür ucundayken her komut bir gidiş-dönüş demek; komut başına 2 ms bile olsa bu sayfa 200 ms'yi sadece beklemekle geçirir. Lazy loading açıksa bu hata daha da sinsi olur, çünkü döngüde author.Books.Count yazmak yeter ve kodda hiçbir sorgu görünmez.

Çözüm, ihtiyacın olan şeyi tek bir sorguda istemek:

var summaries = await db.Authors
    .Select(a => new AuthorSummary(a.Name, a.Books.Count))
    .ToListAsync();

Console.WriteLine($"Commands: {BookstoreContext.CommandCount}");
public record AuthorSummary(string Name, int BookCount);
public record BookListItem(int Id, string Title, decimal Price);
Commands: 1

EF Core, a.Books.Count'u bir correlated subquery'ye çevirdi:

SELECT "a"."Name", (
    SELECT COUNT(*)
    FROM "Books" AS "b"
    WHERE "a"."Id" = "b"."AuthorId")
FROM "Authors" AS "a"

Hesap veritabanında yapılıyor ve yalnız sonuç geliyor: 100 satır, iki kolon. Çıktı EF Core 6 ve 10'da aynı.

Sadece İhtiyacın Olan Kolonlar: DTO'ya Projection

Kitap listesi sayfası her kitabın id'sini, adını ve fiyatını gösteriyor. Bütün entity'yi yüklemek ile bir DTO'ya projection yapmak arasındaki farka bakalım:

var full = db.Books.AsNoTracking().Where(b => b.Stock > 0);
var list = db.Books.Where(b => b.Stock > 0)
    .Select(b => new BookListItem(b.Id, b.Title, b.Price));

Console.WriteLine(full.ToQueryString());
Console.WriteLine(list.ToQueryString());
SELECT "b"."Id", "b"."AuthorId", "b"."Description", "b"."Price", "b"."Stock", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."Stock" > 0
SELECT "b"."Id", "b"."Title", "b"."Price"
FROM "Books" AS "b"
WHERE "b"."Stock" > 0

İlk sorgu 2000 karakterlik Description dahil her kolonu getiriyor; ikincisi yalnız üç kolonu. Her ikisini de 20'şer kez çalıştırdık ve EF Core 10'da şu süreleri gördük:

20x full entity: 87 ms, 20x projection: 37 ms
20x full entity: 75 ms, 20x projection: 38 ms
20x full entity: 76 ms, 20x projection: 32 ms

Sayılar senin makinende farklı olacak; önemli olan oran. Burada projection iki kattan fazla hızlı ve fark tamamen taşınan veriden geliyor. Projection'ın bir yan faydası daha var: sonuç bir entity değil, bir DTO olduğu için change tracker'a hiç girmiyor. API'den entity yerine DTO dönmenin tasarım tarafını ASP.NET Core Web API serisinde konuşmuştuk; bu da performans tarafı.

Gereksiz Tracking

Bazen gerçekten bütün entity'ye ihtiyacın var ama onu değiştirmeyeceksin. Varsayılan sorgular yine de her entity'yi change tracker'a kaydeder:

using var db = new BookstoreContext();
var sw = Stopwatch.StartNew();

var tracked = await db.Books.ToListAsync();
Console.WriteLine($"Tracked: {sw.ElapsedMilliseconds} ms, entries: {db.ChangeTracker.Entries().Count()}");

db.ChangeTracker.Clear();
sw.Restart();

var readOnly = await db.Books.AsNoTracking().ToListAsync();
Console.WriteLine($"No tracking: {sw.ElapsedMilliseconds} ms, entries: {db.ChangeTracker.Entries().Count()}");
Tracked: 13 ms, entries: 2000
No tracking: 3 ms, entries: 0

Ölçüm EF Core 10'un üçüncü turundan; EF Core 6'da tablo benzer: 10 ile 22 ms arası tracked, 4 ms no-tracking. Tracking'in bedeli yalnız süre değil. Her entity için bir snapshot tutulur ve bu bellek, context dispose olana kadar serbest kalmaz. Change tracking yazısında gördüğümüz kural burada da geçerli: yalnız okuyacağın veriyi AsNoTracking() ile getir. Uygulamanın büyük kısmı okuma yapıyorsa varsayılanı context seviyesinde UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) ile değiştirip yalnız yazacağın yerde AsTracking() demek de bir seçenek.

Index'ler ve Sorgu Planı

EF Core'un ürettiği SQL ne kadar iyi olursa olsun, veritabanı doğru index'e sahip değilse her sorguda tabloyu baştan sona okur. SQLite'ta bunu EXPLAIN QUERY PLAN ile görebilirsin. EF Core'un ürettiği SQL'i alıp başına ekledik:

var query = db.Books.Where(b => b.Title == "Book 042-07");
Console.WriteLine(query.ToQueryString());

var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = "EXPLAIN QUERY PLAN " + query.ToQueryString();

using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
    Console.WriteLine(reader.GetString(3)); // planın açıklama kolonu
SELECT "b"."Id", "b"."AuthorId", "b"."Description", "b"."Price", "b"."Stock", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."Title" = 'Book 042-07'
SCAN b

SCAN b, tablonun tamamının okunduğu anlamına geliyor. Fluent API ile Title'a bir index ekliyoruz:

modelBuilder.Entity<Book>().HasIndex(b => b.Title);

Aynı sorgunun planı değişiyor:

SEARCH b USING INDEX IX_Books_Title (Title=?)

Plan EF Core 6 ve 10'da aynı. 2000 satırda fark hissedilmez, iki milyon satırda bütün farkı bu satır yaratır. Hangi kolonlara index koyacağını tahminle değil, sık çalışan ve yavaş görünen sorguların WHERE, JOIN ve ORDER BY kısımlarına bakarak seç. Her index okumayı hızlandırır ama her INSERT ve UPDATE'i biraz yavaşlatır. Index'in model tarafını Fluent API yazısında görmüştük; foreign key kolonlarına EF Core zaten kendiliğinden index koyuyor.

AsSplitQuery: Kartezyen Patlamaya Karşı

İlişkili veri yükleme yazısında single query ile split query'yi konuşmuştuk. Burada ölçelim. Yazarları hem kitaplarıyla hem ödülleriyle getiriyoruz:

var authors = await db.Authors
    .AsNoTracking()
    .Include(a => a.Books)
    .Include(a => a.Awards)
    .ToListAsync();
var authors = await db.Authors
    .AsNoTracking()
    .Include(a => a.Books)
    .Include(a => a.Awards)
    .AsSplitQuery()
    .ToListAsync();

Tek sorgu, iki koleksiyonu aynı JOIN'e koyduğu için her yazar için 20 kitap ile 3 ödülün bütün kombinasyonlarını döndürüyor: 100 yazar için 6000 satır. Split query ise üç ayrı sorgu ile 100 yazar, 2000 kitap ve 300 ödül getiriyor. EF Core 10'da ölçtüklerimiz:

Single query: 1 command(s), 18 ms
Split query:  3 command(s), 8 ms
Single query: 1 command(s), 19 ms
Split query:  3 command(s), 9 ms

Burada split query kazandı, ama bu her zaman böyle değil. Split query üç gidiş-dönüş demek ve aralarında veri değişirse sonuçlar tutarsız olabilir. Kural: tek sorgunun satır sayısı koleksiyonların çarpımıyla patlıyorsa split'e geç, yoksa tek sorguda kal.

Compiled Query

EF Core her LINQ sorgusunu SQL'e çevirmek için bir derleme adımından geçirir. Sonuç önbelleğe alınır, ama EF Core yine her çağrıda expression tree'yi alıp önbellekte arama yapmak zorundadır. Saniyede binlerce kez çalışan küçük sorgularda bu adımı da atlayabilirsin:

public static class Queries
{
    public static readonly Func<BookstoreContext, string, Task<Book?>> BookByTitle =
        EF.CompileAsyncQuery((BookstoreContext db, string title) =>
            db.Books.AsNoTracking().FirstOrDefault(b => b.Title == title));
}

Compiled query, context'i ve parametreyi alan sıradan bir delege gibi çağrılıyor:

var dune = await Queries.BookByTitle(db2, "Book 042-07");

Aynı sorguyu 5000 kez normal, 5000 kez compiled olarak çalıştırdık. Isınma turundan sonraki sonuçlar şöyle:

5000x regular: 205 ms, 5000x compiled: 106 ms
5000x regular: 170 ms, 5000x compiled: 107 ms

Sorgu başına aşağı yukarı 34 mikrosaniyeden 21 mikrosaniyeye. EF Core 6'da da tablo aynı: 170 ms'ye karşı 100 ms. Yüzde olarak büyük görünüyor, ama mutlak değer küçük. Sorgu veritabanında 5 ms sürüyorsa bu kazanç gürültüde kaybolur. Compiled query'yi profiler'da sorgu derlemesini gerçekten gördüğün sıcak noktalara sakla. Ayrıca compiled query'ler sabit bir LINQ şekli ister; koşula göre eklenen Where'ler burada çalışmaz.

Sık Düşülen Tuzaklar

IQueryable yerine IEnumerable. Bir method'un IEnumerable<Book> döndürüp çağıranın üzerine Where eklemesi, filtrenin bellekte çalışması demek. LINQ yazısında gördüğümüz bu tuzak, performans sorunlarının en sessizidir: bütün tablo gelir, sonra atılır.

Sayfalama olmadan liste. ToListAsync() bugün 50 satır getirir, iki yıl sonra 500 bin. Liste döndüren her endpoint'te Skip/Take ve bir üst sınır olsun.

Yanlış yerde ölçmek. Debug build'de, ilk çağrıda ve boş bir veritabanında yapılan ölçüm yanıltır. İlk çağrı modelin kurulmasını ve sorgu derlemesini de içerir; yukarıdaki ilk turların hep daha yavaş olması bu yüzden. Gerçek veri hacmine yakın bir veriyle ve birkaç tur ölçerek karar ver.

Her şeyi pooling ve compiled query ile çözmeye çalışmak. Bu ikisi en son gelen ayarlardır. Önce sorgu sayısını, taşınan veriyi ve index'leri düzelt. Sorunların çoğu orada.

Küçük Bir Deneme

★☆☆ N+1 örneğine TagWith("Author list") ekle ve log'u konsola yazdır. 101 komutun her birinde etiketi gör, sonra projection'lı sürümde tek komutta gör.

★★☆ En ucuz 20 kitabı fiyatıyla birlikte getiren bir projection yaz ve OrderBy(b => b.Price) ile sırala. EF Core 6 projesinde SQLite'ın ORDER BY'da decimal desteklemediğini söyleyen bir NotSupportedException alacaksın; mesajı oku ve önerdiği iki yoldan birini uygula. Aynı sorguyu EF Core 10 projesinde çalıştır ve farkı gör.

★★★ Review tablosunda BookId ve Rating ile filtreleyen bir sorgu yaz, EXPLAIN QUERY PLAN çıktısına bak. Sonra HasIndex(r => new { r.BookId, r.Rating }) ile composite bir index ekle ve planın nasıl değiştiğini karşılaştır.

Performansın bir kısmı da gereksiz satırları hiç getirmemekten geçiyor. Silinmiş kayıtları ya da başka bir müşterinin verisini her sorguda elle filtrelemek hem yorucu hem riskli. Bir sonraki yazıda global query filter'lar ile soft delete ve multi-tenancy'yi modele nasıl gömeceğimizi göreceğiz.