Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Yazılım geliştirici

EF Core Sorgu Performansı: Projection, AsNoTracking ve AsSplitQuery

Yazar: Oğuzhan KARAGÜZEL ·

EF Core ile sorgu yazmak kolay: birkaç satır LINQ yazıyorsunuz, gerisini EF Core hallediyor. Bu kolaylığın bir de öbür yüzü var: arka planda hangi SQL'in çalıştığını görmüyorsunuz. Geliştirme ortamındaki yüz satırlık tabloda hiç fark etmediğiniz bir sorgu, canlıda yüz binlerce satırla karşılaşınca sayfayı saniyelerce bekletebiliyor. Bu yazıda EF Core sorgularını hızlandırmanın üç temel aracını, yani Select projeksiyonunu, AsNoTracking'i ve AsSplitQuery'yi, ürettikleri SQL'e bakarak anlatacağım.

Örneklerde EF Core serisindeki market modelini kullanıyorum: Musteri, Siparis, Urun ve Fatura entity'leri ve MarketDbContext. Bir müşterinin birden çok siparişi var; bir siparişin birden çok ürünü (çoka çok, SiparisUrun ara tablosu üzerinden) ve tek bir faturası var. Seriyi okumadıysanız da örnekleri rahatça takip edebilirsiniz.

Önce SQL'i görün

Bir sorguyu iyileştirmenin ilk adımı, EF Core'un onu hangi SQL'e çevirdiğini görmek. En kolay yolu ToQueryString() metodu; sorguyu çalıştırmadan, üretilecek SQL'i veriyor:

C#
using var context = new MarketDbContext();

var sorgu = context.Siparisler
    .Where(s => s.SiparisTarihi >= new DateTime(2026, 1, 1))
    .OrderBy(s => s.Id);

Console.WriteLine(sorgu.ToQueryString());

Uygulamanın çalıştırdığı bütün sorguları görmek isterseniz loglamayı açabilirsiniz. Konsol uygulamasında LogTo yetiyor. ASP.NET Core'da da Microsoft.EntityFrameworkCore.Database.Command kategorisinin log seviyesini Information yaparsanız aynı sonucu alırsınız:

C#
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(connectionString)
        .LogTo(Console.WriteLine, LogLevel.Information);
}

Burada önemli bir nokta var: yukarıdaki sorgu değişkeni henüz çalışmış bir sorgu değil, sadece sorgunun tarifi (IQueryable). Veritabanına sonucu isteyen bir çağrı yapıldığında gidiliyor: ToList(), First(), Count(), Any() ya da bir foreach döngüsü. O ana kadar eklediğiniz her Where, OrderBy ve Select SQL'e çevriliyor. Araya AsEnumerable() ya da ToList() girdikten sonra yazdığınız filtreler ise veritabanında değil, bellekte çalışıyor. context.Siparisler.AsEnumerable().Where(...) gibi bir satır bütün tabloyu uygulamaya çekip filtrelemeyi orada yapıyor.

Include yerine Select projeksiyonu

Bir sipariş listesi ekranı yaptığımızı düşünelim. Her satırda sipariş tarihi, müşterinin adı, ürün sayısı ve toplam tutar görünecek. İlk akla gelen, ilişkili verileri Include ile yüklemek:

C#
var siparisler = context.Siparisler
    .Include(s => s.Musteri)
    .Include(s => s.Urunler)
    .ToList();

EF Core bu sorgu için aşağıdakine benzer bir SQL üretiyor (okunması kolay olsun diye biraz sadeleştirdim):

SQL
SELECT [s].[Id], [s].[CreatedDate], [s].[MusteriId], [s].[SiparisTarihi],
       [m].[Id], [m].[CreatedDate], [m].[Email], [m].[MusteriAdi],
       [s1].[SiparislerId], [s1].[UrunlerId], [s1].[Id], [s1].[CreatedDate], [s1].[Fiyat], [s1].[UrunAdi]
FROM [Siparisler] AS [s]
INNER JOIN [Musteriler] AS [m] ON [s].[MusteriId] = [m].[Id]
LEFT JOIN (
    SELECT [s0].[SiparislerId], [s0].[UrunlerId], [u].[Id], [u].[CreatedDate], [u].[Fiyat], [u].[UrunAdi]
    FROM [SiparisUrun] AS [s0]
    INNER JOIN [Urunler] AS [u] ON [s0].[UrunlerId] = [u].[Id]
) AS [s1] ON [s].[Id] = [s1].[SiparislerId]
ORDER BY [s].[Id], [m].[Id], [s1].[SiparislerId], [s1].[UrunlerId]

Bu sorgunun üç sorunu var. Ekranda kullanmadığımız sütunlar da okunuyor (CreatedDate, Email, ürün adları). Beş ürünlü bir sipariş beş satır olarak geliyor ve sipariş ile müşteri sütunları her satırda tekrarlanıyor. Bir de bütün nesneler değişiklik takibine alınıyor, oysa biz sadece ekranda göstereceğiz.

Aynı ekranı bu sefer Select ile, sadece gereken alanları seçerek yazalım:

C#
var siparisler = context.Siparisler
    .Select(s => new SiparisOzetDto(
        s.Id,
        s.SiparisTarihi,
        s.Musteri.MusteriAdi,
        s.Urunler.Count,
        s.Urunler.Sum(u => u.Fiyat)))
    .ToList();

public sealed record SiparisOzetDto(
    int Id,
    DateTime SiparisTarihi,
    string MusteriAdi,
    int UrunSayisi,
    decimal Toplam);
SQL
SELECT [s].[Id], [s].[SiparisTarihi], [m].[MusteriAdi], (
    SELECT COUNT(*)
    FROM [SiparisUrun] AS [s0]
    INNER JOIN [Urunler] AS [u] ON [s0].[UrunlerId] = [u].[Id]
    WHERE [s].[Id] = [s0].[SiparislerId]), (
    SELECT COALESCE(SUM([u0].[Fiyat]), 0.0)
    FROM [SiparisUrun] AS [s1]
    INNER JOIN [Urunler] AS [u0] ON [s1].[UrunlerId] = [u0].[Id]
    WHERE [s].[Id] = [s1].[SiparislerId])
FROM [Siparisler] AS [s]
INNER JOIN [Musteriler] AS [m] ON [s].[MusteriId] = [m].[Id]

Artık her sipariş tek satır olarak geliyor. Sayma ve toplama işini veritabanı yapıyor, uygulamaya da sadece ekranda göstereceğimiz beş değer taşınıyor. Dikkat ederseniz hiç Include yazmadık: Select içinde s.Musteri.MusteriAdi gibi bir ilişkiye eriştiğinizde gereken JOIN'i EF Core kendisi ekliyor. Sonuç bir entity değil DTO olduğu için değişiklik takibine de girmiyor.

Include'u ise entity'leri okuyup değiştireceğiniz yerlerde kullanın. Siparişi ürünleriyle birlikte yükleyip bir ürünü çıkaracak ve SaveChanges çağıracaksanız tam entity'lere ihtiyacınız var. Sadece gösterilecek veride ise projeksiyon neredeyse her zaman daha hafif.

AsNoTracking: değişiklik takibini kapatmak

EF Core bir sorgudan entity döndürdüğünde varsayılan olarak her birini change tracker'a kaydediyor. Nesnenin o anki değerlerinin bir kopyasını tutuyor ve SaveChanges çağrıldığında neyin değiştiğini bu kopyayla karşılaştırarak buluyor. Aynı satırı ikinci kez okursanız yeni bir nesne oluşturmak yerine takip ettiği nesneyi geri veriyor (identity resolution).

Okuyup değiştireceğiniz veride bu çok işe yarıyor. Sadece göstereceğiniz veride ise boşa giden bellek ve işlemci zamanı. AsNoTracking() bu kaydı kapatıyor:

C#
var siparisler = context.Siparisler
    .AsNoTracking()
    .Include(s => s.Musteri)
    .Where(s => s.SiparisTarihi >= new DateTime(2026, 1, 1))
    .ToList();

Bilmeniz gereken birkaç şey:

  • Takip edilmeyen nesne kaydedilmez. AsNoTracking ile okuduğunuz bir siparişin tarihini değiştirip SaveChanges çağırırsanız hiçbir şey olmaz, çünkü EF Core o nesneden habersiz. Güncelleme yapacağınız sorgularda takibi açık bırakın.
  • Aynı satır, ayrı nesneler. Takip kapalıyken identity resolution da yapılmıyor. Aynı müşterinin 100 siparişini Include(s => s.Musteri) ile okursanız bellekte 100 ayrı Musteri nesnesi oluşuyor. Bu sizin için sorunsa AsNoTrackingWithIdentityResolution() kullanın: takip yapmıyor ama aynı satır için aynı nesneyi veriyor.
  • Projeksiyonda gerek yok. Sonucu bir DTO'ya Select ettiğinizde zaten takip yapılmıyor, ayrıca AsNoTracking yazmanıza gerek yok.
  • Varsayılanı değiştirebilirsiniz. Çoğunlukla okuma yapan bir uygulamada takibi context seviyesinde kapatıp sadece gereken sorgularda AsTracking() ile açabilirsiniz:
C#
optionsBuilder
    .UseSqlServer(connectionString)
    .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

Tekrarlanan satırlar ve AsSplitQuery

Bir müşteriyi siparişleri ve o siparişlerdeki ürünlerle birlikte yükleyelim:

C#
var musteri = context.Musteriler
    .Include(m => m.Siparisler)
        .ThenInclude(s => s.Urunler)
    .Single(m => m.Id == musteriId);

EF Core varsayılan olarak bunu JOIN'lerle tek bir sorguya çeviriyor. Müşterinin 50 siparişi ve her siparişte ortalama 4 ürün varsa veritabanından yaklaşık 200 satır geliyor. Müşterinin bütün sütunları bu 200 satırın her birinde, her siparişin sütunları da ürün sayısı kadar tekrarlanıyor. Aynı seviyede iki koleksiyonu birlikte yüklediğinizde durum daha da kötüleşiyor: müşterinin 50 siparişi ve 10 adresi olsaydı ve ikisini birden Include etseydik, sonuç 50 × 10 = 500 satır olurdu. Buna kartezyen patlama deniyor. EF Core da birden çok koleksiyon yükleyen sorgularda bu riske karşı loga bir uyarı yazıyor.

AsSplitQuery() aynı yüklemeyi her koleksiyon için ayrı bir SQL sorgusuyla yapıyor:

C#
var musteri = context.Musteriler
    .Include(m => m.Siparisler)
        .ThenInclude(s => s.Urunler)
    .AsSplitQuery()
    .Single(m => m.Id == musteriId);
SQL
-- 1. Müşteri
SELECT [m].[Id], [m].[CreatedDate], [m].[Email], [m].[MusteriAdi]
FROM [Musteriler] AS [m]
WHERE [m].[Id] = @musteriId

-- 2. Müşterinin siparişleri
SELECT [s].[Id], [s].[CreatedDate], [s].[MusteriId], [s].[SiparisTarihi]
FROM [Siparisler] AS [s]
WHERE [s].[MusteriId] = @musteriId
ORDER BY [s].[Id]

-- 3. Bu siparişlerin ürünleri
SELECT [su].[SiparislerId], [u].[Id], [u].[CreatedDate], [u].[Fiyat], [u].[UrunAdi]
FROM [Siparisler] AS [s]
INNER JOIN [SiparisUrun] AS [su] ON [s].[Id] = [su].[SiparislerId]
INNER JOIN [Urunler] AS [u] ON [su].[UrunlerId] = [u].[Id]
WHERE [s].[MusteriId] = @musteriId
ORDER BY [s].[Id]

Yukarıdaki SQL'i mantığı göstermek için sadeleştirdim; EF Core'un ürettiği gerçek sorgularda sürüme göre ek alt sorgular ve sıralama sütunları oluyor. Üç sorgu toplamda 1 + 50 + 200 satır getiriyor ve hiçbir satırda gereksiz tekrar yok. EF Core da sonuçları bellekte birleştirip aynı nesne yapısını kuruyor.

Ama AsSplitQuery her zaman daha iyi değil:

  • Her sorgu ayrı bir gidiş-dönüş. Veritabanı ağ üzerinden uzaktaysa üç sorgunun toplam gecikmesi tek sorgununkinden fazla olur. Koleksiyonlar küçükse tek sorgu çoğu zaman daha hızlıdır. Küçük projelerde ya da az satırlı ilişkilerde split query'nin kazandırdığından fazlasını kaybettirmesinin sebebi bu.
  • Sorgular arasında tutarlılık garantisi yok. İlk sorguyla ikincisi arasında başka bir işlem veriyi değiştirirse sonuçlar birbirini tutmayabilir. Bu sizin için önemliyse sorguları bir transaction içinde (ör. snapshot isolation ile) çalıştırın.
  • Sayfalama yapıyorsanız sıralama benzersiz olmalı. Skip/Take kullanıyorsanız OrderBy'ın benzersiz bir sütuna (ör. Id) göre olması gerekiyor. Yoksa ayrı sorgular farklı satırlar getirebilir.

İsterseniz split query'yi bütün context için varsayılan yapıp tek sorguyu gereken yerlerde AsSingleQuery() ile seçebilirsiniz:

C#
optionsBuilder.UseSqlServer(connectionString,
    o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));

Kararı tahminle değil ölçerek verin. Aynı sorguyu iki şekilde çalıştırıp süreyi ve dönen satır sayısını karşılaştırmak birkaç dakikanızı alır.

Küçük ama etkili diğer kurallar

  • Filtreyi veritabanında uygulayın. Where, ToList()'ten önce gelmeli. Önce listeyi alıp sonra filtrelerseniz bütün tabloyu taşımış olursunuz.
  • Kayıt var mı diye bakarken Any() kullanın. Count() > 0 eşleşen bütün satırları sayar, Any() ilk eşleşmede durur.
  • Listeleri sayfalayın. OrderBy + Skip + Take ile sadece göstereceğiniz kadarını alın. Çok ilerideki sayfalarda Skip yavaşlar; son görülen Id'den devam eden sayfalama (keyset pagination) daha hızlıdır.
  • N+1 sorgusundan kaçının. Lazy loading açıksa bir döngünün içinde siparis.Musteri.MusteriAdi'ye erişmek her turda ayrı bir sorgu çalıştırır. İhtiyacınız olanı projeksiyonla ya da Include ile tek seferde alın.
  • Toplu işlemlerde entity yüklemeyin. EF Core 7 ve sonrasında ExecuteUpdate ve ExecuteDelete, satırları belleğe almadan tek bir SQL komutuyla günceller ya da siler.
  • Web uygulamalarında async metotları kullanın. ToListAsync() sorguyu hızlandırmaz ama beklerken iş parçacığını serbest bırakır. Böylece sunucu aynı anda daha çok isteğe cevap verebilir.
  • Doğru indeksleri unutmayın. EF Core en iyi SQL'i üretse bile uygun indeks yoksa veritabanı tabloyu tarar. Bunun için SQL Server indeksleri yazısına bakabilirsiniz.

Hangi durumda hangisi?

DurumÖneri
Liste, rapor ya da API cevabı; veri sadece okunuyorSelect ile DTO'ya projeksiyon
Entity'leri okuyup gösteriyorsunuz ama değiştirmeyeceksinizAsNoTracking()
Okuyup değiştirip kaydedeceksinizTakip açık (varsayılan) ve sadece gereken Include'lar
Birden çok ya da büyük koleksiyonu birlikte yüklüyorsunuzAsSplitQuery(), ölçerek
Küçük, tek koleksiyonlu bir sorguTek sorgu (varsayılan)
Çok sayıda satırı güncelleme ya da silmeExecuteUpdate / ExecuteDelete

Sonuç

EF Core sorgularını hızlandırmanın sırrı, LINQ'in arkasındaki SQL'i görmek. Sadece göstereceğiniz veride Select ile ihtiyacınız olan alanları alın. Entity okuyup değiştirmeyecekseniz AsNoTracking kullanın. Birden çok koleksiyonu birlikte yüklerken satırların tekrarlanmasına dikkat edin ve AsSplitQuery'yi ölçerek deneyin. Bu yazı EF Core serisinin devamı gibi düşünülebilir. Sitenin genel hızıyla ilgili diğer konular için de Web Sitesi Hızı ve Core Web Vitals yazıma bakabilirsiniz.

Daha ayrıntılı bilgi isterseniz Microsoft'un EF Core belgelerindeki "Efficient Querying", "Tracking vs. No-Tracking Queries" ve "Single vs. Split Queries" sayfaları iyi bir kaynak.